Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c95db8eebc | ||
|
|
6202543b21 | ||
|
|
d0a7b262d2 | ||
|
|
51a005dd43 | ||
|
|
b84d37b5a0 | ||
|
|
9ec8350fc3 | ||
|
|
3ccfd01699 | ||
|
|
79d755cd98 | ||
|
|
5fbf04f56d | ||
|
|
85980b425e | ||
|
|
107fa3472d | ||
|
|
0bfc7fe913 | ||
|
|
3f7fbf1061 | ||
|
|
f1dc6c9f94 | ||
|
|
5d8682d7b7 | ||
|
|
030bc8a1c2 | ||
|
|
346385bad8 | ||
|
|
0026143164 | ||
|
|
ba16237c35 | ||
|
|
981f32b8f6 | ||
|
|
a9a1b923a2 | ||
|
|
124cb66324 |
+19
-19
@@ -4,12 +4,21 @@
|
||||
# or an accepted, documented risk. Keep this list TIGHT and justify every entry — an ignore here
|
||||
# means the audit job stops flagging it, so the reasoning must hold up.
|
||||
#
|
||||
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
|
||||
# `unmaintained` warnings (audiopus_sys via opus, paste via utoipa-axum). Both are transitive, at
|
||||
# their latest published version with no successor, so there's nothing to bump — left visible on
|
||||
# purpose so we keep getting the maintenance signal; they do not fail CI. (rustls-pemfile was dropped
|
||||
# 2026-06-29 by removing axum-server's unused tls-rustls feature + moving our own PEM parsing to
|
||||
# rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11 bump.)
|
||||
# ⚠ NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities* — `unmaintained` AND
|
||||
# `unsound` advisories are warnings that do NOT fail CI. That is deliberate for the two unmaintained
|
||||
# crates below, but it does mean an unsoundness can sit here unnoticed: RUSTSEC-2026-0221
|
||||
# (event-listener) did exactly that until the 2026-08-13 sweep. Read the job's warnings, not just
|
||||
# its exit code.
|
||||
#
|
||||
# The two unmaintained ones, both transitive with no successor to bump to, left visible on purpose
|
||||
# so we keep getting the maintenance signal:
|
||||
# * audiopus_sys via opus (opus itself IS maintained; only its -sys layer is stuck).
|
||||
# * paste via BOTH utoipa-axum (host) and rav1d (client decode path) — an earlier version of this
|
||||
# note named only utoipa-axum, which would have made dropping utoipa-axum look like it cleared
|
||||
# paste. It would not: every client pulls it through rav1d.
|
||||
# (rustls-pemfile was dropped 2026-06-29 by removing axum-server's unused tls-rustls feature +
|
||||
# moving our own PEM parsing to rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11
|
||||
# bump.)
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
@@ -34,17 +43,8 @@ ignore = [
|
||||
# a constant-time rsa ships (then drop this), the host ever signs an attacker-chosen message with
|
||||
# this key, or any RSA decryption / key-transport using the private key is added.
|
||||
"RUSTSEC-2023-0071",
|
||||
|
||||
# quick-xml DoS advisories (RUSTSEC-2026-0194 quadratic-time duplicate-attribute check;
|
||||
# RUSTSEC-2026-0195 unbounded namespace-declaration allocation in NsReader). Both are
|
||||
# exploited by feeding attacker-controlled XML to a running parser. In this tree quick-xml is
|
||||
# a BUILD-TIME-ONLY, transitive dependency of `wayland-scanner` (a proc-macro that parses the
|
||||
# TRUSTED wayland protocol XML files shipped with the wayland-rs crates at compile time). It is
|
||||
# never linked into any shipped binary and never parses runtime/attacker-controlled input, so
|
||||
# neither DoS is reachable. There is no fix to bump to: wayland-scanner 0.31.10 (latest) pins
|
||||
# `quick-xml ^0.39`, and the fixes only exist in quick-xml >=0.41. Revisit (drop these) when
|
||||
# wayland-scanner releases against quick-xml >=0.41, or if quick-xml is ever pulled onto a
|
||||
# runtime path that parses untrusted XML.
|
||||
"RUSTSEC-2026-0194",
|
||||
"RUSTSEC-2026-0195",
|
||||
# The quick-xml DoS pair (RUSTSEC-2026-0194/0195) used to be ignored here, with the note
|
||||
# "revisit when wayland-scanner releases against quick-xml >=0.41". It has: wayland-scanner
|
||||
# 0.31.11 moved to `quick-xml ^0.41` and the lock is on 0.41.0 as of 2026-08-13, so both
|
||||
# entries were dropped rather than left as permanent exceptions.
|
||||
]
|
||||
|
||||
@@ -63,6 +63,15 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# The C/C++ half of the cache. The one that pays here is the CMake-built vendored libopus
|
||||
# (audiopus_sys), which kit/build.gradle.kts drives through cargo-ndk once per ABI — three
|
||||
# from-scratch libopus builds per run until now. The per-ABI compilers come from the NDK via
|
||||
# cargo-ndk's own CC_<android-triple> vars, which this does not touch; CC_x86_64_unknown_linux_gnu
|
||||
# covers only the HOST build scripts and proc macros.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -265,7 +274,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
|
||||
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
||||
# Generic registry is public for reads — matches windows-client.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
|
||||
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
|
||||
# to the unified Gitea Release.
|
||||
- name: Publish to generic registry + attach to Gitea release
|
||||
|
||||
+579
-41
@@ -1,12 +1,86 @@
|
||||
# Apple client CI — runs on the self-hosted macOS runner (home-mac-mini-1, host mode;
|
||||
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
|
||||
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
|
||||
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
|
||||
# Apple client CI **and** distribution — everything that runs on the self-hosted macOS runner
|
||||
# (home-mac-mini-1, host mode; see scripts/ci/setup-macos-runner.sh), in dependency order:
|
||||
#
|
||||
# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI
|
||||
# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches
|
||||
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
|
||||
# from the build/test job and best-effort, so a capture gap never reds the core signal.
|
||||
# swift — build the Rust core into PunktfunkCore.xcframework, then build + test the Swift
|
||||
# package. Network-dependent tests (RemoteFirstLightTests) self-skip without
|
||||
# PUNKTFUNK_REMOTE_HOST. Runs on pushes, tags AND pull requests.
|
||||
# distribute — needs: swift. The signed/notarized artifacts:
|
||||
# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached
|
||||
# to the Gitea release on tag pushes
|
||||
# macOS (App Store) -> archive + upload to TestFlight (App Store Connect)
|
||||
# iOS -> archive + upload to TestFlight, plus an exported .ipa
|
||||
# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets,
|
||||
# nightly -Zbuild-std, in build-xcframework.sh)
|
||||
# screenshots — needs: swift. App Store Connect screenshots of the REAL UI, attached to the run
|
||||
# as a zip artifact. Best-effort, so a capture gap never reds the core signal.
|
||||
#
|
||||
# ⚠ WHY THIS FILE IS ONE FILE. `distribute` used to live in its own workflow called `release.yml` —
|
||||
# a name that described neither what it did (Apple only) nor how releases actually work here (every
|
||||
# platform's packaging workflow attaches to the same Gitea release on a v* tag, and announce.yml is
|
||||
# the manual "go"). The name was the smaller problem. The real one: Gitea has no cross-workflow
|
||||
# `needs`, so nothing sequenced it against apple.yml's tests — a canary main push uploaded iOS,
|
||||
# macOS and tvOS builds to TestFlight even when `swift test` had just failed on the same commit,
|
||||
# and the two files' `paths:` filters had already drifted apart, so it was possible for one to fire
|
||||
# without the other. Merging is what makes `needs: swift` expressible. Do not split them again.
|
||||
#
|
||||
# The trigger list is deliberately NARROW on crates/: everything here is built from
|
||||
# `crates/punktfunk-core` (via scripts/build-xcframework.sh) and nothing else in the workspace.
|
||||
# VERIFY THAT BEFORE WIDENING OR TRUSTING IT — punktfunk-core's only path dependency is its own
|
||||
# vendored fec-rs, under crates/punktfunk-core/vendor/:
|
||||
# sed -n '/^\[dependencies\]/,/^\[/p' crates/punktfunk-core/Cargo.toml | grep path
|
||||
# If punktfunk-core ever gains a path dep on a sibling crate, add that crate here. Cargo.lock is a
|
||||
# partial safety net (it moves when the dep is ADDED) but not a complete one — later edits to that
|
||||
# crate would not fire this workflow. This is the same class of gap flatpak.yml documents.
|
||||
#
|
||||
# ── Signing / distribution notes (all of these belong to `distribute`) ────────────────────────────
|
||||
#
|
||||
# One App Store listing for all platforms (universal purchase): every target shares the
|
||||
# bundle ID io.unom.punktfunk.
|
||||
#
|
||||
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
||||
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
||||
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
|
||||
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
|
||||
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
|
||||
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
|
||||
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
|
||||
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
|
||||
#
|
||||
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
||||
# step is continue-on-error until they exist):
|
||||
# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record
|
||||
# (universal purchase).
|
||||
# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the
|
||||
# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/).
|
||||
# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in
|
||||
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
||||
# .pkg is installer-signed with it.
|
||||
#
|
||||
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
|
||||
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
|
||||
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
|
||||
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
|
||||
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
|
||||
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
|
||||
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
|
||||
# a launchable app.
|
||||
#
|
||||
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
||||
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
||||
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
||||
# ID Application + Apple Distribution, with the WWDR intermediate present (so they show as
|
||||
# *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain.
|
||||
# One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access:
|
||||
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <login-pw> \
|
||||
# ~/Library/Keychains/login.keychain-db
|
||||
#
|
||||
# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API
|
||||
# key — notarization, TestFlight upload, automatic-signing profile fetch).
|
||||
#
|
||||
# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow
|
||||
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
|
||||
# loud warning.
|
||||
name: apple
|
||||
# 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
|
||||
@@ -19,30 +93,39 @@ concurrency:
|
||||
|
||||
on:
|
||||
push:
|
||||
# Canary: a relevant main push builds + tests, then uploads the iOS + macOS + tvOS builds to
|
||||
# TestFlight (Apple's own canary channel) — no notarized DMG (that's stable-only; see the
|
||||
# per-step gates). Heavy on the shared mac-mini runner, hence the tight paths filter.
|
||||
branches: [main]
|
||||
# Scope canary builds to what this artifact is built FROM — a docs-only or
|
||||
# web-only push should not light up the whole fleet. Applies to branch pushes;
|
||||
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'clients/apple/**'
|
||||
- 'scripts/build-xcframework.sh'
|
||||
- 'scripts/ci/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/apple.yml'
|
||||
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
|
||||
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store. Tag runs are matched by
|
||||
# `tags:` and are NOT subject to the paths filter above.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'clients/apple/**'
|
||||
- 'scripts/build-xcframework.sh'
|
||||
- 'scripts/ci/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
- 'scripts/ci/**'
|
||||
- '.gitea/workflows/apple.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testflight:
|
||||
description: "Upload the iOS/macOS/tvOS builds to TestFlight (true/false)"
|
||||
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
|
||||
@@ -61,11 +144,11 @@ env:
|
||||
|
||||
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
|
||||
# code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's
|
||||
# "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral
|
||||
# runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and
|
||||
# still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
|
||||
# with the release-signing job below (which loads the App Store Connect key). Untrusted PR code could
|
||||
# persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's "require
|
||||
# approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral runners. The
|
||||
# `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and still runs
|
||||
# same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
|
||||
swift:
|
||||
runs-on: macos-arm64
|
||||
if: >-
|
||||
@@ -85,17 +168,10 @@ 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.
|
||||
# Shared compile cache. The script handles the macOS side (user-prefix install +
|
||||
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
|
||||
- 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
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# `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
|
||||
@@ -127,6 +203,475 @@ jobs:
|
||||
working-directory: clients/apple
|
||||
run: swift test
|
||||
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# ── Distribution ────────────────────────────────────────────────────────────────────────────────
|
||||
# `needs: swift` is the entire reason this lives here rather than in its own file: it is what makes
|
||||
# a failed `swift test` stop a TestFlight upload. Never demote it to a parallel job.
|
||||
distribute:
|
||||
needs: swift
|
||||
# Pushes to main (canary), v* tags (stable) and manual dispatch — never pull requests.
|
||||
if: gitea.event_name != 'pull_request'
|
||||
runs-on: macos-arm64
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
TEAM_ID: F4H37KF6WC
|
||||
PROJECT: clients/apple/Punktfunk.xcodeproj
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Select release Xcode
|
||||
run: |
|
||||
DEV_DIR=""
|
||||
for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do
|
||||
case "$app" in *beta*|*Beta*) continue;; esac
|
||||
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
|
||||
done
|
||||
if [ -z "$DEV_DIR" ]; then
|
||||
for app in /Applications/Xcode*.app; do
|
||||
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
|
||||
done
|
||||
echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds."
|
||||
fi
|
||||
[ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; }
|
||||
# Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must
|
||||
# keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the
|
||||
# running dyld rejects, killing proc-macro loads (see build-xcframework.sh).
|
||||
echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV"
|
||||
DEVELOPER_DIR="$DEV_DIR" xcodebuild -version
|
||||
|
||||
- name: Version from tag
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
# GITHUB_RUN_NUMBER is REPO-WIDE in Gitea (not per-workflow as on GitHub): consecutive runs
|
||||
# of different workflows get consecutive numbers. That is why folding the old release.yml
|
||||
# into this file could not reset the build number and strand TestFlight, which rejects a
|
||||
# non-increasing CFBundleVersion. It also means this climbs by ~8 per push rather than by 1
|
||||
# — monotonic either way, which is all App Store Connect asks.
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
|
||||
|
||||
- name: Rust toolchain (mac + iOS + tvOS slices)
|
||||
run: |
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
|
||||
# nightly + -Zbuild-std, so ensure nightly + rust-src are present.
|
||||
"$RUSTUP" toolchain install nightly --profile minimal
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
|
||||
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||
command -v cmake >/dev/null || "$BREW" install cmake
|
||||
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
# Shared compile cache. The script handles the macOS side — see scripts/ci/ensure-sccache.sh.
|
||||
- name: sccache (self-healing install)
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
- 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
|
||||
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
|
||||
# under ~/Library that nothing ever collected. 31 of them piled up in three days
|
||||
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
|
||||
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
|
||||
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
|
||||
#
|
||||
# The directory is still named `release` after the workflow this job used to live in. Left
|
||||
# alone deliberately: renaming it would orphan a warm ~760 MB tree and buy nothing.
|
||||
run: |
|
||||
DD="$HOME/ci/derived-data/release"
|
||||
mkdir -p "$DD"
|
||||
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
||||
# Safety net for trees the pin does not own: the legacy per-path ones from before this
|
||||
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
|
||||
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
|
||||
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
|
||||
#
|
||||
# This repeats the `swift` job's mac-slice build, and that is the intended trade: the two
|
||||
# jobs share the runner's sccache and DerivedData, so the overlap is cheap, whereas passing
|
||||
# an xcframework between jobs would mean uploading/downloading it through Gitea's artifact
|
||||
# backend (the one that already forces upload-artifact@v3) on every run.
|
||||
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Stage App Store Connect API key
|
||||
env:
|
||||
ASC_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||||
run: |
|
||||
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
|
||||
chmod 600 "$RUNNER_TEMP/asc.p8"
|
||||
|
||||
- name: macOS — archive, codesign Developer ID, notarize, DMG
|
||||
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
|
||||
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
run: |
|
||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||
# provisioning-profile gate at archive time; we re-assert that authorization below by
|
||||
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
|
||||
# Bundle is a single static binary.
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app"
|
||||
# Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build
|
||||
# uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix.
|
||||
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
||||
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
||||
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
||||
|
||||
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
|
||||
# network/device keys are self-asserted for Developer ID, but a keychain access group
|
||||
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
|
||||
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
|
||||
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
|
||||
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
|
||||
# entitlements authorize the access group, exactly like the App Store build's profile
|
||||
# does. Located by profile Name among the profiles installed on the runner (see header).
|
||||
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
|
||||
PROFILE_SRC=""
|
||||
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
|
||||
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
|
||||
[ -e "$p" ] || continue
|
||||
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
|
||||
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
|
||||
done
|
||||
if [ -n "$PROFILE_SRC" ]; then
|
||||
# Must land BEFORE codesign so it's sealed into the bundle.
|
||||
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
|
||||
echo "embedded Developer ID profile: $PROFILE_SRC"
|
||||
else
|
||||
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
|
||||
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
|
||||
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
|
||||
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
|
||||
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$RESOLVED" \
|
||||
--sign "Developer ID Application" "$APP"
|
||||
codesign --verify --strict --verbose=2 "$APP"
|
||||
# Notarized DMG.
|
||||
STAGE="$RUNNER_TEMP/dmg-stage"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg"
|
||||
hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \
|
||||
--key "$RUNNER_TEMP/asc.p8" \
|
||||
--key-id "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
--issuer "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
|
||||
echo "DMG=$DMG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Attach DMG to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
|
||||
|
||||
- name: macOS App Store — archive + upload to TestFlight
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Best-effort until the App Store Connect record has the macOS platform + the
|
||||
# "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer
|
||||
# Installer" cert are on the runner (see the header). The macOS app is sandboxed
|
||||
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
|
||||
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
|
||||
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
|
||||
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
|
||||
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
|
||||
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
|
||||
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
|
||||
# regenerate the managed *development* profile — needed because the App Groups capability
|
||||
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
|
||||
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
|
||||
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
|
||||
# step below
|
||||
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||
# distribution profile that export needs.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk macOS App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>upload</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-macos-appstore" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: iOS — archive + upload to TestFlight
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
|
||||
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
|
||||
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
|
||||
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
|
||||
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
|
||||
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
|
||||
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
|
||||
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
|
||||
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
|
||||
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
|
||||
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
|
||||
# App Store distribution profile survives for export.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
|
||||
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
|
||||
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
|
||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>upload</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>io.unom.punktfunk</key><string>$PROFILE</string>
|
||||
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-appstore" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: iOS — export .ipa (Gitea release + run artifact)
|
||||
# The TestFlight step above uploads straight to App Store Connect (destination=upload) and
|
||||
# leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an
|
||||
# App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as
|
||||
# that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent,
|
||||
# e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs
|
||||
# only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact.
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
id: ios_ipa
|
||||
run: |
|
||||
ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive"
|
||||
if [ ! -d "$ARCHIVE" ]; then
|
||||
echo "::warning::iOS archive not found — skipping .ipa export"
|
||||
exit 0
|
||||
fi
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
||||
# destination=export writes the .ipa to -exportPath; otherwise identical manual signing to
|
||||
# the upload plist (both profiles, Apple Distribution). No ASC key needed — no network.
|
||||
cat > "$RUNNER_TEMP/export-appstore-ipa.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>export</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>io.unom.punktfunk</key><string>$PROFILE</string>
|
||||
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-ipa"
|
||||
SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1)
|
||||
[ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; }
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
||||
IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa"
|
||||
mv "$SRC" "$IPA"
|
||||
echo "IPA=$IPA" >> "$GITHUB_ENV"
|
||||
echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT"
|
||||
echo "exported $IPA"
|
||||
|
||||
- name: Attach .ipa to the workflow run
|
||||
if: steps.ios_ipa.outputs.ipa != ''
|
||||
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
|
||||
# (same reason as android.yml / the screenshots job below). Download is a zip of the .ipa.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-ios-ipa
|
||||
path: ${{ steps.ios_ipa.outputs.ipa }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Attach .ipa to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$IPA" "Punktfunk-$VERSION.ipa"
|
||||
|
||||
- name: tvOS — archive + upload to TestFlight
|
||||
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
|
||||
# on every apple push (above), so this matches the iOS step's gate exactly.
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
|
||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
|
||||
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
|
||||
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
|
||||
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
|
||||
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
|
||||
# DISTRIBUTION signing is the export step below (manual, plist).
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||
-destination 'generic/platform=tvOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>upload</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-tvos" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
|
||||
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
|
||||
# first, and is a separate job so a capture hiccup can never red the core signal.
|
||||
@@ -155,17 +700,10 @@ 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.
|
||||
# Shared compile cache. The script handles the macOS side (user-prefix install +
|
||||
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
|
||||
- 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
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# 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)
|
||||
@@ -183,7 +721,7 @@ 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)
|
||||
- name: Pin + prune DerivedData (same disease the distribute job 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
|
||||
|
||||
@@ -81,6 +81,14 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# The C/C++ half of the cache (aws-lc-sys, vendored libopus, openh264's C++). Safe at workflow
|
||||
# level — no cross-compiling job here (see ci.yml's `rust` job for that trap). NOTE these ALSO
|
||||
# have to be named in the makepkg step's `sudo -u builder env …` list: sudo's env_reset drops
|
||||
# everything not listed, which is why the sccache vars are already spelled out there.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
@@ -220,6 +228,39 @@ jobs:
|
||||
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||
echo "pacman $V-$R -> repo '$REPO'"
|
||||
|
||||
# ── The web console, built once per (web+sdk content, bun) instead of once per job ─────────
|
||||
# Shares deb.yml's key family — see the fuller note there. Unlike the RPM leg this needs no
|
||||
# hand-off macro: makepkg builds with PF_SRCDIR pointing at this workspace, so a restored
|
||||
# web/.output is already exactly where PKGBUILD's build-if-missing guard looks for it.
|
||||
#
|
||||
# Built here as root, BEFORE the makepkg step's `chown -R builder:` sweeps the tree, so the
|
||||
# bundle ends up owned like everything else the builder user is handed.
|
||||
- name: Web console cache key
|
||||
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
|
||||
- name: Cache the built web console
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
|
||||
- name: Build the web console (cache miss only)
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
run: |
|
||||
if [ ! -f web/.output/server/index.mjs ]; then
|
||||
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
|
||||
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
|
||||
echo "web console present: $(du -sh web/.output | cut -f1)"
|
||||
|
||||
- name: Build packages (makepkg)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
@@ -252,8 +293,19 @@ jobs:
|
||||
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" \
|
||||
CMAKE_C_COMPILER_LAUNCHER="$CMAKE_C_COMPILER_LAUNCHER" \
|
||||
CMAKE_CXX_COMPILER_LAUNCHER="$CMAKE_CXX_COMPILER_LAUNCHER" \
|
||||
CC_x86_64_unknown_linux_gnu="$CC_x86_64_unknown_linux_gnu" \
|
||||
CXX_x86_64_unknown_linux_gnu="$CXX_x86_64_unknown_linux_gnu" \
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
# Visibility only. The stats have to be read as the SAME user that ran the compiles —
|
||||
# sccache keeps its stats in a per-user server process, so a root `--show-stats` here
|
||||
# would report an idle server and zero everything.
|
||||
sudo -u builder env 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" \
|
||||
sccache --show-stats || true
|
||||
|
||||
# The host must ship a VERSIONED libav soname dep, and nothing else in this pipeline proves
|
||||
# it. packaging/arch/PKGBUILD lists bare `libavcodec.so` etc. and relies on makepkg rewriting
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
|
||||
# it lands).
|
||||
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
|
||||
# ⚠ ALL FIVE Rust lockfiles, each named with its own `--file`: a bare `cargo audit`
|
||||
# reads only the root one, which is how the drivers lock went unscanned for so
|
||||
# long despite already being in this job's `paths:` filter.
|
||||
# * bun audit → each Bun-managed tree that ships or publishes: web (the mgmt console BFF —
|
||||
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
|
||||
# plugin-kit (@punktfunk/plugin-kit).
|
||||
@@ -11,7 +14,7 @@
|
||||
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
|
||||
# verified against the LIVE site (the docs don't build standalone) — tracked in
|
||||
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
|
||||
# * cargo-about → license-allowlist gate over BOTH Rust workspaces (about.toml `accepted`);
|
||||
# * cargo-about → license-allowlist gate over the host + driver workspaces (about.toml `accepted`);
|
||||
# fails if any crate carries a license outside the allowlist — the regression
|
||||
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
|
||||
# nothing scans it — see the CRA roadmap.)
|
||||
@@ -47,6 +50,9 @@ on:
|
||||
paths:
|
||||
- 'Cargo.lock'
|
||||
- 'packaging/windows/drivers/Cargo.lock'
|
||||
- 'packaging/windows/pf-vkhdr-layer/Cargo.lock'
|
||||
- 'tools/win-input-matrix/Cargo.lock'
|
||||
- 'tools/hid-descriptor-dump/Cargo.lock'
|
||||
- 'web/bun.lock'
|
||||
- 'docs-site/bun.lock'
|
||||
- 'sdk/bun.lock'
|
||||
@@ -83,7 +89,15 @@ jobs:
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit
|
||||
# Bare `cargo audit` scans ONLY the root Cargo.lock. The other three Rust workspaces are
|
||||
# separate locks and were silently never scanned — the drivers one despite already being
|
||||
# in this job's `paths:` filter, so edits to it triggered a run that then ignored it.
|
||||
# Each needs its own `--file`. `pf-vkhdr-layer` had no lockfile at all until 2026-08-13.
|
||||
cargo audit
|
||||
cargo audit --file packaging/windows/drivers/Cargo.lock
|
||||
cargo audit --file packaging/windows/pf-vkhdr-layer/Cargo.lock
|
||||
cargo audit --file tools/win-input-matrix/Cargo.lock
|
||||
cargo audit --file tools/hid-descriptor-dump/Cargo.lock
|
||||
|
||||
bun-audit:
|
||||
strategy:
|
||||
|
||||
@@ -38,14 +38,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
- name: Prep
|
||||
run: |
|
||||
|
||||
+56
-18
@@ -23,6 +23,12 @@ on:
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
|
||||
# never collide; every Rust job on every host feeds and reads one warm cache.
|
||||
#
|
||||
# RUSTC_WRAPPER covers RUST compilations and nothing else. The C/C++ half of this workspace —
|
||||
# aws-lc-sys, openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys, pyrowave —
|
||||
# was paid in full on every run until the CMAKE_*_COMPILER_LAUNCHER / CC_* wiring below existed.
|
||||
# Linking is the third phase and is cacheable by nothing: that one is addressed in the builder
|
||||
# images with mold (ci/cargo-config-mold.toml).
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
@@ -30,6 +36,11 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# Route CMake-driven C/C++ through the same cache. Safe at workflow level: it names no
|
||||
# triple, and cmake-rs overrides it per-invocation with a `-D` flag when cc-rs reports a
|
||||
# wrapper, so the two can never double-wrap into `sccache sccache cc`.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -40,18 +51,25 @@ jobs:
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# cc-rs recognises `sccache` as a compiler wrapper when it leads CC/CXX, and cmake-rs then
|
||||
# forwards it as -DCMAKE_C_COMPILER_LAUNCHER, so this covers both build-script styles.
|
||||
#
|
||||
# ⚠ JOB-scoped, NOT workflow-scoped, and it must stay that way: the `rust-arm64` job below
|
||||
# runs in the cross image, which sets CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc
|
||||
# (ci/rust-ci-arm64cross.Dockerfile) — a wrapper that strips arm64 include dirs off
|
||||
# HOST-targeted compiles so ffmpeg-sys-next's probe resolves against the amd64 headers.
|
||||
# Setting this at workflow level would silently overwrite that wrapper and break the cross
|
||||
# build in a way that looks like a header mismatch, not a CI config error.
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# punktfunk-client-linux link deps. Also baked into rust-ci.Dockerfile — but ci.yml
|
||||
# runs against the image from the PREVIOUS push (docker.yml bootstrap note), so this
|
||||
@@ -105,8 +123,17 @@ jobs:
|
||||
# out of disk mid-build and actions/cache saved a truncated target/ (a dep's .rmeta
|
||||
# went missing -> E0463 "can't find crate"). A suffix bump wouldn't help — restore-keys
|
||||
# would fall back to the poisoned prefix — so the prefix itself is versioned.
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
#
|
||||
# `-debug-`: THIS JOB BUILDS DEBUG. deb.yml builds RELEASE and used to share this exact
|
||||
# key, with a comment claiming the release build "reuses ci.yml's clean artifacts" — it
|
||||
# never could. actions/cache is first-saver-wins on an exact key and this job is the
|
||||
# faster of the two, so what landed under the shared key was always a debug-only target/
|
||||
# (target/debug, no target/release). deb.yml restored a tree containing nothing it could
|
||||
# use and, because the key was already taken, never got to save its own — so every
|
||||
# release build re-linked from scratch, forever. Splitting the profiles into separate key
|
||||
# families is the fix; do not merge them again, however tempting the dedupe looks.
|
||||
key: cargo-target-debug-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-debug-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
@@ -220,14 +247,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
@@ -242,8 +264,16 @@ jobs:
|
||||
with:
|
||||
path: target
|
||||
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
|
||||
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
|
||||
#
|
||||
# `-debug-` in the name is load-bearing. This job builds DEBUG (clippy + a
|
||||
# `cargo build`), while deb.yml's arm64 leg builds RELEASE into the same
|
||||
# target/aarch64-unknown-linux-gnu tree. They used to share this exact key, and
|
||||
# actions/cache is first-saver-wins on an exact key: this job finishes in ~1.5 min and
|
||||
# saved a debug-only tree, so the .deb leg's release artifacts were NEVER persisted and
|
||||
# it re-linked everything from sccache on every run. Same disease as the amd64 pair —
|
||||
# see the note on deb.yml's `cargo-target-release-v1-` key.
|
||||
key: cargo-target-arm64-debug-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-debug-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Clippy for aarch64 (deny warnings)
|
||||
run: |
|
||||
@@ -259,6 +289,14 @@ jobs:
|
||||
cargo build --release --target aarch64-unknown-linux-gnu --locked \
|
||||
-p punktfunk-client-session --no-default-features
|
||||
|
||||
# Visibility only — but the ONLY way to tell a warm cache from a cold one after the fact.
|
||||
# Every Rust job in this repo ends with this line for that reason; a hit rate that quietly
|
||||
# collapses (a toolchain bump, a flag change, an S3 outage) is otherwise invisible and just
|
||||
# looks like "CI got slower".
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
|
||||
+288
-131
@@ -1,6 +1,6 @@
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
|
||||
# apt distribution/component:
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Debian and
|
||||
# Ubuntu boxes get new builds via `apt update && apt upgrade`. Five jobs; the four builders all
|
||||
# publish to the same apt distribution/component, and the fifth checks the result:
|
||||
#
|
||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
||||
@@ -11,8 +11,17 @@
|
||||
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
||||
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
||||
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
||||
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
|
||||
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
|
||||
# on Ubuntu 24.04 LTS through 26.04 — and, for free, on Debian 13.
|
||||
# (A 26.04-built host .deb is uninstallable on 24.04 — the reason this job
|
||||
# exists; see packaging/debian/README.md.)
|
||||
# build-publish-gamescope
|
||||
# — the patched `punktfunk-gamescope`, on DEBIAN 13. It lived in the host job
|
||||
# until 2026-08 and never once succeeded there: noble's wayland is 1.22.0
|
||||
# and the vendored wlroots floors it at 1.23.1, so v0.26.0 and v0.27.0 both
|
||||
# shipped without the package while the docs told apt users to install it.
|
||||
# smoke-install — installs what was just published, from the registry, in pristine
|
||||
# ubuntu:24.04 / ubuntu:26.04 / debian:trixie images. Nothing here used to
|
||||
# install a package it built, which is how both of the above survived.
|
||||
#
|
||||
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
|
||||
# host and client packages always share a version line. The release-attach helpers are race-safe
|
||||
@@ -70,6 +79,11 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# CMake-driven C/C++ through the same cache (aws-lc-sys, the vendored libopus). Workflow level
|
||||
# is safe — it names no triple; the CC_*/CXX_* half is per-job below, because the arm64 leg's
|
||||
# image needs its own CC_x86_64 wrapper. See ci.yml's `rust` job for the full note.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -80,18 +94,18 @@ jobs:
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# ⚠ Job-scoped, never workflow-scoped: build-publish-client-arm64 runs in the cross image,
|
||||
# whose own CC_x86_64_unknown_linux_gnu=pf-host-cc must survive. See ci.yml's `rust` job.
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
|
||||
@@ -129,7 +143,9 @@ jobs:
|
||||
apt-get install -y --no-install-recommends dpkg-dev python3 \
|
||||
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||
|
||||
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||
# The cargo-home (registry/git) cache IS shared with ci.yml — those are pure downloads,
|
||||
# profile-independent, and deduping them across the fleet is the whole point. The target
|
||||
# cache is NOT; see below.
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
@@ -142,10 +158,20 @@ jobs:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml). Shares the
|
||||
# key with ci.yml so the release build reuses its clean artifacts.
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml).
|
||||
#
|
||||
# `-release-`, and NOT ci.yml's key. This step used to read
|
||||
# `cargo-target-v3-<rustc>-<lock>` — byte-identical to ci.yml's — under a comment saying
|
||||
# the release build "reuses its clean artifacts". It never did, and could not: ci.yml
|
||||
# builds DEBUG, this job builds RELEASE, and actions/cache is first-saver-wins on an
|
||||
# exact key. ci.yml's `rust` job finishes in ~6 min against this job's ~7-8, so ci.yml
|
||||
# always won the save, this job always restored a target/ with an empty target/release,
|
||||
# and — the expensive half — its own release artifacts were then never persisted,
|
||||
# because the key it would have saved under was already taken. Every canary .deb has
|
||||
# therefore been a from-scratch release build (sccache-assisted, but every link and
|
||||
# every build script re-run) for as long as both keys have existed.
|
||||
key: cargo-target-release-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-release-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Build release clients
|
||||
env:
|
||||
@@ -163,7 +189,31 @@ jobs:
|
||||
cargo build --release --locked \
|
||||
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
|
||||
|
||||
# ── The web console, built ONCE per (web+sdk content, bun) rather than once per job ────────
|
||||
# This bundle was being rebuilt six times on every push — ci.yml, here, both RPM legs, arch,
|
||||
# and the docker app image — at ~2.5 min each, for output that is a pure function of web/ and
|
||||
# sdk/. windows-host.yml has cached it this way for a while; this is the same arrangement for
|
||||
# the Linux packaging legs, sharing one key family so a hit in one warms the others.
|
||||
#
|
||||
# The bun version is IN THE KEY. Each builder image installs its own bun (rust-ci, fedora-rpm
|
||||
# and arch-ci each run the bun.sh installer at image-build time), so without it a bundle built
|
||||
# by one image's bun could be shipped by a job running a different one. They are usually the
|
||||
# same version and do share; when they diverge, they simply stop sharing instead of silently
|
||||
# mixing.
|
||||
- name: Web console cache key
|
||||
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
|
||||
- name: Cache the built web console
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
|
||||
- name: Build + smoke-boot web console (bun preset)
|
||||
# Skipped when the cache already holds this exact (web+sdk, bun) bundle — the assertion step
|
||||
# below is what makes that safe. The bundle in the cache was smoke-booted by the run that
|
||||
# produced it, and ci.yml's `web` job still builds and tests the console on every push.
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||
# (our Bun.serve TLS entry), so prove the build IS a bun bundle and serves /login.
|
||||
# No TLS env here, so the custom entry binds plain HTTP — the smoke curl stays simple.
|
||||
@@ -176,7 +226,12 @@ jobs:
|
||||
}
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
# --ignore-scripts, like every other web install in CI (ci.yml, web-screenshots.yml,
|
||||
# windows-host.yml, the SDK installs). This was the ONE site still running lifecycle
|
||||
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
|
||||
# neither consumes nor commits, whose only effect here is to make the install depend on
|
||||
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
|
||||
@@ -188,6 +243,22 @@ jobs:
|
||||
echo "web console smoke: /login -> $code"
|
||||
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under bun"; exit 1; }
|
||||
|
||||
# ⚠ NOT optional, and it must run on BOTH paths (cache hit and fresh build). The packaging
|
||||
# scripts treat a missing web/.output as "build it yourself", which is right for a local run
|
||||
# and wrong here: it would silently turn a broken cache restore into a slow, quiet rebuild, or
|
||||
# — with the build step skipped and the restore empty — into a package with no console at all.
|
||||
# windows-host.yml shipped exactly that in 0.22.1/0.22.2 (an unset WEB_OUTPUT_DIR behind a
|
||||
# single Write-Host), which is why its equivalent step throws too. Fail loudly instead.
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
run: |
|
||||
if [ ! -f web/.output/server/index.mjs ]; then
|
||||
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
|
||||
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
|
||||
echo "web console present: $(du -sh web/.output | cut -f1)"
|
||||
|
||||
- name: Build .debs
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
@@ -198,6 +269,13 @@ jobs:
|
||||
# The plugin/script runner (bun-bundled Effect SDK) — same vendored-bun mechanics.
|
||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-scripting-deb.sh
|
||||
|
||||
# Visibility only. With the target cache now actually saving release artifacts (see the
|
||||
# cache key note above), this is how a regression in that arrangement becomes visible:
|
||||
# a run that suddenly reports thousands of misses is a cache that stopped restoring.
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -242,18 +320,17 @@ jobs:
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-rust-ci-noble:latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# ⚠ Job-scoped — see build-publish above and ci.yml's `rust` job.
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
- name: Version + channel
|
||||
run: |
|
||||
@@ -289,9 +366,12 @@ jobs:
|
||||
with:
|
||||
path: target
|
||||
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
|
||||
# ci.yml's 26.04 target cache (mixing would poison both).
|
||||
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
|
||||
# ci.yml's 26.04 target cache (mixing would poison both). It is also the only job using
|
||||
# this prefix, so — unlike the amd64/arm64 pairs above — it has always been able to save
|
||||
# and restore its own release artifacts. `-release-` is spelled out anyway so the whole
|
||||
# file reads consistently and nobody "unifies" it back into a shared key later.
|
||||
key: cargo-target-noble-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-noble-release-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Build release host
|
||||
env:
|
||||
@@ -326,6 +406,12 @@ jobs:
|
||||
run: |
|
||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||
|
||||
# Visibility only — placed here, right after the last compile, rather than at the end of the
|
||||
# job: the gamescope gate below must stay the final step (see its own note).
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
|
||||
# capability metadata — the postinst applies them — so this reads the postinst that will
|
||||
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
|
||||
@@ -337,82 +423,6 @@ jobs:
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
|
||||
|
||||
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
||||
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
||||
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
||||
#
|
||||
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
- name: Build the patched gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
|
||||
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
|
||||
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
|
||||
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
|
||||
# a half-built result impossible to ship.
|
||||
run: |
|
||||
set -x
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
|
||||
apt-get build-dep -y gamescope || true
|
||||
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
|
||||
# gamescope — where noble has one at all — so it misses what the master tree needs, and
|
||||
# wayland-protocols is the gap that actually stops the build: meson dies in
|
||||
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
|
||||
# file was found", because the tree has no wrap fallback for it. That is what happened on
|
||||
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
|
||||
# shipped with no gamescope .deb while the notes said it had one.
|
||||
apt-get install -y --no-install-recommends wayland-protocols
|
||||
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
|
||||
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
|
||||
# releases should not fail the job. (No libstdc++ static package is needed here — g++
|
||||
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
|
||||
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
|
||||
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
|
||||
# purpose: a single transaction aborts wholesale on one unknown package, which would
|
||||
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
|
||||
# the missing one named; the end-of-job gate below is what actually decides.
|
||||
for p in libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
|
||||
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
|
||||
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
|
||||
cmake xwayland; do
|
||||
apt-get install -y --no-install-recommends "$p" \
|
||||
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
|
||||
done
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
|
||||
# skips the host .deb's own publish + release-attach steps below, which is how the
|
||||
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
|
||||
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
|
||||
# meant to protect the release. Never let a missing EXTRA stop a good artifact
|
||||
# shipping; go red afterwards instead.
|
||||
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
- name: Build punktfunk-gamescope .deb
|
||||
# Picked up by the publish loop below, which globs dist/*.deb.
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Warn only — see the note on the build step. The gate is the last step of this job.
|
||||
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
|
||||
fi
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -440,25 +450,109 @@ jobs:
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
|
||||
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
|
||||
# because every failure on this path was a `::warning::` that returned 0.
|
||||
#
|
||||
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
|
||||
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
|
||||
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
|
||||
# gate protecting the release withheld the fix for it. Everything good ships first; the job
|
||||
# goes red afterwards.
|
||||
- name: A stable tag must ship the gamescope .deb
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# `punktfunk-gamescope` for apt — the patched build that gives a gamescope session HDR, a cursor,
|
||||
# and the client's real refresh rate instead of a hardcoded 60 Hz. Same reasoning as the RPM leg
|
||||
# in rpm.yml: without a package, a Debian/Ubuntu box has no route to it except compiling by hand.
|
||||
#
|
||||
# ⚠ THIS IS ITS OWN JOB, ON DEBIAN 13, BECAUSE THE NOBLE BUILD COULD NEVER HAVE WORKED.
|
||||
# It used to be a few best-effort steps inside build-publish-host (Ubuntu 24.04), where it failed
|
||||
# every single time:
|
||||
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
|
||||
# Our pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1; noble ships 1.22.0 and
|
||||
# always will. v0.26.0 AND v0.27.0 both shipped with no gamescope .deb — while the release notes
|
||||
# and docs-site told apt users to install it — because every rung of that path was a `::warning::`
|
||||
# that returned 0, and the one hard gate ran last by design (so good artifacts still publish) and
|
||||
# was simply never acted on. Moving it to trixie makes the build possible; making it a job of its
|
||||
# own makes its failure visible instead of a footnote in someone else's log.
|
||||
#
|
||||
# Debian 13 is the OLDEST apt distro the tree configures on (wayland 1.23.1, libxcb-errors,
|
||||
# libdisplay-info 0.2). The binary it produces needs GLIBC_2.38 and links no libstdc++, so what
|
||||
# actually bounds it is wayland: Debian 13 and Ubuntu 26.04 YES, Ubuntu 24.04 NO — and 24.04
|
||||
# could not run this binary however it was built, so nothing was lost by leaving noble.
|
||||
build-publish-gamescope:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-gamescope-trixie:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Byte-identical to the other jobs' version step (pf-version.sh is deterministic per commit)
|
||||
# — but only DISTRIBUTION is used here. The package version is the gamescope upstream
|
||||
# version + our patch level, which build-gamescope-deb.sh derives itself; it deliberately
|
||||
# does NOT follow the punktfunk version line, because this package moves on its own cadence.
|
||||
- name: Channel
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
built=(dist/punktfunk-gamescope_*.deb)
|
||||
if [ ${#built[@]} -eq 0 ]; then
|
||||
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
|
||||
exit 1
|
||||
fi
|
||||
echo "gamescope .deb present: ${built[*]}"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) DIST=stable ;;
|
||||
*) DIST=canary ;;
|
||||
esac
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "gamescope -> apt distribution '$DIST'"
|
||||
|
||||
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||
# Keyed `-trixie-` so the noble cache entries (which only ever held misses) can't be hit.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-trixie-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
# NOT best-effort, unlike the noble version of this step. Every dependency now comes from the
|
||||
# image (which asserts the wayland floor at build time), so a failure here is a real
|
||||
# regression in the tree or the pin — exactly the thing the previous arrangement hid.
|
||||
- name: Build the patched gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# `--extra-fallback libdisplay-info` is what makes ONE .deb serve both Debian 13 and
|
||||
# Ubuntu 26.04. Built against the distro's copy, the package picks up
|
||||
# `Depends: libdisplay-info2 (>= 0.2.0)` on trixie — and Ubuntu 26.04 carries
|
||||
# libdisplay-info **3** (0.3.0), so apt refuses it there ("Depends libdisplay-info2 …" —
|
||||
# measured, not predicted). gamescope vendors the library as a submodule, so linking the
|
||||
# vendored copy drops the dependency entirely. Same reasoning the build script already
|
||||
# applies to wlroots: a binary we SHIP must not follow the build host's shared libraries.
|
||||
run: |
|
||||
bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
|
||||
--extra-fallback libdisplay-info
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
|
||||
# The binary must RUN, not merely link: `--version` is what the old job used as its ship
|
||||
# gate, and it is the cheapest proof that the static-libstdc++ trick and the vendored wlroots
|
||||
# actually produced a working compositor.
|
||||
- name: Build the .deb
|
||||
run: |
|
||||
gs-cache/punktfunk-gamescope --version
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for DEB in dist/punktfunk-gamescope_*.deb; do
|
||||
echo "uploading $DEB"
|
||||
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||
VER=$(dpkg-deb -f "$DEB" Version)
|
||||
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
done
|
||||
echo "published gamescope to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
- name: Attach the gamescope .deb to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for DEB in dist/punktfunk-gamescope_*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
||||
@@ -476,14 +570,14 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
#
|
||||
# NOTE this job deliberately sets no CC_x86_64_unknown_linux_gnu: the cross image already
|
||||
# points it at /usr/local/bin/pf-host-cc, which is what keeps ffmpeg-sys-next's HOST probe
|
||||
# from picking up arm64 include dirs. The target-side compiles go through
|
||||
# CC_aarch64_unknown_linux_gnu (also set by the image) and are not sccache-wrapped.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# Byte-identical to build-publish's version step (pf-version.sh is deterministic per
|
||||
# commit), so the arm64 package always shares the amd64 version line.
|
||||
@@ -520,8 +614,13 @@ jobs:
|
||||
path: target
|
||||
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
|
||||
# and must never share the amd64 jobs' target cache.
|
||||
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
|
||||
#
|
||||
# `-release-`: this key was previously identical to ci.yml's `rust-arm64` key, which
|
||||
# builds DEBUG (clippy) and finishes in ~1.5 min against this job's ~5. Exactly the
|
||||
# amd64 collision described on the release key above — ci.yml won every save, this job
|
||||
# restored a tree with no release artifacts and could never persist its own.
|
||||
key: cargo-target-arm64-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-arm64-release-v1-${{ env.rustc }}-
|
||||
|
||||
- name: Build the arm64 client .deb
|
||||
env:
|
||||
@@ -534,6 +633,10 @@ jobs:
|
||||
readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \
|
||||
| grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; }
|
||||
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -560,3 +663,57 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Does the thing we just published actually INSTALL? Nothing in this repo asked that before, and
|
||||
# the cost of not asking was two independent, long-lived facts nobody knew:
|
||||
# * `punktfunk-host` has installed cleanly on Debian 13 for a long time — while docs-site said
|
||||
# "Debian isn't a supported target … nobody has verified it".
|
||||
# * `punktfunk-gamescope` was missing from apt entirely across two releases.
|
||||
# Both are exactly what a five-minute install check catches, so it is now a job.
|
||||
#
|
||||
# It runs on the RUNNER (no `container:`) and drives docker directly — the same access
|
||||
# docker.yml's image builds use — because the check must happen in a pristine distro image, not
|
||||
# in a builder image with the deps already present.
|
||||
#
|
||||
# It installs FROM THE REGISTRY, after the publish jobs, rather than from a local .deb: that
|
||||
# exercises the real path a user takes (repo key, apt distribution, dependency resolution against
|
||||
# the distro's own package set), and it matches this workflow's established order — publish the
|
||||
# good artifacts first, go red afterwards, never let a gate withhold a shipping fix.
|
||||
smoke-install:
|
||||
needs: [build-publish, build-publish-host, build-publish-gamescope]
|
||||
# `needs` for ORDER only — this must still run when a builder went red, or the failure that
|
||||
# matters most (a package that did not publish) is exactly the one that skips its own check and
|
||||
# leaves the run looking merely "partly red" instead of saying what a user would hit.
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Recomputes the SAME version string the builder jobs stamped — pf-version.sh is
|
||||
# deterministic per commit and GITHUB_RUN_NUMBER is shared across a run's jobs — so the check
|
||||
# below can insist the registry is serving THIS run's build. Without that, a smoke job that
|
||||
# beats the index regeneration installs the previous build, passes, and proves nothing.
|
||||
- name: Channel + expected version
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
eval "$(bash scripts/ci/pf-version.sh)"
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "EXPECT_VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "expecting $V in apt distribution '$DIST'"
|
||||
|
||||
# The support matrix, asserted rather than asserted-in-prose. Each row names the packages
|
||||
# that MUST install on that distro; anything absent from the row is expected not to and is
|
||||
# not checked here (the client's glibc 2.43 floor keeps it off 24.04 and Debian 13 —
|
||||
# see docs-site/content/docs/debian.md).
|
||||
- name: Install from the apt registry on every supported distro
|
||||
run: bash scripts/ci/deb-install-smoke.sh
|
||||
env:
|
||||
PF_APT_DISTRIBUTION: ${{ env.DISTRIBUTION }}
|
||||
PF_EXPECT_VERSION: ${{ env.EXPECT_VERSION }}
|
||||
|
||||
@@ -96,6 +96,12 @@ jobs:
|
||||
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
|
||||
- image: punktfunk-rust-ci-noble
|
||||
dockerfile: ci/rust-ci-noble.Dockerfile
|
||||
# Debian 13 gamescope builder. The gamescope .deb used to be built in the noble image
|
||||
# and NEVER once succeeded there — noble's wayland is 1.22.0 and the vendored wlroots
|
||||
# 0.19.3 floors it at 1.23.1, so two releases shipped without the package. trixie is the
|
||||
# oldest apt distro the tree configures on. Consumed by deb.yml's build-publish-gamescope.
|
||||
- image: punktfunk-gamescope-trixie
|
||||
dockerfile: ci/gamescope-trixie.Dockerfile
|
||||
- image: punktfunk-fedora-rpm
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# Gitea has NO flatpak/ostree registry, so the bundle lives in the generic registry:
|
||||
# PUT https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/<version>/<file>
|
||||
# GET https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/<version>/<file>
|
||||
# On tags the bundle is ALSO attached to the Gitea release (mirrors release.yml's DMG).
|
||||
# On tags the bundle is ALSO attached to the Gitea release (mirrors apple.yml's DMG).
|
||||
#
|
||||
# PRIVILEGED-BUILD CONSTRAINT: flatpak-builder runs bubblewrap, which needs user namespaces.
|
||||
# In a Gitea/act_runner Docker executor that means the job container must be --privileged
|
||||
@@ -37,7 +37,7 @@ on:
|
||||
# binary's dependency closure must be listed here — including the native decode rungs, or a
|
||||
# commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly
|
||||
# stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core
|
||||
# and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead).
|
||||
# and never enters the Linux closure (windows-client.yml carries it instead).
|
||||
paths:
|
||||
- 'clients/linux/**'
|
||||
- 'clients/session/**'
|
||||
|
||||
@@ -29,6 +29,12 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# The C/C++ half of the cache — same wiring as ci.yml/deb.yml. Safe at workflow level: no
|
||||
# cross-compiling job here.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -45,14 +51,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# Client link deps (baked into the image; kept here so the job is green across image
|
||||
# rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server,
|
||||
@@ -67,7 +68,8 @@ jobs:
|
||||
libgl1-mesa-dri mesa-vulkan-drivers \
|
||||
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
|
||||
|
||||
# Reuse the workspace cargo caches (same keys as ci.yml/deb.yml).
|
||||
# Reuse the workspace cargo caches. The cargo-home (download) cache is shared verbatim —
|
||||
# it is profile-independent.
|
||||
- name: Cache keys
|
||||
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
@@ -80,8 +82,20 @@ jobs:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||
# This job builds RELEASE (see the build step) in the same image and target layout as
|
||||
# deb.yml's `build-publish`, so it wants THAT tree — it used to name ci.yml's key, which
|
||||
# holds a debug build and gave it nothing. (Third participant in the collision documented
|
||||
# on ci.yml's `cargo-target-debug-v3-` key.)
|
||||
#
|
||||
# Its OWN exact key with deb's prefix as a FALLBACK restore-key, deliberately: both
|
||||
# workflows run on a v* tag, and an exact-key match would make them race for the single
|
||||
# save slot — this job builds one crate, so if it won that race it would replace deb's
|
||||
# full release tree with a nearly empty one for the rest of the lockfile's life. This way
|
||||
# it always READS the warm tree and never blocks the job that fills it.
|
||||
key: cargo-target-shots-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-target-shots-release-v1-${{ env.rustc }}-
|
||||
cargo-target-release-v3-${{ env.rustc }}-
|
||||
|
||||
- name: Build client
|
||||
run: cargo build --release -p punktfunk-client-linux --locked
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
# Production Apple client builds — runs on the macos-arm64 runner (home-mac-mini-1).
|
||||
#
|
||||
# Tag v* (or workflow_dispatch):
|
||||
# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached to a
|
||||
# Gitea release on tag pushes
|
||||
# macOS (App Store) -> archive + upload to TestFlight (App Store Connect)
|
||||
# iOS -> archive + upload straight to TestFlight (App Store Connect)
|
||||
# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets,
|
||||
# nightly -Zbuild-std, in build-xcframework.sh)
|
||||
#
|
||||
# One App Store listing for all platforms (universal purchase): every target shares the
|
||||
# bundle ID io.unom.punktfunk.
|
||||
#
|
||||
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
||||
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
||||
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
|
||||
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
|
||||
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
|
||||
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
|
||||
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
|
||||
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
|
||||
#
|
||||
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
||||
# step is continue-on-error until they exist):
|
||||
# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record
|
||||
# (universal purchase).
|
||||
# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the
|
||||
# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/).
|
||||
# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in
|
||||
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
||||
# .pkg is installer-signed with it.
|
||||
#
|
||||
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
|
||||
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
|
||||
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
|
||||
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
|
||||
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
|
||||
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
|
||||
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
|
||||
# a launchable app.
|
||||
#
|
||||
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
||||
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
||||
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
||||
# ID Application + Apple Distribution, with the WWDR intermediate present (so they show as
|
||||
# *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain.
|
||||
# One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access:
|
||||
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <login-pw> \
|
||||
# ~/Library/Keychains/login.keychain-db
|
||||
#
|
||||
# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API
|
||||
# key — notarization, TestFlight upload, automatic-signing profile fetch).
|
||||
#
|
||||
# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow
|
||||
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
|
||||
# loud warning.
|
||||
name: release
|
||||
# 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
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
# Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's
|
||||
# own canary channel) — no notarized DMG (that's stable-only; see the per-step gates).
|
||||
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
|
||||
# continue-on-error until the App Store Connect record exists, so this no-ops until then.
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'clients/apple/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'scripts/build-xcframework.sh'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/release.yml'
|
||||
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
|
||||
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testflight:
|
||||
description: "Upload the iOS build to TestFlight (true/false)"
|
||||
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
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
TEAM_ID: F4H37KF6WC
|
||||
PROJECT: clients/apple/Punktfunk.xcodeproj
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Select release Xcode
|
||||
run: |
|
||||
DEV_DIR=""
|
||||
for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do
|
||||
case "$app" in *beta*|*Beta*) continue;; esac
|
||||
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
|
||||
done
|
||||
if [ -z "$DEV_DIR" ]; then
|
||||
for app in /Applications/Xcode*.app; do
|
||||
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
|
||||
done
|
||||
echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds."
|
||||
fi
|
||||
[ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; }
|
||||
# Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must
|
||||
# keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the
|
||||
# running dyld rejects, killing proc-macro loads (see build-xcframework.sh).
|
||||
echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV"
|
||||
DEVELOPER_DIR="$DEV_DIR" xcodebuild -version
|
||||
|
||||
- name: Version from tag
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
|
||||
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
|
||||
esac
|
||||
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
|
||||
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
|
||||
|
||||
- name: Rust toolchain (mac + iOS + tvOS slices)
|
||||
run: |
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
|
||||
# nightly + -Zbuild-std, so ensure nightly + rust-src are present.
|
||||
"$RUSTUP" toolchain install nightly --profile minimal
|
||||
"$RUSTUP" component add rust-src --toolchain nightly
|
||||
|
||||
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
|
||||
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
|
||||
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
|
||||
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
|
||||
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
|
||||
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
|
||||
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
|
||||
command -v cmake >/dev/null || "$BREW" install cmake
|
||||
echo "$BREW_BIN" >> "$GITHUB_PATH"
|
||||
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
|
||||
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
|
||||
# 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
|
||||
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
|
||||
# under ~/Library that nothing ever collected. 31 of them piled up in three days
|
||||
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
|
||||
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
|
||||
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
|
||||
run: |
|
||||
DD="$HOME/ci/derived-data/release"
|
||||
mkdir -p "$DD"
|
||||
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
||||
# Safety net for trees the pin does not own: the legacy per-path ones from before this
|
||||
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
|
||||
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
|
||||
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
|
||||
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
|
||||
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
|
||||
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
|
||||
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
- name: Stage App Store Connect API key
|
||||
env:
|
||||
ASC_P8: ${{ secrets.ASC_API_KEY_P8 }}
|
||||
run: |
|
||||
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
|
||||
chmod 600 "$RUNNER_TEMP/asc.p8"
|
||||
|
||||
- name: macOS — archive, codesign Developer ID, notarize, DMG
|
||||
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
|
||||
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
run: |
|
||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||
# provisioning-profile gate at archive time; we re-assert that authorization below by
|
||||
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
|
||||
# Bundle is a single static binary.
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app"
|
||||
# Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build
|
||||
# uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix.
|
||||
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
||||
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
||||
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
||||
|
||||
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
|
||||
# network/device keys are self-asserted for Developer ID, but a keychain access group
|
||||
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
|
||||
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
|
||||
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
|
||||
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
|
||||
# entitlements authorize the access group, exactly like the App Store build's profile
|
||||
# does. Located by profile Name among the profiles installed on the runner (see header).
|
||||
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
|
||||
PROFILE_SRC=""
|
||||
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
|
||||
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
|
||||
[ -e "$p" ] || continue
|
||||
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
|
||||
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
|
||||
done
|
||||
if [ -n "$PROFILE_SRC" ]; then
|
||||
# Must land BEFORE codesign so it's sealed into the bundle.
|
||||
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
|
||||
echo "embedded Developer ID profile: $PROFILE_SRC"
|
||||
else
|
||||
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
|
||||
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
|
||||
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
|
||||
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
|
||||
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$RESOLVED" \
|
||||
--sign "Developer ID Application" "$APP"
|
||||
codesign --verify --strict --verbose=2 "$APP"
|
||||
# Notarized DMG.
|
||||
STAGE="$RUNNER_TEMP/dmg-stage"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg"
|
||||
hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \
|
||||
--key "$RUNNER_TEMP/asc.p8" \
|
||||
--key-id "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
--issuer "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
|
||||
echo "DMG=$DMG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Attach DMG to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
|
||||
|
||||
- name: macOS App Store — archive + upload to TestFlight
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Best-effort until the App Store Connect record has the macOS platform + the
|
||||
# "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer
|
||||
# Installer" cert are on the runner (see the header). The macOS app is sandboxed
|
||||
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
|
||||
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
|
||||
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
|
||||
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
|
||||
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
|
||||
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
|
||||
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
|
||||
# regenerate the managed *development* profile — needed because the App Groups capability
|
||||
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
|
||||
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
|
||||
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
|
||||
# step below
|
||||
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||
# distribution profile that export needs.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk macOS App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-macos-appstore.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>upload</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-macos-appstore" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: iOS — archive + upload to TestFlight
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
|
||||
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
|
||||
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
|
||||
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
|
||||
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
|
||||
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
|
||||
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
|
||||
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
|
||||
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
|
||||
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
|
||||
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
|
||||
# App Store distribution profile survives for export.
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
|
||||
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
|
||||
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
|
||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-appstore.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>upload</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>io.unom.punktfunk</key><string>$PROFILE</string>
|
||||
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-appstore" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
|
||||
- name: iOS — export .ipa (Gitea release + run artifact)
|
||||
# The TestFlight step above uploads straight to App Store Connect (destination=upload) and
|
||||
# leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an
|
||||
# App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as
|
||||
# that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent,
|
||||
# e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs
|
||||
# only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact.
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
id: ios_ipa
|
||||
run: |
|
||||
ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive"
|
||||
if [ ! -d "$ARCHIVE" ]; then
|
||||
echo "::warning::iOS archive not found — skipping .ipa export"
|
||||
exit 0
|
||||
fi
|
||||
PROFILE="Punktfunk iOS App Store Distribution"
|
||||
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
|
||||
# destination=export writes the .ipa to -exportPath; otherwise identical manual signing to
|
||||
# the upload plist (both profiles, Apple Distribution). No ASC key needed — no network.
|
||||
cat > "$RUNNER_TEMP/export-appstore-ipa.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>export</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>io.unom.punktfunk</key><string>$PROFILE</string>
|
||||
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-ipa"
|
||||
SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1)
|
||||
[ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; }
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist"
|
||||
IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa"
|
||||
mv "$SRC" "$IPA"
|
||||
echo "IPA=$IPA" >> "$GITHUB_ENV"
|
||||
echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT"
|
||||
echo "exported $IPA"
|
||||
|
||||
- name: Attach .ipa to the workflow run
|
||||
if: steps.ios_ipa.outputs.ipa != ''
|
||||
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
|
||||
# (same reason as android.yml / apple.yml). Download is a zip of the .ipa.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: punktfunk-ios-ipa
|
||||
path: ${{ steps.ios_ipa.outputs.ipa }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Attach .ipa to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$IPA" "Punktfunk-$VERSION.ipa"
|
||||
|
||||
- name: tvOS — archive + upload to TestFlight
|
||||
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
|
||||
# on every apple push (above), so this matches the iOS step's gate exactly.
|
||||
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
|
||||
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
|
||||
# on the runner (xcodebuild -downloadPlatform tvOS).
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
|
||||
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
|
||||
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
|
||||
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
|
||||
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
|
||||
# DISTRIBUTION signing is the export step below (manual, plist).
|
||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||
pkill -x Xcode 2>/dev/null || true
|
||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk-tvOS \
|
||||
-destination 'generic/platform=tvOS' \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-skipMacroValidation -skipPackagePluginValidation \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
cat > "$RUNNER_TEMP/export-tvos.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>upload</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \
|
||||
-exportPath "$RUNNER_TEMP/export-tvos" \
|
||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
||||
@@ -51,6 +51,14 @@ env:
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# The C/C++ half of the cache (aws-lc-sys, the vendored libopus, openh264's C++). Safe at
|
||||
# workflow level here: unlike ci.yml/deb.yml this workflow has no cross-compiling job whose
|
||||
# image sets its own CC_x86_64_unknown_linux_gnu. See ci.yml's `rust` job for that trap.
|
||||
# This matters twice per push — the f43 and f44 legs are the two longest jobs in the fleet.
|
||||
CMAKE_C_COMPILER_LAUNCHER: sccache
|
||||
CMAKE_CXX_COMPILER_LAUNCHER: sccache
|
||||
CC_x86_64_unknown_linux_gnu: sccache cc
|
||||
CXX_x86_64_unknown_linux_gnu: sccache c++
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -80,14 +88,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
|
||||
# images; this fetch keeps the job green while the running :latest predates the bake.
|
||||
# images; this heals the job while the running :latest predates the bake.
|
||||
- name: sccache (no-op once the image bakes it)
|
||||
run: |
|
||||
command -v sccache >/dev/null 2>&1 || {
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
|
||||
}
|
||||
sccache --version
|
||||
run: sh scripts/ci/ensure-sccache.sh
|
||||
|
||||
# rpmbuild + git archive need the checkout trusted; cache the crates download.
|
||||
# The client link deps are also baked into the fedora-rpm image, but this job runs
|
||||
@@ -145,11 +148,63 @@ jobs:
|
||||
echo "GROUP=$GROUP" >> "$GITHUB_ENV"
|
||||
echo "rpm $V-$R -> group '$GROUP'"
|
||||
|
||||
# ── The web console, built once per (web+sdk content, bun) instead of once per leg ─────────
|
||||
# Two legs run here (f43 + f44) and each built its own identical copy of a bundle that is a
|
||||
# pure function of web/ and sdk/ — see the fuller note in deb.yml, whose key family this
|
||||
# shares, so whichever job builds it first warms the rest of the fleet.
|
||||
#
|
||||
# ⚠ The build has to happen HERE, in the workspace, rather than being left to the spec. Two
|
||||
# reasons, and both are load-bearing:
|
||||
# * build-rpm.sh packages a `git archive` tarball and web/.output is gitignored, so a
|
||||
# bundle sitting in the workspace is invisible to rpmbuild — it must be handed over by
|
||||
# absolute path (PF_PREBUILT_WEB_OUTPUT -> the spec's pf_prebuilt_web macro).
|
||||
# * the reverse direction is worse: the spec builds into rpmbuild's %{_topdir}, which
|
||||
# build-rpm.sh creates with mktemp and removes on EXIT. A console built in there is gone
|
||||
# before actions/cache's post step runs, so the cache would never populate and every run
|
||||
# would be a miss that quietly rebuilt — the cache would look present and do nothing.
|
||||
- name: Web console cache key
|
||||
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
|
||||
- name: Cache the built web console
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
|
||||
- name: Build the web console (cache miss only)
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd web
|
||||
bun install --frozen-lockfile --ignore-scripts
|
||||
bun run build
|
||||
|
||||
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
|
||||
# become a quietly console-less RPM. The spec re-checks the marker on whatever it packages.
|
||||
- name: The console must exist (cache hit or fresh build)
|
||||
run: |
|
||||
if [ ! -f web/.output/server/index.mjs ]; then
|
||||
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
|
||||
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
|
||||
echo "web console present: $(du -sh web/.output | cut -f1)"
|
||||
|
||||
- name: Build RPM
|
||||
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
|
||||
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
|
||||
# Recommends both). Both need bun (ensured in Prep).
|
||||
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
|
||||
run: |
|
||||
PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" \
|
||||
PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
|
||||
PF_PREBUILT_WEB_OUTPUT="$GITHUB_WORKSPACE/web/.output" \
|
||||
bash packaging/rpm/build-rpm.sh
|
||||
|
||||
# Visibility only — the two RPM legs are the longest jobs in the fleet, so a cache
|
||||
# regression here is the most expensive one to leave undetected.
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# Signs with packages@unom.io (org secret) and self-verifies before publish. On a v* tag a
|
||||
# missing key FAILS the build rather than publishing unsigned RPMs into a gpgcheck=1 repo.
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
upsert_asset "$RID" "$SBOM_FILE"
|
||||
# v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see release.yml).
|
||||
# v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see apple.yml).
|
||||
- name: Upload artifact (non-tag runs)
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
# Windows CLIENT — build, lint, test and package, on a self-hosted windows-amd64 runner (host mode;
|
||||
# the generic runner + MSVC/WinUI toolchain come from unom/infra's windows-runner/, punktfunk's own
|
||||
# extras — WDK, Inno Setup, the ARM64 rustup target — self-provision via the "Ensure Windows
|
||||
# toolchain" step, a fast no-op once present, so any runner with that label works).
|
||||
#
|
||||
# Covers BOTH client binaries: the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the
|
||||
# punktfunk-session Vulkan client (pf-presenter/pf-client-core/pf-console-ui — every stream runs in
|
||||
# it, spawned by the shell), plus punktfunk-cli, whose `punktfunk.exe` alias the MSIX manifest
|
||||
# references.
|
||||
#
|
||||
# ⚠ WHY THIS IS ONE FILE. This was `windows.yml` (build+lint+test, DEBUG, x64 + arm64) and
|
||||
# `windows-msix.yml` (build+package, RELEASE, x64 + arm64) — four full compiles of the same crates
|
||||
# per client push, on ONE runner, from three copies of the same `paths:` list that had already
|
||||
# started to drift. windows-host.yml learned the hard way that debug trees on this machine are pure
|
||||
# liability: a second dep tree tips it into `cabac_decoder.cpp: fatal error C1069` building
|
||||
# openh264-sys2's vendored C++, which is disk/temp exhaustion, not a source error. So there is now
|
||||
# ONE release build per arch, and clippy/fmt/test run against it. Do not reintroduce a debug leg.
|
||||
#
|
||||
# Renamed from windows-msix.yml deliberately, and safely: `github.run_number` is REPO-WIDE in Gitea
|
||||
# (consecutive runs of DIFFERENT workflows get consecutive numbers), so the canary MSIX version
|
||||
# `<minor>.<run>.0` keeps climbing across the rename — on GitHub, where run_number is per-workflow,
|
||||
# this same rename would have reset it to 1 and made every canary sort below the published ones.
|
||||
#
|
||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and aarch64-pc-windows-msvc
|
||||
# by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
||||
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust target
|
||||
# with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64 compiler from
|
||||
# the target triple (SDL3 + libopus build-from-source cross-compile fine). The one thing the aarch64
|
||||
# build can't do is *run* on the x64 host, so fmt + test run only for x64.
|
||||
#
|
||||
# ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the session builds
|
||||
# --no-default-features there (no Skia console UI; streaming is unaffected) — flip when
|
||||
# skia-binaries adds the target.
|
||||
#
|
||||
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
|
||||
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
|
||||
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
|
||||
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
|
||||
# `amf-qsv` leg link-imports them.
|
||||
#
|
||||
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
|
||||
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout / per-arch
|
||||
# vars are set in a step:
|
||||
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
|
||||
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
|
||||
# so the default target\ path blows past Windows' MAX_PATH (260) inside the
|
||||
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
|
||||
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
|
||||
# root keeps every nested path well under the limit (per-arch so the two
|
||||
# matrix legs don't share a target dir).
|
||||
#
|
||||
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
|
||||
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
|
||||
# var silently never gets set). pwsh writes no BOM.
|
||||
# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh.
|
||||
#
|
||||
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
|
||||
#
|
||||
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
|
||||
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
#
|
||||
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
|
||||
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||
# unified Gitea Release alongside every other platform's artifact.
|
||||
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
|
||||
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
|
||||
# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
|
||||
# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
|
||||
# .cer is published next to the .msix (users import it to Trusted People before install).
|
||||
#
|
||||
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
|
||||
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
|
||||
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
|
||||
name: windows-client
|
||||
# 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
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# ONE list now, not three. The old windows.yml + windows-msix.yml pair carried this same set
|
||||
# three times (push, pull_request, and the second file), which is exactly how a crate goes
|
||||
# missing from one copy — windows-host.yml documents the "Cargo.lock luck" gap that produced.
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-client.yml'
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-client.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
|
||||
# never collide; every Rust job on every host feeds and reads one warm cache.
|
||||
#
|
||||
# NOTE the C/C++ launcher wiring the Linux workflows carry (CMAKE_*_COMPILER_LAUNCHER, CC_*) is
|
||||
# deliberately NOT set here. This runner's failure mode under extra compiler processes is the
|
||||
# C1069 disk/temp exhaustion documented in windows-host.yml, so sccache-for-MSVC is its own
|
||||
# change, to be made with a measurement rather than folded into a reorganisation.
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PKG: punktfunk-client-windows
|
||||
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:
|
||||
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
|
||||
# host-mode, persistent `windows-amd64` runner that the release-SIGNING steps below and
|
||||
# windows-host.yml (which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
|
||||
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
|
||||
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
|
||||
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
|
||||
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
|
||||
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
|
||||
# never blocks internal PR CI.
|
||||
client:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
td: C:\t
|
||||
session_flags: ''
|
||||
- arch: arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
td: C:\t-a64
|
||||
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
|
||||
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
|
||||
# rust-skia adds the target.
|
||||
session_flags: '--no-default-features'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
- name: Configure + version
|
||||
shell: pwsh
|
||||
run: |
|
||||
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
|
||||
# crates (see this file's header). No FFMPEG_DIR: nothing in this package links libav*
|
||||
# (M10), and pack-msix.ps1 no longer copies runtime DLLs from one.
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
rustc --version
|
||||
cargo --version
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||
} else {
|
||||
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
|
||||
}
|
||||
while ($parts.Count -lt 4) { $parts += '0' }
|
||||
$v = ($parts[0..3] -join '.')
|
||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }} target-dir ${{ matrix.td }}"
|
||||
|
||||
# All three client binaries, ONCE, in release. The shell spawns punktfunk-session.exe (a
|
||||
# package sibling) for every stream, and punktfunk-cli builds the `punktfunk.exe` the manifest
|
||||
# aliases and pack-msix.ps1 requires (bf981027 added the requirement without the build — the
|
||||
# same gap 90c84ef4 closed for deb). --no-default-features on ARM64 is a no-op for the shell.
|
||||
#
|
||||
# Release, not debug, even for the lint/test legs below: a debug build here would compile the
|
||||
# whole dep tree into a SECOND target dir and re-run openh264-sys2's vendored C++ through
|
||||
# cc-rs's cl.exe fan-out, which is what tips this runner into C1069 (see the header).
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
|
||||
# dependency but only LINTS the packages it is given, so a decode crate that starts the
|
||||
# run but is missing from this list would be gated by nothing.
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
|
||||
$sf = @()
|
||||
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||
cargo clippy --release @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||
if ($LASTEXITCODE) { throw "clippy" }
|
||||
|
||||
- name: Rustfmt check
|
||||
if: matrix.arch == 'x64'
|
||||
shell: pwsh
|
||||
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-dxvadec -- --check
|
||||
if ($LASTEXITCODE) { throw "rustfmt" }
|
||||
|
||||
- name: Test
|
||||
# x64 only: the aarch64 binaries cross-compile here but cannot RUN on this host.
|
||||
if: matrix.arch == 'x64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
cargo test --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
|
||||
if ($LASTEXITCODE) { throw "tests" }
|
||||
|
||||
- name: sccache stats (visibility only)
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: sccache --show-stats
|
||||
|
||||
# ── Packaging: pushes, tags and dispatch only. A PR gets the build/lint/test signal above and
|
||||
# stops there — packing would sign with a throwaway cert and publish nothing.
|
||||
- name: Pack + sign MSIX
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& clients/windows/packaging/pack-msix.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
if: github.event_name != 'pull_request'
|
||||
shell: pwsh
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
||||
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
|
||||
$aliasNames = @{
|
||||
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
|
||||
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
|
||||
}
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
|
||||
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
|
||||
# status code instead of aborting on it.
|
||||
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
|
||||
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
|
||||
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
|
||||
Write-Output "published ($code): $url"
|
||||
}
|
||||
foreach ($f in $files) {
|
||||
$name = Split-Path $f -Leaf
|
||||
# 1) immutable, versioned path
|
||||
Put $f "$base/$($env:MSIX_VERSION)/$name"
|
||||
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
|
||||
$an = $aliasNames["$f"]
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
# only live NVENC encode does, which defers to the RTX box.
|
||||
#
|
||||
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
|
||||
# first GITHUB_ENV line — see windows.yml).
|
||||
# first GITHUB_ENV line — see windows-client.yml).
|
||||
name: windows-drivers
|
||||
# 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
|
||||
@@ -39,7 +39,7 @@ on:
|
||||
|
||||
jobs:
|
||||
# SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with
|
||||
# the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full
|
||||
# the release-signing jobs (windows-host.yml / windows-client.yml). See windows-client.yml for the full
|
||||
# rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR
|
||||
# runners); the `if:` is a fail-open backstop that never blocks internal PR CI.
|
||||
probe-and-proto:
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Build + test pf-driver-proto (MSVC)
|
||||
run: |
|
||||
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
|
||||
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows-client.yml).
|
||||
$env:CARGO_TARGET_DIR = "C:\t\drv"
|
||||
cargo build -p pf-driver-proto
|
||||
cargo test -p pf-driver-proto
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
|
||||
# Shared self-provision step (also used by windows.yml/windows-msix.yml/windows-host.yml) so
|
||||
# Shared self-provision step (also used by windows-client.yml/windows-host.yml) so
|
||||
# driver-build is self-sufficient on any windows-amd64 runner and never races a manually
|
||||
# dispatched provisioning workflow landing on a different one. Path is relative to the job
|
||||
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
|
||||
# run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
|
||||
# windows-amd64 runner
|
||||
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
|
||||
# (host mode; same MSVC/Windows-SDK/LLVM env as windows-client.yml — generic from unom/infra's
|
||||
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
|
||||
#
|
||||
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
"CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by
|
||||
# scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10
|
||||
# it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
|
||||
# it links no libav* at all (windows-client.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
|
||||
# and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend
|
||||
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
|
||||
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
|
||||
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
|
||||
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
|
||||
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest
|
||||
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
|
||||
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
|
||||
#
|
||||
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
|
||||
# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the
|
||||
# BOM/MAX_PATH runner gotchas.
|
||||
#
|
||||
# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the
|
||||
# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer
|
||||
# (windows-host.yml) is unchanged.
|
||||
#
|
||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
|
||||
# Packaging internals: clients/windows/packaging/README.md.
|
||||
#
|
||||
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
|
||||
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
|
||||
# Published to the generic registry + the stable `latest/` alias + attached to the
|
||||
# unified Gitea Release alongside every other platform's artifact.
|
||||
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
|
||||
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
|
||||
# Published to the generic registry + the `canary/` alias.
|
||||
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
|
||||
#
|
||||
# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
|
||||
# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
|
||||
# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
|
||||
# .cer is published next to the .msix (users import it to Trusted People before install).
|
||||
#
|
||||
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
|
||||
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
|
||||
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
|
||||
name: windows-msix
|
||||
# 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
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows-msix.yml'
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PKG: punktfunk-client-windows
|
||||
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:
|
||||
package:
|
||||
runs-on: windows-amd64
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
td: C:\t
|
||||
session_flags: ''
|
||||
- arch: arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
td: C:\t-a64
|
||||
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
|
||||
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
|
||||
# rust-skia adds the target.
|
||||
session_flags: '--no-default-features'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
- name: Configure + version
|
||||
shell: pwsh
|
||||
run: |
|
||||
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
|
||||
# crates (see windows.yml). No FFMPEG_DIR: nothing in this package links libav* (M10),
|
||||
# and pack-msix.ps1 no longer copies runtime DLLs from one.
|
||||
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
rustup target add ${{ matrix.target }}
|
||||
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
|
||||
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
|
||||
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
|
||||
} else {
|
||||
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
|
||||
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
|
||||
}
|
||||
while ($parts.Count -lt 4) { $parts += '0' }
|
||||
$v = ($parts[0..3] -join '.')
|
||||
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
|
||||
|
||||
# All three client binaries — the shell spawns punktfunk-session.exe (a package
|
||||
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
|
||||
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
# 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
|
||||
env:
|
||||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||||
run: |
|
||||
& clients/windows/packaging/pack-msix.ps1 `
|
||||
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
|
||||
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
|
||||
|
||||
- name: Publish to Gitea generic registry
|
||||
shell: pwsh
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
|
||||
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
|
||||
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
|
||||
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
|
||||
$aliasNames = @{
|
||||
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
|
||||
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
|
||||
}
|
||||
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
|
||||
if (-not $files) { throw "pack produced no artifacts to publish" }
|
||||
function Put($f, $url) {
|
||||
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
|
||||
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
|
||||
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
|
||||
# status code instead of aborting on it.
|
||||
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
|
||||
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
|
||||
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
|
||||
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
|
||||
Write-Output "published ($code): $url"
|
||||
}
|
||||
foreach ($f in $files) {
|
||||
$name = Split-Path $f -Leaf
|
||||
# 1) immutable, versioned path
|
||||
Put $f "$base/$($env:MSIX_VERSION)/$name"
|
||||
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
|
||||
$an = $aliasNames["$f"]
|
||||
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
|
||||
Put $f "$base/$alias/$an"
|
||||
}
|
||||
|
||||
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
|
||||
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
|
||||
# the race, and x64/arm64 filenames differ so the assets don't collide.
|
||||
- name: Attach MSIX to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
|
||||
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup,
|
||||
# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast
|
||||
# no-op once already present, so any runner with that label works with no manual dispatch step
|
||||
# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell
|
||||
# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
|
||||
# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the
|
||||
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
|
||||
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
|
||||
# flip when skia-binaries adds the target.
|
||||
#
|
||||
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
|
||||
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
|
||||
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
|
||||
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
|
||||
# `amf-qsv` leg link-imports them.
|
||||
#
|
||||
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
|
||||
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
|
||||
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust
|
||||
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
|
||||
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
|
||||
# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64.
|
||||
#
|
||||
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
|
||||
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
|
||||
# / per-arch vars are set in a step:
|
||||
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
|
||||
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
|
||||
# so the default target\ path blows past Windows' MAX_PATH (260) inside the
|
||||
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
|
||||
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
|
||||
# root keeps every nested path well under the limit (per-arch so the two
|
||||
# matrix legs don't share a target dir).
|
||||
#
|
||||
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
|
||||
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
|
||||
# var silently never gets set). pwsh writes no BOM.
|
||||
# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh.
|
||||
name: windows
|
||||
# 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
|
||||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'clients/windows/**'
|
||||
- 'clients/session/**'
|
||||
- 'crates/punktfunk-core/**'
|
||||
- 'crates/pf-client-core/**'
|
||||
- 'crates/pf-presenter/**'
|
||||
- 'crates/pf-console-ui/**'
|
||||
- 'crates/pf-bitstream/**'
|
||||
- 'crates/pf-vkdecode/**'
|
||||
- 'crates/pf-dxvadec/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.gitea/workflows/windows.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
|
||||
# never collide; every Rust job on every host feeds and reads one warm cache.
|
||||
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:
|
||||
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
|
||||
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
|
||||
# windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
|
||||
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
|
||||
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
|
||||
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
|
||||
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
|
||||
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
|
||||
# never blocks internal PR CI.
|
||||
build:
|
||||
runs-on: windows-amd64
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork != true
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
|
||||
shell: pwsh
|
||||
run: ./scripts/ci/ensure-windows-toolchain.ps1
|
||||
|
||||
- name: Configure + toolchain versions
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
|
||||
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
|
||||
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav*
|
||||
# since M10 (see this file's header), so nothing here needs import libs or runtime DLLs.
|
||||
# The HOST still does — windows-host.yml sets them for its amf-qsv leg.
|
||||
rustup target add ${{ matrix.target }}
|
||||
rustc --version
|
||||
cargo --version
|
||||
Write-Output "target ${{ matrix.target }} target-dir $td"
|
||||
|
||||
# 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 -p punktfunk-cli @sf --target ${{ matrix.target }}
|
||||
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
|
||||
# dependency but only LINTS the packages it is given, so a decode crate that starts the
|
||||
# run but is missing from this list would be gated by nothing.
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
|
||||
$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
|
||||
|
||||
- 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 punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --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 punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
|
||||
@@ -32,6 +32,47 @@ pairing + the legacy GCM path, security-review #5/#9) are enabled only by an exp
|
||||
the old flag is still accepted as explicit-off).
|
||||
- Windows was already opt-in (unchecked installer task) and is unchanged.
|
||||
|
||||
### TLS moved to aws-lc-rs, with post-quantum key exchange (⚠ build-visible for packagers/embedders)
|
||||
|
||||
The rustls backend across the whole workspace — host, tray, clients and `punktfunk-core` — is now
|
||||
**aws-lc-rs** instead of `ring`, which enables rustls's `prefer-post-quantum`: every TLS 1.3
|
||||
handshake (management API, the native `punktfunk/1` control plane, QUIC) now offers the
|
||||
**X25519MLKEM768** hybrid key exchange first. Ring has no ML-KEM, which is why the backend had to
|
||||
move. This is negotiation-only and additive — the classical curves stay in the list, so any client
|
||||
that does not implement ML-KEM connects exactly as before, and no wire format, ABI or pairing
|
||||
record changes. The session AEAD (AES-128-GCM / ChaCha20-Poly1305) is a separate mechanism and is
|
||||
untouched.
|
||||
|
||||
⚠ **Building from source now needs a working C compiler**, because `aws-lc-sys` compiles AWS-LC.
|
||||
No CMake, Go, or NASM is required for the default (non-FIPS) build — on Windows x86_64 rustls turns
|
||||
on `aws-lc-rs/prebuilt-nasm`, so no NASM has to be installed. If you add a crate that depends on
|
||||
`aws-lc-rs` *directly*, name `features = ["prebuilt-nasm"]` on it: a package selection that pulls
|
||||
`aws-lc-rs` without also enabling rustls's `aws_lc_rs` feature otherwise fails on Windows.
|
||||
|
||||
`punktfunk-core` gains an off-by-default **`ureq-tls`** feature (`tls::ureq_agent`) that builds a
|
||||
blocking HTTP agent around a caller-supplied `rustls::ClientConfig` — the only way to install the
|
||||
fingerprint-pinning verifier, since ureq's own `TlsConfig` has no hook for one. The desktop client
|
||||
and the tray enable it; the Apple/Android cdylib embedders do not, and pull no HTTP stack.
|
||||
|
||||
**`ring` is gone from the tree entirely** — aws-lc-rs is now the only crypto backend on every
|
||||
target we ship. Getting there needed the `ureq 2 → 3` upgrade in the same change, because ureq 2
|
||||
named `rustls/ring` inside its own dependency declaration where no dependent could switch it off.
|
||||
ureq 3 declares rustls with `default-features = false` and picks no backend, so the choice is
|
||||
finally ours. ⚠ Spell that dependency `features = ["rustls-no-provider", "rustls-webpki-roots"]`:
|
||||
ureq 3's convenience `rustls` feature pulls `_ring` and would quietly restore the second backend.
|
||||
|
||||
The ureq upgrade is otherwise internal, but two behaviours are worth knowing. Response size caps
|
||||
are now enforced by the body reader, so an over-cap response is an **error** instead of ureq 2's
|
||||
silent truncation (which used to surface as a confusing signature failure). And a fingerprint
|
||||
mismatch is now matched on ureq 3's typed `Error::Rustls(..)` rather than by sniffing a substring
|
||||
out of a transport error message — the old test could also fire on unrelated certificate errors.
|
||||
Conditional requests are unchanged: ureq 3 still returns 304 as `Ok`, only 4xx/5xx become `Err`.
|
||||
|
||||
**Embedders of `punktfunk-core` that build their own rustls configs** should still call
|
||||
`punktfunk_core::tls::install_default_provider()` at startup, or use `builder_with_provider`. With
|
||||
one backend present rustls can infer it, so this is now insurance rather than a requirement — but
|
||||
it is what stops a future second backend from turning config construction into a panic.
|
||||
|
||||
### The ENet control port now exists only while a pairing does (rust-safety WP0)
|
||||
|
||||
`rusty_enet` — a c2rust-style transpile of C ENet, and the host's only pre-auth-reachable unsafe
|
||||
@@ -220,6 +261,42 @@ Helldivers 2 at 1% lows of 2–5 FPS, cured by uninstalling). Two mechanisms, bo
|
||||
is now refcounted across the hot stream threads and reverts when the last one exits
|
||||
(= session teardown), the same lifetime the per-thread MMCSS effects already ride.
|
||||
|
||||
### Debian 13 is a supported target, and `punktfunk-gamescope` reaches apt for the first time
|
||||
|
||||
🛑 **The `punktfunk-gamescope` .deb had never been published — not once, in any release.** It was
|
||||
built inside the host job's Ubuntu 24.04 image, where it cannot build: our pin vendors wlroots
|
||||
0.19.3, which floors `wayland-server` at 1.23.1, and noble ships 1.22.0 (it also has no
|
||||
`libxcb-errors-dev` and only libdisplay-info 0.1.1). Every rung of that path was a `::warning::`
|
||||
returning 0, and the one hard gate ran last by design so good artifacts still shipped — so
|
||||
**v0.26.0 and v0.27.0 both released with the package missing** while the release notes and
|
||||
docs-site told Debian/Ubuntu users to `apt install` it. The same tag shipped it fine for Arch,
|
||||
Fedora 44 and Bazzite; apt was the only platform affected.
|
||||
|
||||
It now has its own job on **Debian 13** (`ci/gamescope-trixie.Dockerfile`), the oldest apt base the
|
||||
tree configures on. One package serves Debian 13 **and** Ubuntu 26.04 — verified by installing and
|
||||
running it on both — because the build additionally vendors libdisplay-info
|
||||
(`build-punktfunk-gamescope.sh --extra-fallback libdisplay-info`, opt-in so the Arch/Fedora/nix
|
||||
outputs are unchanged): linked against the distro copy it would demand `libdisplay-info2` on trixie,
|
||||
which Ubuntu 26.04 does not have (it carries `libdisplay-info3`). **Ubuntu 24.04 gets no gamescope
|
||||
package** — its wayland is too old to run one, however it is built.
|
||||
|
||||
⭐ **Debian 13 is now a documented, CI-tested host target** ([docs](https://docs.punktfunk.unom.io/docs/debian)).
|
||||
It required no packaging change: the host .deb's glibc-2.39 floor and bundled FFmpeg already made
|
||||
it installable, and it had been working for a long time while docs-site said Debian was unsupported
|
||||
and unverified. The desktop **client** remains Ubuntu-26.04-only (built there, floors at
|
||||
`libc6 >= 2.43`; Debian 13 has 2.41).
|
||||
|
||||
⚠ **Cinnamon (Linux Mint, LMDE) cannot host a virtual display**, and compositor detection now says
|
||||
so instead of advising a `PUNKTFUNK_COMPOSITOR` value that cannot help. Muffin forked from Mutter
|
||||
3.36: `org.cinnamon.Muffin.ScreenCast` has only `RecordMonitor`/`RecordWindow`, never
|
||||
`RecordVirtual`, and `xdg-desktop-portal-xapp` implements no ScreenCast at all. The error names the
|
||||
route that does work on those boxes — a headless gamescope, which needs no desktop compositor.
|
||||
|
||||
New CI job **`smoke-install`** installs every published package from the registry in pristine
|
||||
`ubuntu:24.04`, `ubuntu:26.04` and `debian:trixie` images and asserts the version served is the one
|
||||
the run just built. Nothing in `deb.yml` had ever installed a package it produced, which is how
|
||||
both facts above survived for so long.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
Generated
+580
-726
File diff suppressed because it is too large
Load Diff
@@ -100,7 +100,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|
||||
| Platform | Install | Guide |
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
|
||||
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
|
||||
|
||||
+1503
-616
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -40,7 +40,6 @@ accepted = [
|
||||
"CC0-1.0",
|
||||
"Unlicense",
|
||||
"WTFPL",
|
||||
"OpenSSL",
|
||||
]
|
||||
|
||||
# cbindgen is MPL-2.0 but it is a BUILD-ONLY codegen tool that never links into a shipped artifact
|
||||
@@ -57,7 +56,8 @@ ignore-dev-dependencies = true
|
||||
# accepted arm on its own (MIT/Apache-2.0 are globally accepted), so it needs no entry. (It is
|
||||
# also UEFI-target-gated out of every shipped build.)
|
||||
#
|
||||
# ring's license is an AND of permissive terms including the OpenSSL license; accept the
|
||||
# OpenSSL/ISC parts for this crate only, not globally.
|
||||
[ring]
|
||||
accepted = ["OpenSSL", "ISC"]
|
||||
# There is deliberately NO per-crate entry here any more. `ring` used to need one (its licence is an
|
||||
# AND that includes the OpenSSL licence, which was accepted for that crate alone), but the crypto
|
||||
# backend moved to aws-lc-rs and the ureq 2 → 3 upgrade removed ring from every target we build.
|
||||
# aws-lc-sys 0.44's SPDX is an AND of ISC / Apache-2.0 / MIT / BSD-3-Clause / MIT-0 — all globally
|
||||
# accepted above — and carries no OpenSSL clause, so `OpenSSL` left the global list with ring.
|
||||
|
||||
@@ -48,6 +48,11 @@ RUN pacman -Syu --noconfirm --needed \
|
||||
hwdata luajit seatd sdl2-compat vulkan-icd-loader \
|
||||
xcb-util-errors xcb-util-wm xorg-xwayland \
|
||||
meson glm wayland-protocols benchmark libxcursor \
|
||||
# mold: link-phase accelerator (sccache cannot cache linking). makepkg links the release
|
||||
# host, client, worker and tray on every arch.yml run. Wired via cargo-config-mold.toml
|
||||
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
|
||||
# and its `-static-libstdc++` link is untouched.
|
||||
mold \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
|
||||
@@ -64,3 +69,16 @@ 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
|
||||
|
||||
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
|
||||
# level (and so `cargo` finds the config below when the image is used by hand). The workflow still
|
||||
# passes CARGO_HOME explicitly across the `sudo -u builder env …` boundary, which strips ambient
|
||||
# env — that is why the C/C++ sccache wiring has to be re-exported there by name while THIS file,
|
||||
# being a file, crosses the boundary for free.
|
||||
ENV CARGO_HOME=/usr/local/cargo
|
||||
RUN mkdir -p /usr/local/cargo && chmod -R a+w /usr/local/cargo
|
||||
|
||||
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
|
||||
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
|
||||
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
|
||||
RUN mold --version && test -r /usr/local/cargo/config.toml
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Installed as $CARGO_HOME/config.toml in every Linux CI builder image (ci/*.Dockerfile).
|
||||
#
|
||||
# WHAT: link the x86_64 Linux targets with mold instead of GNU ld. Linking is the one phase of a
|
||||
# Rust build that sccache CANNOT cache — every job relinks punktfunk-host, punktfunk-client-linux,
|
||||
# punktfunk-client-session, punktfunk-cli, pf-update and punktfunk-encode-worker from scratch on
|
||||
# every run, and the packaging legs (deb/rpm/arch) do it for release binaries with full debug info.
|
||||
# mold is the only lever that touches that phase.
|
||||
#
|
||||
# ⚠ THE TRAP THIS FILE HAS TO STAY CLEAR OF — read before editing, and before adding rustflags
|
||||
# anywhere else in this repo:
|
||||
#
|
||||
# 1. A `RUSTFLAGS` ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY. It does not merge
|
||||
# and it does not append. Any job that sets RUSTFLAGS silently loses mold here (it still
|
||||
# builds — just with the default linker), and, far worse, would lose the aarch64
|
||||
# `--cfg aes_armv8` / `--cfg polyval_armv8` flags from the workspace's own .cargo/config.toml,
|
||||
# which are worth a measured ~3x on the decrypt path. audit.yml's miri gf8 step is the one
|
||||
# place in the repo that sets RUSTFLAGS, and its comment already carries this warning; keep it
|
||||
# that way. Never "simplify" this file into a RUSTFLAGS export.
|
||||
#
|
||||
# 2. CONFIG FILES MERGE PER KEY, HIGHEST-PRECEDENCE FILE WINS — they do not concatenate. The
|
||||
# workspace's .cargo/config.toml outranks this one ($CARGO_HOME is the LOWEST precedence).
|
||||
# Today that is harmless because the two files touch DISJOINT keys: the workspace file defines
|
||||
# only `target.'cfg(target_arch = "aarch64")'.rustflags`, this one only
|
||||
# `target.x86_64-unknown-linux-gnu.rustflags`, and cargo JOINS a matching cfg-spec table with
|
||||
# the triple table rather than picking one. But the moment someone adds an x86_64 rustflags
|
||||
# entry to the workspace .cargo/config.toml, IT WINS and mold silently stops being used here.
|
||||
# If that ever happens, move the link-arg into that file instead of duplicating it.
|
||||
#
|
||||
# 3. aarch64 IS DELIBERATELY NOT WIRED. The cross image links with aarch64-linux-gnu-gcc against a
|
||||
# multiarch sysroot (ci/rust-ci-arm64cross.Dockerfile); pointing that driver at mold is a
|
||||
# separate thing to prove, and those legs are already the fast ones (~1.5 min of clippy, ~5 min
|
||||
# for the arm64 .deb). Add it only with a measurement, and in a commit of its own.
|
||||
#
|
||||
# Requires GCC >= 12.1 (or clang) for `-fuse-ld=mold`; every base here ships far newer. mold itself
|
||||
# is installed in the same Dockerfile layer that copies this file, so an image can never carry the
|
||||
# flag without the linker — see the `mold --version` assertion there.
|
||||
#
|
||||
# NOTE this affects the HOST-targeted compiles of build scripts and proc macros too (they are
|
||||
# x86_64-unknown-linux-gnu), which is exactly what we want: those link constantly and are pure
|
||||
# overhead.
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
@@ -22,6 +22,12 @@ RUN dnf -y install \
|
||||
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
|
||||
# build toolchain + bindgen
|
||||
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
|
||||
# mold: link-phase accelerator (sccache cannot cache linking). This image links the release
|
||||
# host, client, worker and tray on every rpm.yml run, TWICE per push (f43 + f44). Wired via
|
||||
# cargo-config-mold.toml below. Note the linker DRIVER is unchanged — still gcc, so Fedora's
|
||||
# default `-Wl,--build-id` still reaches the link and rpmbuild's debuginfo extraction (which
|
||||
# hard-requires a build-id) behaves exactly as before; mold implements --build-id natively.
|
||||
mold \
|
||||
# ffmpeg (NVENC), capture/audio/display link deps
|
||||
ffmpeg-devel pipewire-devel wayland-devel libxkbcommon-devel opus-devel \
|
||||
mesa-libGL-devel mesa-libgbm-devel \
|
||||
@@ -76,3 +82,8 @@ 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
|
||||
|
||||
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
|
||||
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
|
||||
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
|
||||
RUN mold --version && test -r /usr/local/cargo/config.toml
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Builder for the `punktfunk-gamescope` .deb — Debian 13 (trixie).
|
||||
#
|
||||
# WHY THIS EXISTS, AND WHY IT IS NOT THE NOBLE IMAGE:
|
||||
# The gamescope .deb was built in the host job's Ubuntu 24.04 (noble) image, and it has NEVER once
|
||||
# succeeded there — v0.26.0 and v0.27.0 both shipped with no gamescope package while the release
|
||||
# notes and docs-site said it was apt-installable. The failure is structural, not a flaky dep:
|
||||
#
|
||||
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
|
||||
# subprojects/wlroots/meson.build:96:17: ERROR: Dependency 'wayland-server' is required but not found
|
||||
#
|
||||
# Our gamescope pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1. Noble ships
|
||||
# 1.22.0 and will never ship more — so no amount of `apt-get install` in that image can fix it.
|
||||
# Noble also has no `libxcb-errors-dev` at all and only libdisplay-info 0.1.1 (the tree wants 0.2).
|
||||
#
|
||||
# Debian 13 ships wayland 1.23.1 exactly, libxcb-errors 1.0.1 and libdisplay-info 0.2.0 — the
|
||||
# oldest apt distro the tree actually builds on. Building HERE rather than on Ubuntu 26.04
|
||||
# (wayland 1.24, libdisplay-info 0.3) is deliberate twice over: it keeps the glibc floor low, and
|
||||
# it stays on the libdisplay-info 0.2 line the pin was developed against.
|
||||
#
|
||||
# WHAT THE RESULTING BINARY RUNS ON — verified by building it and reading the ELF:
|
||||
# * glibc floor GLIBC_2.38 (the C++ runtime is linked statically by
|
||||
# build-punktfunk-gamescope.sh, so libstdc++ never enters the NEEDED list)
|
||||
# * NEEDED libwayland-server.so.0 / libwayland-client.so.0 — wlroots 0.19 calls symbols
|
||||
# added in 1.23.1, so THAT, not glibc, is the real floor.
|
||||
# ⇒ Debian 13 (1.23.1) and Ubuntu 26.04 (1.24.0) YES; Ubuntu 24.04 (1.22.0) NO — and 24.04
|
||||
# could not run this binary however it was built, so nothing is lost by moving off noble.
|
||||
#
|
||||
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-gamescope-trixie); consumed by
|
||||
# the `build-publish-gamescope` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci-noble, the
|
||||
# first deb.yml run after this image is added needs the image to already exist — seed it once by
|
||||
# hand (docker build -f ci/gamescope-trixie.Dockerfile -t <registry>/punktfunk-gamescope-trixie:latest ci
|
||||
# && docker push …) before that job can run.
|
||||
FROM debian:trixie
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# nodejs is not optional: the Gitea runner executes the JS actions (checkout/cache) INSIDE this
|
||||
# container, so an image without it fails before the first `run:` step ever starts.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake meson ninja-build git curl ca-certificates nodejs \
|
||||
# .deb assembly (dpkg-shlibdeps computes the runtime Depends from the built binary)
|
||||
dpkg-dev \
|
||||
# shader compilers gamescope's meson looks for
|
||||
glslc glslang-tools \
|
||||
# wayland + protocols. libwayland-dev 1.23.1 is the whole reason this image is Debian.
|
||||
libwayland-dev wayland-protocols \
|
||||
# gamescope's own dependency set. `apt-get build-dep gamescope` is useless here — Debian has
|
||||
# no gamescope package to derive it from — so the tree's needs are named outright, exactly as
|
||||
# the noble job had to. Kept as ONE transaction on purpose: in an image build a missing name
|
||||
# SHOULD fail loudly at build time, unlike the workflow's per-package best-effort loop where a
|
||||
# rename would have silently dropped a dep into a warning nobody reads.
|
||||
libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
|
||||
libxcb-ewmh-dev libxcb-icccm4-dev libxcb-errors-dev libxcb-shape0-dev \
|
||||
libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev xwayland \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Assert the ONE version that decides whether this image can do its job, so a future Debian base
|
||||
# bump that regressed it fails HERE (loudly, at image build) instead of in a deb.yml run whose
|
||||
# gamescope failure has historically been a `::warning::` nobody saw.
|
||||
RUN set -eux; \
|
||||
have="$(pkg-config --modversion wayland-server)"; \
|
||||
pkg-config --atleast-version=1.23.1 wayland-server \
|
||||
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
|
||||
echo "wayland-server $have — OK"
|
||||
@@ -26,6 +26,9 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# mold: link-phase accelerator (sccache cannot cache linking). This image links the release
|
||||
# host + encode worker on every deb.yml run. Wired via cargo-config-mold.toml below.
|
||||
mold \
|
||||
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
|
||||
dpkg-dev patchelf \
|
||||
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
|
||||
@@ -99,3 +102,10 @@ 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
|
||||
|
||||
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
|
||||
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
|
||||
# ⚠ This does NOT touch the from-source FFmpeg built above: that is a plain ./configure && make in
|
||||
# an earlier layer, linked by GNU ld exactly as before. Only cargo's links move to mold.
|
||||
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
|
||||
RUN mold --version && test -r /usr/local/cargo/config.toml
|
||||
|
||||
@@ -13,6 +13,9 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
|
||||
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||
# mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this
|
||||
# image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below.
|
||||
mold \
|
||||
# ffmpeg-next 9, built against whatever libav* 26.04 ships (FFmpeg 8 / libavcodec 62 today).
|
||||
# The crate major is a CEILING — ffmpeg-sys-next 9 spans libavcodec 56..63 — so this image does
|
||||
# not need to move in lockstep with Arch's FFmpeg 9; it just links what the distro has.
|
||||
@@ -61,3 +64,12 @@ 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
|
||||
|
||||
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
|
||||
#
|
||||
# The assertion is the point: an image carrying the flag but NOT the linker would fail every cargo
|
||||
# invocation in every consuming job, which is a catastrophic way to find out that a base image
|
||||
# renamed the package. `mold --version` fails the docker build instead, so nothing is pushed and
|
||||
# `:latest` keeps pointing at the previous working image — consumers never see it.
|
||||
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
|
||||
RUN mold --version && test -r /usr/local/cargo/config.toml
|
||||
|
||||
@@ -15,7 +15,8 @@ crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# The whole protocol/transport/FEC/crypto + the embeddable NativeClient connector. `quic` pulls
|
||||
# the punktfunk/1 control plane (now ring-only — no aws-lc, see punktfunk-core/Cargo.toml).
|
||||
# the punktfunk/1 control plane, whose TLS runs on aws-lc-rs (see punktfunk-core/Cargo.toml) —
|
||||
# aws-lc-sys cross-compiles for all three ABIs with the NDK clang cargo-ndk already exports.
|
||||
punktfunk-core = { path = "../../../crates/punktfunk-core", features = ["quic"] }
|
||||
jni = "0.21"
|
||||
log = "0.4"
|
||||
|
||||
@@ -21,7 +21,6 @@ path = "src/main.rs"
|
||||
pf-client-core = { path = "../../crates/pf-client-core", default-features = false }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1389,6 +1389,7 @@ from the config directory for a true factory reset."
|
||||
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn main() -> std::process::ExitCode {
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
// Logs to stderr; stdout is the machine interface (TSV/JSON), exactly like the session
|
||||
// binary's contract.
|
||||
tracing_subscriber::fmt()
|
||||
|
||||
@@ -33,6 +33,7 @@ mod ui_trust;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> gtk::glib::ExitCode {
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
app::run()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,14 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
quinn = "0.11"
|
||||
# Backend features mirror punktfunk-core's quinn exactly (see its Cargo.toml).
|
||||
quinn = { version = "0.11", default-features = false, features = [
|
||||
"log",
|
||||
"platform-verifier",
|
||||
"runtime-tokio",
|
||||
"rustls-aws-lc-rs",
|
||||
"bloom",
|
||||
] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros"] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -37,7 +37,6 @@ pf-client-core = { path = "../../crates/pf-client-core", default-features = fals
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
# The fake-library dev hook (`PUNKTFUNK_FAKE_LIBRARY`, browse mode) parses GameEntry JSON.
|
||||
serde_json = { version = "1", optional = true }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
|
||||
@@ -47,8 +47,15 @@ pf-client-core = { path = "../../crates/pf-client-core", default-features = fals
|
||||
# Unpublished (version 0.0.0) and fast-moving, so pinned to a verified commit. Pin bumped
|
||||
# 2026-07-29 (from the 2026-07-01 rev) for: reconciler keyed-child-order fix (#4728), widget
|
||||
# validation (#4727), DPI collision fix (#4751), icon elements (#4736), multi-window (#4730),
|
||||
# scroll virtualization (#4710). All three windows-rs deps here MUST share this rev, and it
|
||||
# must match pf-client-core's `windows` pin, so the workspace builds ONE windows-rs.
|
||||
# scroll virtualization (#4710). All three windows-rs deps here MUST share this rev, and it must
|
||||
# match pf-client-core's `windows` pin — that is what makes the `IDXGISwapChain1` handed to reactor
|
||||
# satisfy reactor's own `windows_core::Interface`.
|
||||
# ⚠ This is NOT "the workspace builds ONE windows-rs", which an earlier version of this note
|
||||
# claimed. `wasapi` (via pf-client-core) pulls the crates.io `windows 0.62.2` alongside this git
|
||||
# copy, so both are in the lock and both compile. That costs build time and binary size, not
|
||||
# correctness. ⛔ Do NOT try to collapse it with a blanket `[patch.crates-io] windows`: this rev
|
||||
# uses header-named features (`dxgi`, `combaseapi`) while a dozen other manifests still use the
|
||||
# old `Win32_*` namespace features, and the patch would break every one of them.
|
||||
windows-reactor = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a7441033d9312b16842af02eb0c2b403dc" }
|
||||
# Win32 / DXGI for the GPU picker and the shell's window plumbing. Pulled from the SAME
|
||||
# windows-rs commit as windows-reactor so their `windows-core` unifies — the `IDXGISwapChain1`
|
||||
@@ -84,7 +91,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
|
||||
# pf-client-core with the same `build-from-source,hidapi` features, so it is not a direct dep here.
|
||||
mdns-sd = "0.20"
|
||||
async-channel = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start
|
||||
tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from
|
||||
[`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's
|
||||
[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's
|
||||
**generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that
|
||||
touches the client (canary) and on `vX.Y.Z` release tags (stable) — see
|
||||
[Release Channels](https://punktfunk.unom.io/docs/channels).
|
||||
@@ -14,7 +14,7 @@ package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged
|
||||
one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed
|
||||
(`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64`
|
||||
stamps the manifest `ProcessorArchitecture` and names the output. See
|
||||
[`windows.yml`](../../../.gitea/workflows/windows.yml) for the cross-build rationale.
|
||||
[`windows-client.yml`](../../../.gitea/workflows/windows-client.yml) for the cross-build rationale.
|
||||
|
||||
## What's in the package
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ fn main() {
|
||||
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
}
|
||||
set_app_user_model_id();
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
|
||||
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
|
||||
// a GUI/MSIX launch has no console, so without the file the client side of any field report
|
||||
|
||||
@@ -29,7 +29,9 @@ ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] }
|
||||
pipewire = "0.9"
|
||||
libc = "0.2"
|
||||
# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane).
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||
# `sync` is for the `tokio::sync::oneshot` quit channels in the portal/linux capture paths. It used
|
||||
# to be absent and compile anyway, borrowed from ashpd→zbus via feature unification.
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "sync"] }
|
||||
# XFixes cursor source for gamescope (remote-desktop-sweep Phase C): gamescope paints no
|
||||
# `SPA_META_Cursor`, so the pointer never reaches the PipeWire node. We read the shape/hotspot/
|
||||
# visibility from gamescope's nested Xwayland via XFixes instead and feed the existing cursor slot.
|
||||
|
||||
@@ -96,6 +96,10 @@ struct UserData {
|
||||
/// into the first-frame-timeout retry loop; the promised renegotiation normally lands
|
||||
/// within a frame or two).
|
||||
gate_since: Option<std::time::Instant>,
|
||||
/// Deferred requeue of raw-passthrough buffers (see [`DeferredRequeue`]): the encode thread
|
||||
/// reads the dmabuf long after `.process` returns, so the buffer must not rejoin the
|
||||
/// producer's pool until the frame's [`BufferHold`] drops.
|
||||
defer: std::sync::Arc<DeferredRequeue>,
|
||||
}
|
||||
|
||||
impl UserData {
|
||||
@@ -113,6 +117,46 @@ impl UserData {
|
||||
}
|
||||
let _ = self.wake.try_send(());
|
||||
}
|
||||
|
||||
/// Withhold the raw-passthrough buffer from the producer's pool until the returned hold
|
||||
/// drops — the deferred requeue that closes the rewrite-while-the-encoder-reads race.
|
||||
/// `None` (pool too shallow, or `PUNKTFUNK_ZEROCOPY_HOLD=0`) falls back to the immediate
|
||||
/// `.process`-epilogue requeue, i.e. the old racy contract; said once per session.
|
||||
fn try_defer(&mut self, pw_buf: *mut pw::sys::pw_buffer) -> Option<pf_frame::FrameHold> {
|
||||
if !zerocopy_hold_enabled() {
|
||||
return None;
|
||||
}
|
||||
let buf = pw_buf as usize;
|
||||
let pool_live = self.pool.live;
|
||||
let generation = self.defer.book.lock().ok()?.try_hold(buf, pool_live);
|
||||
let Some(generation) = generation else {
|
||||
if !self.defer.logged_shallow.swap(true, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
pool_depth = pool_live,
|
||||
reserve = HOLD_POOL_RESERVE,
|
||||
"zero-copy: the producer's buffer pool cannot spare a buffer to hold across \
|
||||
the encode — falling back to the immediate requeue, which the producer may \
|
||||
rewrite mid-encode (torn/discolored frames under load); PUNKTFUNK_FORCE_SHM=1 \
|
||||
trades CPU for a race-free capture if artifacts appear"
|
||||
);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
if !self.defer.logged_active.swap(true, Ordering::Relaxed) {
|
||||
tracing::info!(
|
||||
pool_depth = pool_live,
|
||||
reserve = HOLD_POOL_RESERVE,
|
||||
"zero-copy: withholding each published buffer from the producer until the \
|
||||
encoder releases it (deferred requeue — the producer can no longer rewrite a \
|
||||
frame mid-encode); PUNKTFUNK_ZEROCOPY_HOLD=0 restores the immediate requeue"
|
||||
);
|
||||
}
|
||||
Some(std::sync::Arc::new(BufferHold {
|
||||
defer: self.defer.clone(),
|
||||
buf,
|
||||
generation,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the zero-copy negotiation decision depends on, gathered at ONE point in time.
|
||||
@@ -510,11 +554,12 @@ impl FenceWaitStats {
|
||||
|
||||
/// PW5 stage 1: how many buffers the producer actually allocated for this stream.
|
||||
///
|
||||
/// **Nothing in this codebase had ever counted them.** The zero-copy path dups the dmabuf fd and
|
||||
/// publishes the frame while the SPA buffer is handed straight back to the producer at `.process`
|
||||
/// return — so the only thing keeping capture untorn is that the producer round-robins a pool
|
||||
/// deeper than our import+encode window. That depth was an unmeasured assumption; this makes it a
|
||||
/// logged number, on every producer, before anything is built on it.
|
||||
/// **Nothing in this codebase had ever counted them.** The zero-copy path used to hand the SPA
|
||||
/// buffer straight back to the producer at `.process` return, leaving pool depth as the only
|
||||
/// thing keeping capture untorn. The deferred requeue ([`DeferredRequeue`]) now withholds
|
||||
/// published buffers until the consumer is done, but the depth still matters twice over: it is
|
||||
/// the budget `HoldBook::try_hold` spends (a pool of ≤ [`HOLD_POOL_RESERVE`] cannot defer at
|
||||
/// all and runs the old race), and for un-deferred frames it remains the race window.
|
||||
///
|
||||
/// `live` is maintained by the `add_buffer`/`remove_buffer` stream callbacks, which PipeWire fires
|
||||
/// on the loop thread as the pool is allocated (and again, remove-then-add, on a renegotiation that
|
||||
@@ -586,6 +631,104 @@ impl PassthroughFallbacks {
|
||||
/// short streak of dropped frames the capturer fails loudly and the session renegotiates.
|
||||
const IMPORT_FAIL_POISON: u32 = 3;
|
||||
|
||||
/// Buffers the deferred requeue always leaves in the producer's pool. One for the frame the
|
||||
/// producer is rendering right now, one in transit — withholding past that would make the
|
||||
/// producer skip frames whenever our holds are at their worst (host frame + up to two encoder
|
||||
/// ring slots), which is a pacing hiccup, not corruption, but there is no reason to court it.
|
||||
const HOLD_POOL_RESERVE: u32 = 2;
|
||||
|
||||
/// `PUNKTFUNK_ZEROCOPY_HOLD=0` restores the immediate `.process`-return requeue (the racy
|
||||
/// pre-hold behavior) — a field bisect lever, not a tuning knob. `env_on` grammar like every
|
||||
/// other capture knob (a bare `== "0"` compare is the trap `PUNKTFUNK_FORCE_SHM` already fell in).
|
||||
fn zerocopy_hold_enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| pf_host_config::env_on("PUNKTFUNK_ZEROCOPY_HOLD").unwrap_or(true))
|
||||
}
|
||||
|
||||
/// Pure bookkeeping for the deferred requeue: which buffers are currently withheld from the
|
||||
/// producer, each under a per-hold generation so a pointer-value reuse across a pool
|
||||
/// renegotiation can never satisfy a stale hold's release (see `complete`).
|
||||
///
|
||||
/// Threading contract (what makes the single-requeue invariant hold with no atomics): entries are
|
||||
/// INSERTED (`try_hold`) and REMOVED (`complete` via the requeue channel's callback, `purge` via
|
||||
/// `remove_buffer`) only on the PipeWire loop thread; a dropping [`BufferHold`] on any other
|
||||
/// thread only *sends* the release message. So between a hold's creation and the loop servicing
|
||||
/// its release, `contains` is stable — which is exactly what the `.process` epilogue relies on to
|
||||
/// decide "requeue now" vs "the hold owns the requeue".
|
||||
#[derive(Default)]
|
||||
struct HoldBook {
|
||||
/// Withheld buffers: `*mut pw_buffer` as usize → the generation of the hold that owns it.
|
||||
out: std::collections::HashMap<usize, u64>,
|
||||
/// Last issued hold generation (monotonic per stream).
|
||||
last_gen: u64,
|
||||
}
|
||||
|
||||
impl HoldBook {
|
||||
/// Withhold `buf` if the pool can spare it: at most `pool_live - HOLD_POOL_RESERVE` buffers
|
||||
/// out at once. Returns the generation to release with, or `None` (pool too shallow / buffer
|
||||
/// somehow already out — the caller falls back to the immediate requeue).
|
||||
fn try_hold(&mut self, buf: usize, pool_live: u32) -> Option<u64> {
|
||||
let cap = pool_live.saturating_sub(HOLD_POOL_RESERVE) as usize;
|
||||
if self.out.len() >= cap || self.out.contains_key(&buf) {
|
||||
return None;
|
||||
}
|
||||
self.last_gen += 1;
|
||||
self.out.insert(buf, self.last_gen);
|
||||
Some(self.last_gen)
|
||||
}
|
||||
|
||||
/// A hold released: take `buf` out of the book iff this generation still owns it. `true` ⇒
|
||||
/// the caller must requeue the buffer; `false` ⇒ the entry was purged (pool renegotiated —
|
||||
/// the pointer may even be a NEW buffer under a reused address) and the buffer must NOT be
|
||||
/// touched.
|
||||
fn complete(&mut self, buf: usize, generation: u64) -> bool {
|
||||
match self.out.get(&buf) {
|
||||
Some(&g) if g == generation => {
|
||||
self.out.remove(&buf);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `remove_buffer`: the buffer is being freed under us (renegotiation/teardown) — forget it.
|
||||
/// Its hold's later release finds the generation gone and becomes a no-op.
|
||||
fn purge(&mut self, buf: usize) {
|
||||
self.out.remove(&buf);
|
||||
}
|
||||
|
||||
fn contains(&self, buf: usize) -> bool {
|
||||
self.out.contains_key(&buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared between the loop thread ([`HoldBook`] ops) and the [`BufferHold`] guards riding
|
||||
/// published frames to the encode thread.
|
||||
struct DeferredRequeue {
|
||||
book: std::sync::Mutex<HoldBook>,
|
||||
/// Wakes the loop to requeue `(buffer, generation)`. Send failure = the loop (and with it
|
||||
/// the stream and every buffer) is gone — nothing to release.
|
||||
tx: pw::channel::Sender<(usize, u64)>,
|
||||
/// One-per-session lines: the first successful defer, and the shallow-pool fallback.
|
||||
logged_active: std::sync::atomic::AtomicBool,
|
||||
logged_shallow: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
/// The concrete [`pf_frame::FrameHold`]: releases its buffer back to the producer when the last
|
||||
/// clone drops. Send-only from the dropping thread — the actual `pw_stream_queue_buffer` runs in
|
||||
/// the requeue channel's loop-thread callback.
|
||||
struct BufferHold {
|
||||
defer: std::sync::Arc<DeferredRequeue>,
|
||||
buf: usize,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Drop for BufferHold {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.defer.tx.send((self.buf, self.generation));
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
|
||||
/// pipeline must say why without flooding).
|
||||
fn warn_once(msg: &'static str) {
|
||||
@@ -644,7 +787,14 @@ impl Drop for DmabufMap {
|
||||
/// `.process` callback with the NEWEST drained buffer (latest-frame-only). `datas` is sourced
|
||||
/// via the same transparent cast libspa's `Buffer::datas_mut` performs, so the safe `Data`
|
||||
/// accessors (`.type_()`, `.chunk()`, `.data()`, `.fd()`, `.as_raw()`) keep working.
|
||||
fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
///
|
||||
/// `pw_buf` is the buffer's `pw_buffer` handle (`spa_buf`'s owner), used only as the identity a
|
||||
/// raw-passthrough publish withholds via [`UserData::try_defer`] — never dereferenced here.
|
||||
fn consume_frame(
|
||||
ud: &mut UserData,
|
||||
spa_buf: *mut spa::sys::spa_buffer,
|
||||
pw_buf: *mut pw::sys::pw_buffer,
|
||||
) {
|
||||
// No active stream: release the buffer without the (expensive at 5K) de-pad.
|
||||
if !ud.signals.active.load(Ordering::Relaxed) {
|
||||
return;
|
||||
@@ -822,8 +972,11 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
None
|
||||
};
|
||||
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
||||
// imports it. Content stability across the brief import/encode window relies
|
||||
// on the compositor's buffer-pool depth, like any zero-copy capture.
|
||||
// imports it. Content stability across the read window comes from the deferred
|
||||
// requeue below (`try_defer` — the producer does not get this buffer back until
|
||||
// the frame's hold drops); with no hold (shallow pool / PUNKTFUNK_ZEROCOPY_HOLD=0)
|
||||
// it falls back to the compositor's pool depth outrunning the encode, the old
|
||||
// racy contract.
|
||||
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
||||
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
||||
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
||||
@@ -836,6 +989,7 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let hold = ud.try_defer(pw_buf);
|
||||
ud.publish(CapturedFrame {
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
@@ -852,6 +1006,7 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
offset,
|
||||
stride,
|
||||
plane1,
|
||||
hold,
|
||||
}),
|
||||
// Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope
|
||||
// embeds its pointer in the produced pixels, so native NV12 has none.
|
||||
@@ -1434,6 +1589,18 @@ pub fn pipewire_thread(
|
||||
);
|
||||
}
|
||||
|
||||
// Deferred requeue (the rewrite-while-encoding fix): holds riding published frames release
|
||||
// their buffers through this channel from whatever thread drops them last; the receiver —
|
||||
// attached to the loop below, after the stream exists — is the single place a withheld
|
||||
// buffer rejoins the producer's pool.
|
||||
let (requeue_tx, requeue_rx) = pw::channel::channel::<(usize, u64)>();
|
||||
let defer = std::sync::Arc::new(DeferredRequeue {
|
||||
book: std::sync::Mutex::new(HoldBook::default()),
|
||||
tx: requeue_tx,
|
||||
logged_active: std::sync::atomic::AtomicBool::new(false),
|
||||
logged_shallow: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
|
||||
let data = UserData {
|
||||
info: VideoInfoRaw::default(),
|
||||
format: None,
|
||||
@@ -1459,6 +1626,7 @@ pub fn pipewire_thread(
|
||||
},
|
||||
gate_skips: 0,
|
||||
gate_since: None,
|
||||
defer: defer.clone(),
|
||||
};
|
||||
|
||||
let stream = pw::stream::StreamBox::new(
|
||||
@@ -1562,10 +1730,18 @@ pub fn pipewire_thread(
|
||||
}
|
||||
})
|
||||
// PW5 stage 1 — the pool census. PipeWire fires these on the loop thread as it allocates
|
||||
// (and, on a renegotiation, frees then re-allocates) the stream's buffers. Counting only:
|
||||
// the buffer pointer is not touched, so no lifetime question arises here.
|
||||
// (and, on a renegotiation, frees then re-allocates) the stream's buffers. The census only
|
||||
// counts; `remove_buffer` additionally purges the buffer from the deferred-requeue book —
|
||||
// the buffer is being freed under any hold still riding a frame, so that hold's later
|
||||
// release must become a no-op (the generation check in `HoldBook::complete` also covers
|
||||
// the freed address being reused by a new pool's buffer).
|
||||
.add_buffer(|_stream, ud, _buf| ud.pool.add())
|
||||
.remove_buffer(|_stream, ud, _buf| ud.pool.remove())
|
||||
.remove_buffer(|_stream, ud, buf| {
|
||||
ud.pool.remove();
|
||||
if let Ok(mut book) = ud.defer.book.lock() {
|
||||
book.purge(buf as usize);
|
||||
}
|
||||
})
|
||||
.process(|stream, ud| {
|
||||
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its
|
||||
// pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue
|
||||
@@ -1598,19 +1774,19 @@ pub fn pipewire_thread(
|
||||
// value. MEASURED, not requested: `build_dmabuf_buffers` asks for a range and the
|
||||
// producer picks — this line is the only place the picked number is visible.
|
||||
//
|
||||
// Why it matters beyond curiosity: `stream.queue_raw_buffer(newest)` at the end of this
|
||||
// callback hands the buffer back while the encode thread may still be importing and
|
||||
// reading its dmabuf, so content stability rests entirely on the producer not cycling
|
||||
// back to this buffer before we are done with it. That window is `pool_depth` buffer
|
||||
// periods wide. A pool of 2 has essentially none.
|
||||
// Why it matters beyond curiosity: the depth is the budget the deferred requeue
|
||||
// (`HoldBook::try_hold`) spends withholding published buffers from the producer
|
||||
// while the encoder reads them. A pool of ≤ HOLD_POOL_RESERVE cannot defer at all —
|
||||
// those sessions run the old contract, where a requeued buffer may be rewritten
|
||||
// mid-encode and only pool depth keeps frames untorn.
|
||||
if let Some(depth) = ud.pool.note_frame() {
|
||||
tracing::info!(
|
||||
pool_depth = depth,
|
||||
high_water = ud.pool.high_water,
|
||||
drained,
|
||||
"pipewire buffer pool negotiated — this is the producer's ACTUAL count \
|
||||
(add_buffer/remove_buffer), the window in which a buffer we handed back may \
|
||||
be rewritten while the encoder still reads it"
|
||||
"pipewire buffer pool negotiated — the producer's ACTUAL count \
|
||||
(add_buffer/remove_buffer): the deferred-requeue budget, and the rewrite \
|
||||
window for any frame published without a hold"
|
||||
);
|
||||
}
|
||||
// Sacrificial-mode gate (kwin.rs `create`): until the producer renegotiates to the
|
||||
@@ -1766,14 +1942,30 @@ pub fn pipewire_thread(
|
||||
return;
|
||||
}
|
||||
|
||||
consume_frame(ud, spa_buf);
|
||||
consume_frame(ud, spa_buf, newest);
|
||||
}));
|
||||
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip,
|
||||
// or a caught panic in the closure above. This single requeue is what keeps the fixed
|
||||
// buffer pool from draining.
|
||||
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame) completed
|
||||
// inside the closure above; `newest` was dequeued from this stream and not yet requeued.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
// or a caught panic in the closure above — UNLESS a raw-passthrough publish withheld it
|
||||
// (`try_defer` put it in the hold book): then the requeue duty belongs to the frame's
|
||||
// `BufferHold`, and requeueing here too would hand the producer the same buffer twice.
|
||||
// The book is stable across this check: only this thread removes entries (the requeue
|
||||
// channel's callback / `remove_buffer`), and neither can run inside `.process` — a
|
||||
// consumer racing the frame to its drop merely queues the release message. A panic
|
||||
// AFTER the publish leaves the hold live on the published frame, so skipping the
|
||||
// immediate requeue remains correct on that path too.
|
||||
let withheld = ud
|
||||
.defer
|
||||
.book
|
||||
.lock()
|
||||
.map(|b| b.contains(newest as usize))
|
||||
.unwrap_or(false);
|
||||
if !withheld {
|
||||
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame)
|
||||
// completed inside the closure above; `newest` was dequeued from this stream,
|
||||
// not yet requeued, and — per the `withheld` check — carries no hold that would
|
||||
// requeue it a second time.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
}
|
||||
if outcome.is_err() {
|
||||
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
|
||||
// format) would fire this every frame, so power-of-two throttle it — enough to
|
||||
@@ -1789,6 +1981,34 @@ pub fn pipewire_thread(
|
||||
.register()
|
||||
.context("register stream listener")?;
|
||||
|
||||
// The deferred-requeue service. A `BufferHold` dropping on any thread only *sends*
|
||||
// `(buffer, generation)`; this callback — on the loop thread, like every other stream op —
|
||||
// is where a withheld buffer actually rejoins the producer's pool. `HoldBook::complete`
|
||||
// makes a release for a renegotiated-away buffer (or a freed address reused by a new
|
||||
// pool's buffer) a no-op, so a stale hold can never queue somebody else's buffer.
|
||||
let defer_cb = defer.clone();
|
||||
let stream_ptr = stream.as_raw_ptr() as usize;
|
||||
let _requeue_attach = requeue_rx.attach(mainloop.loop_(), move |(buf, generation)| {
|
||||
let requeue = defer_cb
|
||||
.book
|
||||
.lock()
|
||||
.map(|mut b| b.complete(buf, generation))
|
||||
.unwrap_or(false);
|
||||
if requeue {
|
||||
// SAFETY: `complete` returned true ⇒ this buffer was withheld by exactly this hold
|
||||
// and no `remove_buffer` has freed it since (that purges the book), so the pointer
|
||||
// is a live buffer of this stream that we own (dequeued, never requeued). The
|
||||
// stream outlives this attached receiver (declared after it, dropped before it),
|
||||
// and the loop stops dispatching once `run()` returns.
|
||||
let _ = unsafe {
|
||||
pw::sys::pw_stream_queue_buffer(
|
||||
stream_ptr as *mut pw::sys::pw_stream,
|
||||
buf as *mut pw::sys::pw_buffer,
|
||||
)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Debug knob: offer a single fixed format (PUNKTFUNK_PW_FIXED_POD="WxH") to bisect
|
||||
// negotiation failures against a producer's exact EnumFormat (e.g. gamescope).
|
||||
let fixed_pod: Option<(u32, u32)> = std::env::var("PUNKTFUNK_PW_FIXED_POD")
|
||||
@@ -2479,4 +2699,77 @@ mod tests {
|
||||
assert_eq!(p.note_frame(), Some(0));
|
||||
assert_eq!(p.high_water, 0);
|
||||
}
|
||||
|
||||
use super::{HoldBook, HOLD_POOL_RESERVE};
|
||||
|
||||
/// The book must always leave [`HOLD_POOL_RESERVE`] buffers with the producer: an 8-pool
|
||||
/// spares 6, and the pools at or below the reserve spare NOTHING — those sessions must fall
|
||||
/// back to the immediate requeue rather than starve the compositor of render targets.
|
||||
#[test]
|
||||
fn hold_book_spends_at_most_pool_minus_reserve() {
|
||||
let mut b = HoldBook::default();
|
||||
for i in 0..6 {
|
||||
assert!(
|
||||
b.try_hold(0x1000 + i, 8).is_some(),
|
||||
"hold {i} within budget"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
b.try_hold(0x2000, 8).is_none(),
|
||||
"7th of 8 exceeds the budget"
|
||||
);
|
||||
assert!(
|
||||
HoldBook::default()
|
||||
.try_hold(0x1000, HOLD_POOL_RESERVE)
|
||||
.is_none(),
|
||||
"a pool of exactly the reserve cannot spare a buffer"
|
||||
);
|
||||
assert!(
|
||||
HoldBook::default()
|
||||
.try_hold(0x1000, HOLD_POOL_RESERVE + 1)
|
||||
.is_some(),
|
||||
"one past the reserve spares exactly one"
|
||||
);
|
||||
}
|
||||
|
||||
/// One hold ⇒ one requeue: the first `complete` releases, a duplicate release (a bug shape,
|
||||
/// but also the benign stale-message case) must NOT requeue a second time — handing the
|
||||
/// producer the same buffer twice corrupts its pool.
|
||||
#[test]
|
||||
fn hold_book_releases_exactly_once() {
|
||||
let mut b = HoldBook::default();
|
||||
let g = b.try_hold(0x1000, 8).unwrap();
|
||||
assert!(b.complete(0x1000, g), "first release requeues");
|
||||
assert!(!b.complete(0x1000, g), "second release is a no-op");
|
||||
assert!(!b.contains(0x1000));
|
||||
}
|
||||
|
||||
/// The renegotiation hazard the generation exists for: the pool is replaced (`remove_buffer`
|
||||
/// purges), a NEW buffer lands on the SAME address and is withheld, and only then does the
|
||||
/// OLD hold's release arrive. Matching by pointer alone would requeue the new tenant while
|
||||
/// its own hold is still out — the mid-encode rewrite race, reintroduced by the fix itself.
|
||||
#[test]
|
||||
fn hold_book_generation_outlives_an_address_reuse() {
|
||||
let mut b = HoldBook::default();
|
||||
let old = b.try_hold(0x1000, 8).unwrap();
|
||||
b.purge(0x1000); // remove_buffer: pool renegotiated away under the hold
|
||||
assert!(!b.complete(0x1000, old), "purged hold releases nothing");
|
||||
let new = b.try_hold(0x1000, 8).unwrap(); // new pool's buffer, same address
|
||||
assert!(
|
||||
!b.complete(0x1000, old),
|
||||
"the OLD hold cannot release the NEW tenant"
|
||||
);
|
||||
assert!(b.contains(0x1000), "new tenant still withheld");
|
||||
assert!(b.complete(0x1000, new), "its own hold releases it");
|
||||
}
|
||||
|
||||
/// A buffer already out cannot be withheld again (one requeue duty per buffer): `.process`
|
||||
/// can only re-see an address after its requeue, so a duplicate try_hold means state
|
||||
/// confusion — refuse it and let the epilogue requeue immediately.
|
||||
#[test]
|
||||
fn hold_book_refuses_a_buffer_already_out() {
|
||||
let mut b = HoldBook::default();
|
||||
b.try_hold(0x1000, 8).unwrap();
|
||||
assert!(b.try_hold(0x1000, 8).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,12 +322,14 @@ pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
|
||||
|
||||
/// PW5 stage 2: the buffer-pool depth we ASK for on the zero-copy path, as a Choice range.
|
||||
///
|
||||
/// The zero-copy path hands the SPA buffer back to the producer at `.process` return, while the
|
||||
/// encode thread still holds a dup of its dmabuf fd and has not yet imported, let alone read, the
|
||||
/// contents. Nothing bounds that window — see the `queue_raw_buffer` comment in `pipewire.rs` — so
|
||||
/// the only thing that keeps capture untorn is the producer round-robining a pool deeper than our
|
||||
/// import+encode latency. Until PW5 stage 1 nobody had ever counted what that pool was; we never
|
||||
/// even asked for a size (`build_dmabuf_buffers` set `dataType` and nothing else).
|
||||
/// The raw-passthrough arm now WITHHOLDS each published buffer from the producer until the
|
||||
/// consumer's hold drops (`DeferredRequeue` in `pipewire.rs` — the fix for the producer
|
||||
/// rewriting a buffer mid-encode), spending up to `pool - HOLD_POOL_RESERVE` buffers of this
|
||||
/// depth. A pool at the old floor of 2 has nothing to spend and falls back to the racy
|
||||
/// immediate requeue, where only the producer round-robining a pool deeper than our
|
||||
/// import+encode latency keeps capture untorn. Until PW5 stage 1 nobody had ever counted what
|
||||
/// that pool was; we never even asked for a size (`build_dmabuf_buffers` set `dataType` and
|
||||
/// nothing else).
|
||||
///
|
||||
/// A **range**, deliberately, not a fixed count: SPA intersects the consumer's and producer's
|
||||
/// Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection
|
||||
|
||||
@@ -14,7 +14,7 @@ repository.workspace = true
|
||||
# the old main.rs. Audio is the one per-OS swap: PipeWire on Linux, WASAPI on Windows
|
||||
# (same public surface — see lib.rs).
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic", "ureq-tls"] }
|
||||
# Native Vulkan Video decode (WP-C of the native-decode program, HEVC added by M3
|
||||
# WP-2, AV1 by M7): auto's TOP rung on both desktop OSes since M9 — for every codec it
|
||||
# speaks, AV1 included — also pinnable via `PUNKTFUNK_DECODER=native-vulkan` —
|
||||
@@ -101,11 +101,19 @@ ash = { version = "0.38", optional = true }
|
||||
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
|
||||
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
||||
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
||||
ureq = "2"
|
||||
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature: that one pulls `_ring`, which would
|
||||
# put the ring backend back into a tree that has moved to aws-lc-rs. Same spelling everywhere.
|
||||
ureq = { version = "3", default-features = false, features = [
|
||||
"rustls-no-provider",
|
||||
"rustls-webpki-roots",
|
||||
"gzip",
|
||||
] }
|
||||
# Signed update-manifest fetch/verify + the install-kind ladder, shared with the host so one
|
||||
# trust rule serves both (crates/pf-update-check).
|
||||
pf-update-check = { path = "../pf-update-check" }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
# aws-lc-rs backend + PQ hybrid key exchange, matching punktfunk-core (see its Cargo.toml for
|
||||
# why every crate that names a rustls backend has to name the same one).
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "logging", "std", "tls12"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
@@ -166,6 +174,11 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
|
||||
"handleapi",
|
||||
# RECT/HMONITOR for DXGI_OUTPUT_DESC1 (the display-HDR volume query).
|
||||
"windef",
|
||||
# HGLOBAL (clipboard.rs) + HINSTANCE (video_d3d11.rs), and the NT `HANDLE` the shared-surface
|
||||
# hand-off uses. Both headers were used without being declared — they resolved only because
|
||||
# clients/windows enables them on the same pinned rev, so this crate did not build standalone.
|
||||
"minwindef",
|
||||
"winnt",
|
||||
# IDXGIResource1::CreateSharedHandle takes an optional SECURITY_ATTRIBUTES.
|
||||
"minwinbase",
|
||||
# The GlobalAlloc block the clipboard takes ownership of (clipboard.rs).
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -171,9 +170,9 @@ pub fn agent(
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
let bad =
|
||||
|what: &str, e: &dyn std::fmt::Display| LibraryError::Unreachable(format!("{what}: {e}"));
|
||||
// The ring provider, explicitly — the same one core's QUIC endpoints install, so the
|
||||
// The aws-lc-rs provider, explicitly — the same one core's QUIC endpoints install, so the
|
||||
// process never mixes rustls crypto providers.
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let builder = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| bad("tls config", &e))?
|
||||
@@ -186,11 +185,15 @@ pub fn agent(
|
||||
let cfg = builder
|
||||
.with_client_auth_cert(vec![cert], key)
|
||||
.map_err(|e| bad("client auth", &e))?;
|
||||
Ok(ureq::AgentBuilder::new()
|
||||
.tls_config(Arc::new(cfg))
|
||||
.timeout_connect(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build())
|
||||
// ureq's own `TlsConfig` has no hook for a custom verifier, so the agent is built around this
|
||||
// `ClientConfig` verbatim (punktfunk-core owns that glue — see `tls::ureq_agent`).
|
||||
Ok(punktfunk_core::tls::ureq_agent::agent(
|
||||
Arc::new(cfg),
|
||||
ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.timeout_global(Some(Duration::from_secs(10)))
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetch the host's unified library. Errors are pre-classified for the UI (401/403 →
|
||||
@@ -204,8 +207,9 @@ pub fn fetch_games(
|
||||
let agent = agent(identity, pin)?;
|
||||
let url = format!("{}/api/v1/library", base_url(addr, mgmt_port));
|
||||
let body = match agent.get(&url).call() {
|
||||
Ok(resp) => resp
|
||||
.into_string()
|
||||
Ok(mut resp) => resp
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|e| LibraryError::Unreachable(format!("read body: {e}")))?,
|
||||
Err(e) => return Err(classify(e)),
|
||||
};
|
||||
@@ -221,18 +225,26 @@ const ART_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// a public CDN URL on a custom entry — uses ureq's default agent with normal webpki
|
||||
/// trust and no client cert (Apple's `LibraryTLSDelegate` does the same split).
|
||||
pub fn fetch_art(pinned: &ureq::Agent, base: &str, url: &str) -> Result<Vec<u8>, LibraryError> {
|
||||
let resp = if url.starts_with(base) {
|
||||
let mut resp = if url.starts_with(base) {
|
||||
pinned.get(url).call()
|
||||
} else {
|
||||
ureq::get(url).timeout(Duration::from_secs(10)).call()
|
||||
// ureq's default agent builds its own rustls config from the process-default provider.
|
||||
// Installed here rather than trusting the binary, since several link this crate.
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
ureq::get(url)
|
||||
.config()
|
||||
.timeout_global(Some(Duration::from_secs(10)))
|
||||
.build()
|
||||
.call()
|
||||
}
|
||||
.map_err(classify)?;
|
||||
let mut bytes = Vec::new();
|
||||
resp.into_reader()
|
||||
.take(ART_MAX_BYTES)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| LibraryError::Unreachable(format!("read image: {e}")))?;
|
||||
Ok(bytes)
|
||||
// `limit` replaces the old `take()` — ureq 3 caps body reads itself, and its default cap is
|
||||
// lower than the largest legitimate hero asset.
|
||||
resp.body_mut()
|
||||
.with_config()
|
||||
.limit(ART_MAX_BYTES)
|
||||
.read_to_vec()
|
||||
.map_err(|e| LibraryError::Unreachable(format!("read image: {e}")))
|
||||
}
|
||||
|
||||
/// Concurrent poster fetches — a handful is plenty for a LAN art proxy without turning a
|
||||
@@ -288,19 +300,15 @@ pub fn spawn_art_fetch(
|
||||
|
||||
fn classify(e: ureq::Error) -> LibraryError {
|
||||
match e {
|
||||
ureq::Error::Status(401 | 403, _) => LibraryError::NotPaired,
|
||||
ureq::Error::Status(code, _) => LibraryError::Http(code),
|
||||
ureq::Error::Transport(t) => {
|
||||
// A pin rejection surfaces as a TLS alert wrapped in a transport error; the
|
||||
// verifier's error kind survives in the message.
|
||||
let msg = t.to_string();
|
||||
if msg.contains("ApplicationVerificationFailure") || msg.contains("InvalidCertificate")
|
||||
{
|
||||
LibraryError::PinMismatch
|
||||
} else {
|
||||
LibraryError::Unreachable(msg)
|
||||
}
|
||||
}
|
||||
ureq::Error::StatusCode(401 | 403) => LibraryError::NotPaired,
|
||||
ureq::Error::StatusCode(code) => LibraryError::Http(code),
|
||||
// Exactly the rejection `PinVerify` raises on a fingerprint mismatch. ureq 3 carries the
|
||||
// typed `rustls::Error`, so this is a real match instead of the substring sniff the 2.x
|
||||
// `Transport(t)` string forced — which would also have fired on unrelated cert errors.
|
||||
ureq::Error::Rustls(rustls::Error::InvalidCertificate(
|
||||
rustls::CertificateError::ApplicationVerificationFailure,
|
||||
)) => LibraryError::PinMismatch,
|
||||
other => LibraryError::Unreachable(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,15 @@ publish = false
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
quinn = "0.11"
|
||||
# Backend features mirror punktfunk-core's quinn exactly — quinn's default `rustls-ring` would
|
||||
# drag a second crypto stack into every build that links this crate.
|
||||
quinn = { version = "0.11", default-features = false, features = [
|
||||
"log",
|
||||
"platform-verifier",
|
||||
"runtime-tokio",
|
||||
"rustls-aws-lc-rs",
|
||||
"bloom",
|
||||
] }
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
# CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format.
|
||||
# Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host.
|
||||
|
||||
@@ -12,7 +12,11 @@ repository.workspace = true
|
||||
[target.'cfg(any(target_os = "linux", windows))'.dependencies]
|
||||
pf-presenter = { path = "../pf-presenter" }
|
||||
# MenuEvent/MenuPulse (the gamepad service's menu mode drives the library).
|
||||
pf-client-core = { path = "../pf-client-core" }
|
||||
# `default-features = false` like every other consumer (pf-presenter, cli, session, clients/windows):
|
||||
# pf-client-core's default is `pyrowave`, which compiles the vendored PyroWave C++ — fatal on
|
||||
# Windows ARM64. Whether that backend is on is the session binary's call (it forwards a `pyrowave`
|
||||
# feature); this crate needs none of it, and taking defaults here quietly turned it on.
|
||||
pf-client-core = { path = "../pf-client-core", default-features = false }
|
||||
|
||||
# Skia on the presenter's VkDevice (`vulkan`); `textlayout` = skparagraph/harfbuzz for
|
||||
# the typography the console library needs (~15 MB stripped, prebuilt binaries exist for
|
||||
|
||||
@@ -84,9 +84,9 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
# D3DKMTSetProcessSchedulingPriorityClass — raise the host's WDDM GPU scheduling priority
|
||||
# above a running game so PyroWave's compute-shader encode isn't starved (enc/windows/pyrowave.rs).
|
||||
"Wdk_Graphics_Direct3D",
|
||||
# ("Wdk_Graphics_Direct3D" used to be here for D3DKMTSetProcessSchedulingPriorityClass. That
|
||||
# call lives in pf-frame's dxgi.rs and is resolved via GetProcAddress on gdi32 because
|
||||
# windows-rs has no stable binding for it — so nothing in this crate ever used the feature.)
|
||||
] }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -2803,6 +2803,7 @@ mod tests {
|
||||
plane1: None,
|
||||
offset: 0,
|
||||
stride: 64 * 4,
|
||||
hold: None,
|
||||
}
|
||||
};
|
||||
let fd_count = || std::fs::read_dir("/proc/self/fd").expect("procfs").count();
|
||||
|
||||
@@ -936,6 +936,7 @@ mod tests {
|
||||
plane1: None,
|
||||
offset: 0,
|
||||
stride: 1920 * 4,
|
||||
hold: None,
|
||||
}),
|
||||
cursor,
|
||||
}
|
||||
|
||||
@@ -586,6 +586,12 @@ struct Frame {
|
||||
pts_ns: u64,
|
||||
keyframe: bool,
|
||||
recovery_anchor: bool,
|
||||
/// The captured dmabuf's deferred-requeue hold ([`pf_frame::FrameHold`]), cloned at submit and
|
||||
/// dropped when this slot retires (fence signaled — `poll`/backpressure/`reset`). This is what
|
||||
/// extends "the producer must not rewrite the buffer" across the whole asynchronous GPU read:
|
||||
/// the host's own clone only lives until it takes the NEXT frame, which with a ring of 2 is
|
||||
/// before this slot's encode finished. `None` for non-dmabuf sources or un-held frames.
|
||||
src_hold: Option<pf_frame::FrameHold>,
|
||||
}
|
||||
|
||||
pub struct VulkanVideoEncoder {
|
||||
@@ -2274,7 +2280,9 @@ impl VulkanVideoEncoder {
|
||||
// First import: acquire from the foreign producer (UNDEFINED preserves the modifier-tiled
|
||||
// bytes). Cached re-read: we still own it, so no queue-family transfer — just a visibility
|
||||
// barrier so the shader read sees the content the producer wrote out-of-band this frame
|
||||
// (single-GPU coherent; the capture layer guarantees the buffer is ready at hand-off).
|
||||
// (single-GPU coherent). The barrier orders nothing against the PRODUCER — content
|
||||
// stability across this read is the frame's deferred-requeue hold (`Frame::src_hold`):
|
||||
// the producer does not get the buffer back to rewrite until this slot's fence retires.
|
||||
let (old, src_qf, dst_qf) = if fresh {
|
||||
(
|
||||
vk::ImageLayout::UNDEFINED,
|
||||
@@ -3875,11 +3883,24 @@ impl VulkanVideoEncoder {
|
||||
),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
// Fence signaled ⟹ the GPU is done reading this slot's captured dmabuf — release
|
||||
// its hold so the capture layer requeues the producer's buffer.
|
||||
self.frames[slot].src_hold = None;
|
||||
let done = self.read_slot(slot)?;
|
||||
self.pending.push_back(done);
|
||||
}
|
||||
let slot = self.ring;
|
||||
self.ring = (self.ring + 1) % self.frames.len();
|
||||
// Take over the frame's deferred-requeue hold for this occupancy BEFORE recording: the
|
||||
// producer must not get the buffer back until this slot's fence retires (poll /
|
||||
// backpressure / reset), because the encode reads the imported dmabuf for its whole
|
||||
// duration — the host's own clone drops as soon as it takes the next frame. Assigned
|
||||
// even if `record_submit` then fails: an over-hold until the slot's next tenant is
|
||||
// harmless, a released-while-referenced buffer is the exact race this closes.
|
||||
self.frames[slot].src_hold = match &frame.payload {
|
||||
FramePayload::Dmabuf(d) => d.hold.clone(),
|
||||
_ => None,
|
||||
};
|
||||
self.record_submit(slot, frame, wire)?;
|
||||
self.in_flight.push_back(slot);
|
||||
Ok(())
|
||||
@@ -4002,6 +4023,9 @@ impl Encoder for VulkanVideoEncoder {
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
self.in_flight.pop_front();
|
||||
// Fence signaled ⟹ the GPU is done reading this slot's captured dmabuf — release its
|
||||
// hold so the capture layer requeues the producer's buffer.
|
||||
self.frames[slot].src_hold = None;
|
||||
// SAFETY: fence signaled ⟹ this slot's CSC+encode is complete; read its bitstream.
|
||||
Ok(Some(unsafe { self.read_slot(slot)? }))
|
||||
}
|
||||
@@ -4064,6 +4088,11 @@ impl Encoder for VulkanVideoEncoder {
|
||||
}
|
||||
self.in_flight.clear();
|
||||
self.pending.clear();
|
||||
// The waits above proved every slot's GPU read is done — release the captured-dmabuf
|
||||
// holds so the capture layer (possibly mid-rebuild itself) gets its buffers back.
|
||||
for f in &mut self.frames {
|
||||
f.src_hold = None;
|
||||
}
|
||||
self.ring = 0;
|
||||
self.first_frame = true;
|
||||
self.force_kf = false;
|
||||
|
||||
@@ -692,6 +692,9 @@ fn encode_one(
|
||||
plane1: req.plane1,
|
||||
offset: req.offset,
|
||||
stride: req.stride,
|
||||
// The deferred-requeue hold stays host-side: this backend is synchronous at depth 1
|
||||
// (see below), so the host's frame — hold and all — outlives the whole encode.
|
||||
hold: None,
|
||||
}),
|
||||
cursor,
|
||||
};
|
||||
|
||||
@@ -231,6 +231,23 @@ pub struct CapturedFrame {
|
||||
pub cursor: Option<CursorOverlay>,
|
||||
}
|
||||
|
||||
/// Keeps the producer's buffer behind a zero-copy frame OUT of the producer's pool.
|
||||
///
|
||||
/// The fd on a [`DmabufFrame`] only keeps the buffer object from being *freed*; nothing stops the
|
||||
/// compositor from *re-rendering into it* once the capture layer hands the buffer back — which it
|
||||
/// used to do at `.process` return, before the encoder had even imported the dmabuf (the
|
||||
/// gamescope-at-120fps torn-frame race). This handle is the fix: the PipeWire capture attaches one
|
||||
/// to every raw-passthrough frame (pool depth permitting) and defers the requeue until the LAST
|
||||
/// clone drops. A consumer that reads the dmabuf asynchronously (the Vulkan encoder's ring) clones
|
||||
/// it into whatever tracks the read (its ring slot) and drops it when the GPU is provably done
|
||||
/// (the slot's fence), so content stability covers exactly the read window. Consumers that finish
|
||||
/// their read while the frame is alive need to do nothing — the frame's own clone is enough.
|
||||
///
|
||||
/// Opaque on purpose: the concrete guard lives in the capture crate; everyone else only clones and
|
||||
/// drops.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type FrameHold = std::sync::Arc<dyn std::any::Any + Send + Sync>;
|
||||
|
||||
/// A captured frame still living in a DMA-BUF. Packed RGB uses one plane. Native Linux NV12
|
||||
/// (gamescope PipeWire) travels in ONE fd: Y starts at `offset`, and the interleaved UV plane
|
||||
/// lives at `plane1`'s offset/stride when the producer reported them — else at the contiguous
|
||||
@@ -238,8 +255,9 @@ pub struct CapturedFrame {
|
||||
///
|
||||
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
|
||||
/// imported there without the compositor's buffer being closed underneath it. Content stability
|
||||
/// across the brief import window relies on the compositor's buffer pool depth, like any zero-copy
|
||||
/// capture.
|
||||
/// across the read window comes from [`hold`](Self::hold) when present (the producer does not get
|
||||
/// the buffer back until the hold drops); a `None` hold falls back to the old contract — the
|
||||
/// compositor's pool depth outrunning the import+encode window.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct DmabufFrame {
|
||||
pub fd: std::os::fd::OwnedFd,
|
||||
@@ -253,6 +271,9 @@ pub struct DmabufFrame {
|
||||
pub plane1: Option<(u32, u32)>,
|
||||
pub offset: u32,
|
||||
pub stride: u32,
|
||||
/// Deferred-requeue hold on the producer's buffer (see [`FrameHold`]); `None` when the
|
||||
/// capture could not spare a buffer from the pool (shallow pool, or `PUNKTFUNK_ZEROCOPY_HOLD=0`).
|
||||
pub hold: Option<FrameHold>,
|
||||
}
|
||||
|
||||
/// Where a captured frame's pixels live.
|
||||
|
||||
@@ -41,7 +41,10 @@ wayland-backend = "0.3"
|
||||
# libei (EI sender) for the portable input path on KWin/GNOME (RemoteDesktop portal) + gamescope-EI.
|
||||
reis = { version = "0.6.1", features = ["tokio"] }
|
||||
futures-util = "0.3"
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||
# `macros` is for the `tokio::select!` in the libei and steam_usbip worker loops. It used to be
|
||||
# absent and compile anyway, borrowed from punktfunk-core's `quic` feature via unification — i.e. an
|
||||
# unrelated crate dropping it would have broken this one.
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time", "macros"] }
|
||||
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
|
||||
xkbcommon = "0.8"
|
||||
# Vendored + trimmed usbip server core — presents a virtual Steam Deck over USB/IP for Steam Input.
|
||||
|
||||
@@ -22,13 +22,27 @@ publish = false
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Ed25519 over the exact manifest bytes. The workspace is ring-only (no aws-lc-sys — it fails
|
||||
# on the Windows CI runner), and this is the same primitive the plugin-store index uses.
|
||||
ring = "0.17"
|
||||
# Ed25519 over the exact manifest bytes — the same primitive the plugin-store index uses, on the
|
||||
# workspace's one crypto backend. aws-lc-rs's API is ring-compatible, so the call sites are
|
||||
# unchanged apart from the crate name.
|
||||
#
|
||||
# `prebuilt-nasm` is what lets aws-lc-sys build on Windows x86_64 without NASM installed. rustls
|
||||
# enables it for its own dependents, but a build that selects THIS crate without one that turns on
|
||||
# rustls's `aws_lc_rs` feature — `cargo test -p pf-update-check` is exactly that, since its only
|
||||
# rustls comes from ureq's ring-flavoured dependency — would get no enabler and fail on the CI
|
||||
# runner. Naming it here makes the crate build standalone instead of relying on who else is in
|
||||
# the selection.
|
||||
aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] }
|
||||
base64 = "0.22"
|
||||
# Small, sync, bundles webpki roots — no system cert store dependency, which matters on the
|
||||
# Deck (Decky's embedded Python has no usable roots either; see clients/decky/main.py).
|
||||
ureq = "2"
|
||||
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature — that one pulls `_ring`, which would
|
||||
# put the ring backend back into a tree that has deliberately moved to aws-lc-rs.
|
||||
ureq = { version = "3", default-features = false, features = [
|
||||
"rustls-no-provider",
|
||||
"rustls-webpki-roots",
|
||||
"gzip",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -79,17 +79,18 @@ pub fn fetch_manifest_blocking(
|
||||
"no update key is pinned in this build".into(),
|
||||
));
|
||||
}
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.redirects(3)
|
||||
.user_agent(user_agent)
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(FETCH_TIMEOUT))
|
||||
.max_redirects(3)
|
||||
.user_agent(user_agent.to_string())
|
||||
.build()
|
||||
.into();
|
||||
let url = format!("{base}/{channel}/manifest.json");
|
||||
let sig_url = format!("{url}.sig");
|
||||
|
||||
// Only the MANIFEST leg can report an empty channel; see [`FeedError::NotPublished`].
|
||||
let body = read_capped(agent.get(&url).call().map_err(manifest_err)?)?;
|
||||
let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?;
|
||||
let body = read_capped(&mut agent.get(&url).call().map_err(manifest_err)?)?;
|
||||
let sig = read_capped(&mut agent.get(&sig_url).call().map_err(fetch_err)?)?;
|
||||
let sig_text = String::from_utf8(sig)
|
||||
.map_err(|_| FeedError::Failed("signature file is not text".into()))?;
|
||||
|
||||
@@ -100,24 +101,26 @@ pub fn fetch_manifest_blocking(
|
||||
/// The manifest leg: a 404 here means the channel is empty, not broken.
|
||||
fn manifest_err(e: ureq::Error) -> FeedError {
|
||||
match e {
|
||||
ureq::Error::Status(404, _) => FeedError::NotPublished,
|
||||
ureq::Error::StatusCode(404) => FeedError::NotPublished,
|
||||
other => fetch_err(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_err(e: ureq::Error) -> FeedError {
|
||||
FeedError::Failed(match e {
|
||||
ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
|
||||
ureq::Error::StatusCode(code) => format!("feed returned HTTP {code}"),
|
||||
other => format!("feed fetch failed: {other}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, FeedError> {
|
||||
use std::io::Read as _;
|
||||
let mut buf = Vec::new();
|
||||
let mut reader = resp.into_reader().take(MAX_MANIFEST_BYTES as u64 + 1);
|
||||
reader
|
||||
.read_to_end(&mut buf)
|
||||
fn read_capped(resp: &mut ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, FeedError> {
|
||||
// cap+1 so an over-cap body is rejected by the length check rather than silently truncated
|
||||
// into something that would then fail signature verification for the wrong reason.
|
||||
let buf = resp
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MAX_MANIFEST_BYTES as u64 + 1)
|
||||
.read_to_vec()
|
||||
.map_err(|e| FeedError::Failed(format!("read failed: {e}")))?;
|
||||
if buf.len() > MAX_MANIFEST_BYTES {
|
||||
return Err(FeedError::Failed(
|
||||
@@ -143,7 +146,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn status(code: u16) -> ureq::Error {
|
||||
ureq::Error::Status(code, ureq::Response::new(code, "status", "").unwrap())
|
||||
ureq::Error::StatusCode(code)
|
||||
}
|
||||
|
||||
/// The whole point of the split: an empty channel is not a broken feed.
|
||||
|
||||
@@ -40,7 +40,8 @@ pub fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Res
|
||||
.decode(sig_text.trim())
|
||||
.context("signature file is not valid base64")?;
|
||||
for key in keys {
|
||||
let pk = ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &key.0);
|
||||
let pk =
|
||||
aws_lc_rs::signature::UnparsedPublicKey::new(&aws_lc_rs::signature::ED25519, &key.0);
|
||||
if pk.verify(bytes, &sig).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -52,14 +53,14 @@ pub fn verify_signature(bytes: &[u8], sig_text: &str, keys: &[PublicKey]) -> Res
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fresh ring keypair as `(pinned key string, signer)` — the format contract with the
|
||||
/// A fresh keypair as `(pinned key string, signer)` — the format contract with the
|
||||
/// CI signers (raw 32-byte key, `ed25519:<base64>`; raw 64-byte signature, base64).
|
||||
pub(crate) fn keypair() -> (String, ring::signature::Ed25519KeyPair) {
|
||||
pub(crate) fn keypair() -> (String, aws_lc_rs::signature::Ed25519KeyPair) {
|
||||
use aws_lc_rs::signature::KeyPair as _;
|
||||
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 rng = aws_lc_rs::rand::SystemRandom::new();
|
||||
let pkcs8 = aws_lc_rs::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
|
||||
let kp = aws_lc_rs::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
|
||||
let key_str = format!(
|
||||
"ed25519:{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
|
||||
|
||||
+141
-19
@@ -321,11 +321,7 @@ pub fn detect() -> Result<Compositor> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(v) = pf_host_config::config().compositor.as_deref() {
|
||||
return compositor_from_pin(v).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
|
||||
)
|
||||
});
|
||||
return compositor_from_pin(v).ok_or_else(|| unknown_pin_error(v));
|
||||
}
|
||||
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
|
||||
return Ok(c);
|
||||
@@ -338,20 +334,78 @@ pub fn detect() -> Result<Compositor> {
|
||||
let desktop = with_env_lock(|| std::env::var("XDG_CURRENT_DESKTOP"))
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
if desktop.contains("KDE") {
|
||||
Ok(Compositor::Kwin)
|
||||
} else if desktop.contains("GNOME") {
|
||||
Ok(Compositor::Mutter)
|
||||
} else if desktop.contains("HYPRLAND") {
|
||||
Ok(Compositor::Hyprland)
|
||||
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
|
||||
Ok(Compositor::Wlroots)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"could not detect compositor: no live graphical session for this uid and \
|
||||
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
|
||||
)
|
||||
}
|
||||
compositor_from_xdg(&desktop)
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for a `PUNKTFUNK_COMPOSITOR` value that names no backend.
|
||||
///
|
||||
/// `cinnamon`/`muffin` get their own answer rather than the bare list: it is the value a Mint or
|
||||
/// LMDE user reaches for first, and the plain list invites them to try the next-closest name
|
||||
/// (`mutter` — Muffin *is* a Mutter fork), which starts a session that then fails deep inside a
|
||||
/// `org.gnome.Mutter.ScreenCast` call Muffin does not serve. There is no working value; say so, and
|
||||
/// name the route that does work.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn unknown_pin_error(v: &str) -> anyhow::Error {
|
||||
const ACCEPTED: &str = "kwin|wlroots|hyprland|mutter|gamescope";
|
||||
if matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"cinnamon" | "muffin"
|
||||
) {
|
||||
return anyhow::anyhow!(
|
||||
"PUNKTFUNK_COMPOSITOR='{v}' is not a backend and cannot become one: Cinnamon's \
|
||||
compositor Muffin has no virtual-output API (no `RecordVirtual`), so it cannot make a \
|
||||
screen for a client. Do NOT substitute 'mutter' — Muffin is a Mutter fork but serves \
|
||||
none of that interface. Use PUNKTFUNK_COMPOSITOR=gamescope to stream games through a \
|
||||
headless gamescope, which needs no desktop compositor. See \
|
||||
https://docs.punktfunk.unom.io/docs/debian#cinnamon-linux-mint-and-lmde"
|
||||
);
|
||||
}
|
||||
anyhow::anyhow!("unknown PUNKTFUNK_COMPOSITOR '{v}' ({ACCEPTED})")
|
||||
}
|
||||
|
||||
/// The last-resort `XDG_CURRENT_DESKTOP` sniff, as a **pure function of the (uppercased) value** so
|
||||
/// its branches — including the two that only ever produce an error — are testable without mutating
|
||||
/// process-global env. Called only by [`detect`], after both the operator pin and live-session
|
||||
/// detection have come up empty.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn compositor_from_xdg(desktop: &str) -> Result<Compositor> {
|
||||
// CINNAMON is tested FIRST, ahead of GNOME, and the order is load-bearing rather than
|
||||
// stylistic: Cinnamon is a GNOME derivative, so a session that advertises both (`X-Cinnamon`
|
||||
// alongside a GNOME-compatibility token) would otherwise match the GNOME arm and be handed the
|
||||
// Mutter backend — which then fails deep in a `org.gnome.Mutter.ScreenCast` call that Muffin
|
||||
// does not serve, i.e. an obscure D-Bus error instead of the explanation below. The more
|
||||
// specific desktop wins.
|
||||
if desktop.contains("CINNAMON") {
|
||||
// Linux Mint / LMDE report `X-Cinnamon`. Cinnamon is NOT a missing backend we could add —
|
||||
// its compositor (Muffin) exposes no virtual-output API at all: the fork base is Mutter
|
||||
// 3.36, and `org.cinnamon.Muffin.ScreenCast` carries only `RecordMonitor` / `RecordWindow`,
|
||||
// never Mutter 42+'s `RecordVirtual`. Its portal backend (xdg-desktop-portal-xapp)
|
||||
// implements no ScreenCast either, so the sway/Hyprland portal route is closed too. The
|
||||
// generic message below would send a Cinnamon user hunting for the setting that turns it
|
||||
// on; there isn't one. Name the ONE route that does work on that box — a headless
|
||||
// gamescope, which needs no desktop compositor at all — instead of a dead end.
|
||||
anyhow::bail!(
|
||||
"Cinnamon (XDG_CURRENT_DESKTOP='{desktop}') cannot host a virtual display: its \
|
||||
compositor Muffin has no virtual-output API, so Punktfunk cannot create a screen \
|
||||
for a client on it. Stream games instead by setting PUNKTFUNK_COMPOSITOR=gamescope \
|
||||
in host.env — the host then spawns its own headless gamescope per connect and needs \
|
||||
no desktop session. See \
|
||||
https://docs.punktfunk.unom.io/docs/debian#cinnamon-linux-mint-and-lmde"
|
||||
)
|
||||
} else if desktop.contains("KDE") {
|
||||
Ok(Compositor::Kwin)
|
||||
} else if desktop.contains("GNOME") {
|
||||
Ok(Compositor::Mutter)
|
||||
} else if desktop.contains("HYPRLAND") {
|
||||
Ok(Compositor::Hyprland)
|
||||
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
|
||||
Ok(Compositor::Wlroots)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"could not detect compositor: no live graphical session for this uid and \
|
||||
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -811,6 +865,74 @@ mod wlroots;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The XDG sniff is the last thing standing between an unrecognized desktop and a useless
|
||||
/// error, and `mgmt/display.rs` puts that error VERBATIM in the console's `/display/monitors`
|
||||
/// response — so its exact wording is a user-facing surface, tested as one.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn xdg_sniff_maps_known_desktops() {
|
||||
// Real-world values, uppercased the way `detect` hands them over.
|
||||
assert_eq!(compositor_from_xdg("KDE").unwrap(), Compositor::Kwin);
|
||||
assert_eq!(compositor_from_xdg("GNOME").unwrap(), Compositor::Mutter);
|
||||
assert_eq!(
|
||||
compositor_from_xdg("UBUNTU:GNOME").unwrap(),
|
||||
Compositor::Mutter
|
||||
);
|
||||
assert_eq!(
|
||||
compositor_from_xdg("HYPRLAND").unwrap(),
|
||||
Compositor::Hyprland
|
||||
);
|
||||
assert_eq!(compositor_from_xdg("SWAY").unwrap(), Compositor::Wlroots);
|
||||
}
|
||||
|
||||
/// Cinnamon must NOT fall into the generic "set PUNKTFUNK_COMPOSITOR" arm: Muffin has no
|
||||
/// virtual-output API, so there is no value of that variable which makes a Cinnamon desktop
|
||||
/// host a virtual display. The error has to name gamescope — the one route that works on an
|
||||
/// LMDE/Mint box — or the user is sent hunting for a setting that does not exist.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn cinnamon_is_told_to_use_gamescope_not_to_pick_a_backend() {
|
||||
// `X-Cinnamon` is what Mint and LMDE actually set.
|
||||
for v in ["X-CINNAMON", "CINNAMON", "X-CINNAMON:GNOME-FLASHBACK"] {
|
||||
let err = compositor_from_xdg(v)
|
||||
.expect_err("Cinnamon cannot host a virtual display")
|
||||
.to_string();
|
||||
assert!(err.contains("gamescope"), "no gamescope route named: {err}");
|
||||
assert!(err.contains("Muffin"), "does not say why: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pinning `cinnamon` explicitly must not answer with the plain list of accepted values: the
|
||||
/// next thing a Mint user tries is `mutter` (Muffin is a Mutter fork), which fails much later
|
||||
/// and much less clearly. A typo'd pin still gets the ordinary list.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn pinning_cinnamon_explains_instead_of_listing_backends() {
|
||||
for v in ["cinnamon", "Cinnamon", "muffin", " MUFFIN "] {
|
||||
let err = unknown_pin_error(v).to_string();
|
||||
assert!(err.contains("gamescope"), "no working route named: {err}");
|
||||
assert!(
|
||||
err.contains("Muffin"),
|
||||
"does not explain why it cannot work: {err}"
|
||||
);
|
||||
}
|
||||
let typo = unknown_pin_error("kwim").to_string();
|
||||
assert!(
|
||||
typo.contains("kwin|wlroots|hyprland|mutter|gamescope"),
|
||||
"{typo}"
|
||||
);
|
||||
assert!(!typo.contains("Muffin"), "{typo}");
|
||||
}
|
||||
|
||||
/// An unknown desktop keeps the generic advice — the Cinnamon arm must not swallow it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn unknown_desktop_keeps_the_generic_error() {
|
||||
let err = compositor_from_xdg("XFCE").unwrap_err().to_string();
|
||||
assert!(err.contains("PUNKTFUNK_COMPOSITOR"), "{err}");
|
||||
assert!(!err.contains("Muffin"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_kind_maps_to_its_backend() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -111,9 +111,15 @@ struct OperatorGamescope {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn operator_gamescope() -> &'static OperatorGamescope {
|
||||
OPERATOR_GAMESCOPE.get_or_init(|| {
|
||||
// Explicit-off grammar, NOT a presence test. These two used to be `var_os(..).is_some()`,
|
||||
// which read `PUNKTFUNK_GAMESCOPE_ATTACH=0` as ATTACH ON — the exact opposite of what the
|
||||
// line says, and of every other knob on this host (`env_on` is shared for that reason).
|
||||
// Anyone turning a shipped `=1` off does it the way the rest of the file works, and the
|
||||
// rung this feeds outranks a dedicated game session, so a silent inversion here costs the
|
||||
// client its own display for the whole stream.
|
||||
let ov = with_env_lock(|| OperatorGamescope {
|
||||
managed: std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
|
||||
attach: std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
|
||||
managed: pf_host_config::env_on("PUNKTFUNK_GAMESCOPE_MANAGED").unwrap_or(false),
|
||||
attach: pf_host_config::env_on("PUNKTFUNK_GAMESCOPE_ATTACH").unwrap_or(false),
|
||||
node: std::env::var("PUNKTFUNK_GAMESCOPE_NODE")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty()),
|
||||
|
||||
@@ -16,9 +16,11 @@ publish = false
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# `Mode` (the negotiated display mode) is the core wire type; `pf-paths` for the pnp-disabled-monitors
|
||||
# state file.
|
||||
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||
# Just `punktfunk_core::Mode` (win_display.rs), which lives in the ungated `config` module — the
|
||||
# `quic` feature this used to request dragged quinn/tokio/rcgen/hmac/spake2/opus/rustls into a leaf
|
||||
# crate's declared closure for one type.
|
||||
punktfunk-core = { path = "../punktfunk-core", default-features = false }
|
||||
pf-paths = { path = "../pf-paths" }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
# The pnp-disabled-monitors state file (a `Vec<String>` of instance ids) is serialized as JSON.
|
||||
serde_json = "1"
|
||||
|
||||
@@ -24,6 +24,12 @@ tls = ["dep:rustls", "dep:sha2", "dep:rustls-pki-types"]
|
||||
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
|
||||
# never on the per-frame hot path. Off by default so the core stays runtime-free.
|
||||
quic = ["tls", "dep:quinn", "dep:tokio", "dep:rcgen", "dep:hmac", "dep:spake2", "dep:opus"]
|
||||
# Blocking-HTTP clients that must speak the SAME pinned TLS as the QUIC plane: `tls::ureq_agent`
|
||||
# hands ureq a caller-built `rustls::ClientConfig` (which is how `PinVerify` gets installed —
|
||||
# ureq's own `TlsConfig` has no hook for a custom verifier). Separate from `tls` because the
|
||||
# cdylib/staticlib embedders (Apple, Android) pin the host themselves over QUIC and have no use
|
||||
# for an HTTP stack; only the desktop clients and the tray turn this on.
|
||||
ureq-tls = ["tls", "dep:ureq"]
|
||||
|
||||
[dependencies]
|
||||
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
||||
@@ -39,7 +45,6 @@ aes-gcm = "0.10" # AES-128-GCM session crypto, matches GameStream
|
||||
# cross-compiles like aes-gcm (no cmake). See design/chacha20-session-cipher.md.
|
||||
chacha20poly1305 = "0.10"
|
||||
zerocopy = { version = "0.8", features = ["derive"] }
|
||||
bytes = "1"
|
||||
socket2 = { version = "0.6", features = [
|
||||
"all",
|
||||
] } # SO_SNDBUF/SO_RCVBUF growth (default UDP buffers too small for 4K/5K bursts) + DSCP/SO_PRIORITY media QoS
|
||||
@@ -52,14 +57,31 @@ zeroize = "1"
|
||||
# not just the default route. Tiny, cross-platform (getifaddrs / GetAdaptersAddresses), no cmake.
|
||||
if-addrs = "0.13"
|
||||
|
||||
quinn = { version = "0.11", optional = true }
|
||||
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
|
||||
# Crypto backend pinned to `ring` (matching rustls/quinn above) so the whole quic tree is
|
||||
# ring-only: no aws-lc-rs/aws-lc-sys (heavy C dep, needs cmake) is pulled in. Keeps the
|
||||
# Android/iOS cdylib lean and the cross-compile cmake-free. `generate_simple_self_signed`
|
||||
# is backend-agnostic, so the swap is transparent.
|
||||
rcgen = { version = "0.13", optional = true, default-features = false, features = ["ring", "pem"] }
|
||||
# Crypto backend is aws-lc-rs, and rustls/quinn/rcgen must all name it: they each select a
|
||||
# backend independently, so one dissenter pulls a SECOND crypto stack in via feature unification.
|
||||
# `prefer-post-quantum` puts the X25519MLKEM768 hybrid key exchange first in the TLS 1.3
|
||||
# handshake, which is the reason the old `ring` pin is gone — ring has no ML-KEM.
|
||||
# Windows needs no NASM: rustls's `aws_lc_rs` feature enables `aws-lc-rs/prebuilt-nasm`.
|
||||
# quinn's feature list is its own default set with `rustls-ring` swapped out, nothing more.
|
||||
quinn = { version = "0.11", optional = true, default-features = false, features = [
|
||||
"log",
|
||||
"platform-verifier",
|
||||
"runtime-tokio",
|
||||
"rustls-aws-lc-rs",
|
||||
"bloom",
|
||||
] }
|
||||
rustls = { version = "0.23", optional = true, default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std"] }
|
||||
# `generate_simple_self_signed` is backend-agnostic, so the swap is transparent here.
|
||||
rcgen = { version = "0.13", optional = true, default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||
rustls-pki-types = { version = "1", optional = true }
|
||||
# `rustls-no-provider`, NOT the default `rustls` feature — ureq's `rustls` feature body pulls
|
||||
# `_ring`, which would drag the whole ring backend back into a tree that has deliberately moved to
|
||||
# aws-lc-rs. `rustls-webpki-roots` supplies the CA set for the non-pinned origins (cover-art CDNs).
|
||||
ureq = { version = "3", optional = true, default-features = false, features = [
|
||||
"rustls-no-provider",
|
||||
"rustls-webpki-roots",
|
||||
"gzip",
|
||||
] }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
hmac = { version = "0.12", optional = true }
|
||||
spake2 = { version = "0.4", optional = true }
|
||||
|
||||
@@ -176,7 +176,7 @@ fn server_from_der(
|
||||
addr: std::net::SocketAddr,
|
||||
idle: std::time::Duration,
|
||||
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
// Client auth is OFFERED but optional: a client that presents its self-signed
|
||||
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
|
||||
// one that presents none still connects (and is rejected at the app layer when
|
||||
@@ -254,7 +254,7 @@ pub fn client_pinned_with_identity(
|
||||
) -> PinnedClient {
|
||||
let observed = Arc::new(Mutex::new(None));
|
||||
let ep = (|| {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let builder = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(crate::tls::PinVerify::with_observed(
|
||||
@@ -354,7 +354,7 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -368,12 +368,12 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,25 @@
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A blocking HTTP agent over a caller-built `rustls::ClientConfig` — the only way to give an
|
||||
/// HTTP client the [`PinVerify`] verifier below.
|
||||
#[cfg(feature = "ureq-tls")]
|
||||
pub mod ureq_agent;
|
||||
|
||||
/// Install aws-lc-rs as this process's rustls provider. Call once, early, from `main`.
|
||||
///
|
||||
/// aws-lc-rs is currently the ONLY backend in the tree, so rustls can infer it and this call is
|
||||
/// belt-and-braces rather than load-bearing. It is kept because the inference is what breaks
|
||||
/// first: the moment any dependency drags a second backend in — which is exactly what `ureq 2`
|
||||
/// used to do, naming `rustls/ring` in its own dependency line where no dependent could switch it
|
||||
/// off — rustls stops guessing, and every config built through `ClientConfig::builder()` rather
|
||||
/// than `builder_with_provider` **panics** instead of picking one. Calling this makes the choice
|
||||
/// explicit and survives that. Idempotent: losing the race to another installer is the expected
|
||||
/// outcome, not an error, since every caller in this workspace installs the same provider.
|
||||
pub fn install_default_provider() {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
}
|
||||
|
||||
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. Re-exported as
|
||||
/// `crate::quic::endpoint::cert_fingerprint` for callers that already reach it there.
|
||||
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
||||
@@ -91,7 +110,7 @@ impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,12 +124,12 @@ impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
&rustls::crypto::aws_lc_rs::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! A blocking [`ureq::Agent`] that speaks TLS through a caller-supplied
|
||||
//! [`rustls::ClientConfig`] — which is the only way to get [`PinVerify`](super::PinVerify) into an
|
||||
//! HTTP client, because ureq's own `TlsConfig` exposes roots, a client cert and an
|
||||
//! off-switch, but no hook for a custom [`ServerCertVerifier`](rustls::client::danger::ServerCertVerifier).
|
||||
//!
|
||||
//! Every caller here pins the host's self-signed leaf by fingerprint (the same trust rule as the
|
||||
//! QUIC plane), so "just use the default agent" is not an option: the default agent validates
|
||||
//! against webpki roots, which a self-signed host cert can never satisfy.
|
||||
//!
|
||||
//! The connector below is modelled on ureq 3.x's own (crate-private) `RustlsConnector` minus its
|
||||
//! `TlsConfig`-driven config-building step. It is transport glue, not crypto: the handshake, the
|
||||
//! verifier and the cipher suites all live in the `ClientConfig` the caller hands in.
|
||||
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::sync::Arc;
|
||||
|
||||
use ureq::unversioned::resolver::DefaultResolver;
|
||||
use ureq::unversioned::transport::{
|
||||
Buffers, ConnectionDetails, Connector, Either, LazyBuffers, NextTimeout, TcpConnector,
|
||||
Transport, TransportAdapter,
|
||||
};
|
||||
|
||||
/// Build an agent whose HTTPS connections use `tls` verbatim, with `config` for everything else
|
||||
/// (timeouts, redirect policy, buffer sizes) — built by the caller via
|
||||
/// [`ureq::Agent::config_builder`], since those knobs differ per call site.
|
||||
pub fn agent(tls: Arc<rustls::ClientConfig>, config: ureq::config::Config) -> ureq::Agent {
|
||||
let connector = TcpConnector::default().chain(PinnedTlsConnector { config: tls });
|
||||
ureq::Agent::with_parts(config, connector, DefaultResolver::default())
|
||||
}
|
||||
|
||||
struct PinnedTlsConnector {
|
||||
config: Arc<rustls::ClientConfig>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PinnedTlsConnector {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PinnedTlsConnector").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<In: Transport> Connector<In> for PinnedTlsConnector {
|
||||
type Out = Either<In, PinnedTlsTransport>;
|
||||
|
||||
fn connect(
|
||||
&self,
|
||||
details: &ConnectionDetails,
|
||||
chained: Option<In>,
|
||||
) -> Result<Option<Self::Out>, ureq::Error> {
|
||||
let Some(transport) = chained else {
|
||||
// Unreachable via `agent()` above, which always chains onto a TcpConnector.
|
||||
return Err(ureq::Error::Tls("no chained transport to wrap in TLS"));
|
||||
};
|
||||
// A plain-HTTP URL, or something that already negotiated TLS, passes straight through.
|
||||
if !details.needs_tls() || transport.is_tls() {
|
||||
return Ok(Some(Either::A(transport)));
|
||||
}
|
||||
|
||||
let name: rustls::pki_types::ServerName<'_> = details
|
||||
.uri
|
||||
.authority()
|
||||
.ok_or(ureq::Error::Tls("uri has no authority"))?
|
||||
.host()
|
||||
.try_into()
|
||||
.map_err(|_| ureq::Error::Tls("invalid DNS name"))?;
|
||||
let conn = rustls::ClientConnection::new(self.config.clone(), name.to_owned())?;
|
||||
let stream = rustls::StreamOwned {
|
||||
conn,
|
||||
sock: TransportAdapter::new(transport.boxed()),
|
||||
};
|
||||
let buffers = LazyBuffers::new(
|
||||
details.config.input_buffer_size(),
|
||||
details.config.output_buffer_size(),
|
||||
);
|
||||
Ok(Some(Either::B(PinnedTlsTransport { buffers, stream })))
|
||||
}
|
||||
}
|
||||
|
||||
struct PinnedTlsTransport {
|
||||
buffers: LazyBuffers,
|
||||
stream: rustls::StreamOwned<rustls::ClientConnection, TransportAdapter>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PinnedTlsTransport {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PinnedTlsTransport").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for PinnedTlsTransport {
|
||||
fn buffers(&mut self) -> &mut dyn Buffers {
|
||||
&mut self.buffers
|
||||
}
|
||||
|
||||
fn transmit_output(&mut self, amount: usize, timeout: NextTimeout) -> Result<(), ureq::Error> {
|
||||
self.stream.get_mut().set_timeout(timeout);
|
||||
let output = &self.buffers.output()[..amount];
|
||||
self.stream.write_all(output)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn await_input(&mut self, timeout: NextTimeout) -> Result<bool, ureq::Error> {
|
||||
self.stream.get_mut().set_timeout(timeout);
|
||||
let input = self.buffers.input_append_buf();
|
||||
let amount = self.stream.read(input)?;
|
||||
self.buffers.input_appended(amount);
|
||||
Ok(amount > 0)
|
||||
}
|
||||
|
||||
fn is_open(&mut self) -> bool {
|
||||
self.stream.get_mut().get_mut().is_open()
|
||||
}
|
||||
|
||||
fn is_tls(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,15 @@ pf-vdisplay = { path = "../pf-vdisplay" }
|
||||
# compiles everywhere; the backends are cfg-gated inside it.
|
||||
pf-clipboard = { path = "../pf-clipboard" }
|
||||
# M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP).
|
||||
quinn = "0.11"
|
||||
# Feature list = quinn's own defaults with `rustls-ring` swapped for `rustls-aws-lc-rs`; every
|
||||
# crate selecting a rustls backend must agree or feature unification builds both (see core).
|
||||
quinn = { version = "0.11", default-features = false, features = [
|
||||
"log",
|
||||
"platform-verifier",
|
||||
"runtime-tokio",
|
||||
"rustls-aws-lc-rs",
|
||||
"bloom",
|
||||
] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -66,7 +74,6 @@ mdns-sd = "0.20"
|
||||
mac_address = "1"
|
||||
if-addrs = "0.13"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
parking_lot = "0.12"
|
||||
# GameStream-only (behind the `gamestream` feature): the Moonlight RSA-2048 identity generator +
|
||||
# pairing signer (cert.rs, pairing.rs) and the legacy-client-cert leniency verifier (tls.rs).
|
||||
# The native planes use the P-256 identity (src/identity.rs) and never touch this crate — so a
|
||||
@@ -85,21 +92,28 @@ base64 = "0.22"
|
||||
# run on a background thread off the hot path. `ureq` is small + sync (no tokio here) and bundles
|
||||
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
|
||||
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
|
||||
ureq = "2"
|
||||
rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
|
||||
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature — that one pulls `_ring`, which would
|
||||
# put the ring backend back into a tree that has deliberately moved to aws-lc-rs.
|
||||
ureq = { version = "3", default-features = false, features = [
|
||||
"rustls-no-provider",
|
||||
"rustls-webpki-roots",
|
||||
"gzip",
|
||||
] }
|
||||
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||
x509-parser = "0.16"
|
||||
# Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over
|
||||
# tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that
|
||||
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
|
||||
axum-server = "0.8"
|
||||
# Ring backend, NOT the (default) aws-lc-rs one — matches punktfunk-core + the client so the whole
|
||||
# tree stays ring-only (no aws-lc-sys: a heavy C dep that fails to build on the Windows CI runner).
|
||||
# aws-lc-rs backend, matching punktfunk-core + the clients (a dissenting crate would pull a
|
||||
# second crypto stack in). `prefer-post-quantum` offers X25519MLKEM768 first on the mgmt/native
|
||||
# TLS 1.3 listeners; classical curves stay in the list, so a client without ML-KEM still connects.
|
||||
# Keep `tls12` for GameStream/Moonlight clients that negotiate TLS 1.2.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "std", "tls12", "logging"] }
|
||||
# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a
|
||||
# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the
|
||||
# verified fingerprint injected as a request extension. Versions match the workspace lock.
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12", "logging"] }
|
||||
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
@@ -122,10 +136,12 @@ serde_json = "1"
|
||||
# utoipa into axum 0.8 extractors; utoipa-axum collects `#[utoipa::path]` routes into the
|
||||
# spec; utoipa-scalar serves the interactive docs. Codegen-friendly: the spec is emitted
|
||||
# verbatim by the `openapi` subcommand. Control plane only — never the per-frame path.
|
||||
# Plugin-store index signatures: ed25519 verification of a catalog document before any field of
|
||||
# it is read (store/index.rs). Already in the tree via rustls — the workspace is ring-only, so this
|
||||
# is the one signature primitive available without pulling aws-lc-sys (which fails on Windows CI).
|
||||
ring = "0.17"
|
||||
# SHA-256 over the downloaded installer and its signing-leaf DER (update/windows.rs) — Windows-only
|
||||
# in practice, the plugin-store's ed25519 path re-exports pf-update-check's verifier. Already in the
|
||||
# tree via rustls; its API is ring-compatible by design, which is what made the swap mechanical.
|
||||
# `prebuilt-nasm`: see pf-update-check's copy — it keeps aws-lc-sys building on Windows x86_64
|
||||
# with no NASM on the box, whatever else the build happens to select.
|
||||
aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] }
|
||||
# Semver comparisons for the plugin store: `minHost` gating and the revocation list's version
|
||||
# ranges (`<0.3.2`). Already in the lockfile transitively.
|
||||
semver = "1"
|
||||
@@ -134,8 +150,9 @@ utoipa-axum = "0.2"
|
||||
utoipa-scalar = { version = "0.3", features = ["axum"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Drive the management API router in-process (no socket) in the handler tests.
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
# (`tower` drives the management API router in-process in the handler tests, but it is already a
|
||||
# normal dependency above and those are visible to tests — the dev-dependency re-declaration was
|
||||
# redundant.)
|
||||
http-body-util = "0.1"
|
||||
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
|
||||
tempfile = "3"
|
||||
@@ -157,51 +174,21 @@ libc = "0.2"
|
||||
# Must match the pipewire crate ashpd 0.13 links (libspa/pipewire-sys `links` key is
|
||||
# unique per build), i.e. 0.9 — NOT the 0.10 the setup doc mentions.
|
||||
pipewire = "0.9"
|
||||
# ashpd 0.13 uses the tokio runtime; a current-thread runtime drives the one-time
|
||||
# portal handshake (control plane — never the per-frame path).
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||
# Input injection into headless Sway via the wlroots virtual-input Wayland protocols
|
||||
# (uinput won't reach a compositor running with WLR_LIBINPUT_NO_DEVICES=1).
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||
wayland-protocols-misc = { version = "0.3", features = ["client"] }
|
||||
# `xdg-output` (zxdg_output_v1): the per-output *logical* geometry (post-scale size + global
|
||||
# position), used by the KWin fake_input backend to map absolute coordinates under display scaling.
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin
|
||||
# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path).
|
||||
# `wayland-backend` is referenced by the generated interface tables.
|
||||
wayland-scanner = "0.31"
|
||||
wayland-backend = "0.3"
|
||||
# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend).
|
||||
serde_json = "1"
|
||||
# (ashpd 0.13 drives the one-time portal handshake on tokio; the unconditional `tokio = "full"`
|
||||
# in [dependencies] already covers that — this block used to re-declare a strict subset of it.)
|
||||
# NOTE: the Wayland stack (wayland-client / -protocols{,-wlr,-misc} / -scanner / -backend),
|
||||
# `xkbcommon`, `reis`, `khronos-egl`, `ash` and `usbip-sim` used to be declared here. The code that
|
||||
# used them moved to `pf-inject` (virtual input, libei, USB/IP) and `pf-zerocopy` (EGL/Vulkan dmabuf)
|
||||
# in the subsystem extraction, and each of those crates declares them itself — only the manifest
|
||||
# entries were left behind, along with comments describing code this crate no longer contains.
|
||||
# Verified unused before removal: zero `use`/path references across src/ + build.rs.
|
||||
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
|
||||
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
|
||||
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
|
||||
xkbcommon = "0.8"
|
||||
# libei (EI sender) for the portable input path on KWin/GNOME (RemoteDesktop portal).
|
||||
# The `tokio` feature wires reis's event stream into tokio's reactor.
|
||||
reis = { version = "0.6.1", features = ["tokio"] }
|
||||
# `StreamExt::next` on reis's tokio event stream in the libei worker loop.
|
||||
futures-util = "0.3"
|
||||
# Zero-copy capture (plan §9): EGL imports the PipeWire dmabuf, CUDA maps it, NVENC encodes
|
||||
# it with no CPU roundtrip. `khronos-egl` (dynamic = load the NVIDIA libEGL at runtime) gives
|
||||
# eglCreateImage + the dma_buf import; the CUDA driver API (EGL interop) and libgbm are linked
|
||||
# via hand-rolled FFI in `src/zerocopy/` (no Rust crate exposes the EGL-interop driver calls).
|
||||
khronos-egl = { version = "6", features = ["dynamic"] }
|
||||
# Vulkan bridge for LINEAR dmabufs (gamescope): import via VK_EXT_external_memory_dma_buf,
|
||||
# GPU-copy into an exportable allocation, export OPAQUE_FD → cuImportExternalMemory (the
|
||||
# officially-supported CUDA pairing; raw dmabuf fds are rejected by the desktop driver).
|
||||
ash = "0.38"
|
||||
# `libcuda.so.1` is dlopen'd at runtime (NOT link-time) so one Linux binary runs on NVIDIA
|
||||
# (zero-copy via CUDA) AND on AMD/Intel (VAAPI, no NVIDIA driver present) — see `zerocopy::cuda`.
|
||||
libloading = "0.8"
|
||||
# Vendored + trimmed `usbip` server core (no libusb) — presents a virtual Steam Deck over USB/IP
|
||||
# so the local `vhci_hcd` attaches it: the shippable, Secure-Boot-clean, Steam-Input-promotable
|
||||
# virtual-Deck transport on non-SteamOS hosts (`inject/linux/steam_usbip.rs`). See the crate's NOTICE.
|
||||
usbip-sim = { path = "vendor/usbip-sim" }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend
|
||||
@@ -300,12 +287,12 @@ winreg = "0.56"
|
||||
roxmltree = "0.21"
|
||||
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
|
||||
wasapi = "0.23"
|
||||
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend
|
||||
# (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs,
|
||||
# defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs
|
||||
# to/from the DeviceIoControl byte buffers.
|
||||
# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend: the
|
||||
# control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs, defined ONCE so host<->driver
|
||||
# ABI drift is a compile error (used from `capture.rs`). The `bytemuck` that serializes those
|
||||
# structs into the DeviceIoControl buffers is pf-vdisplay's dependency, not this crate's — it was
|
||||
# declared here too, unused.
|
||||
pf-driver-proto = { path = "../pf-driver-proto" }
|
||||
bytemuck = { version = "1.19", features = ["derive"] }
|
||||
|
||||
# The encode feature flags now FORWARD to the pf-encode subsystem crate (the heavy encoder deps —
|
||||
# ffmpeg-next, the NVENC SDK, openh264, pyrowave-sys — moved there, plan §W6). Selecting a feature
|
||||
|
||||
@@ -459,7 +459,7 @@ pub fn serve(
|
||||
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
|
||||
rt.block_on(async move {
|
||||
// rustls needs a process-wide crypto provider before any TLS config is built.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let native_opts = crate::native::native_serve_opts(&native);
|
||||
// The hook runner consumes the live event tail for the host's lifetime — spawned BEFORE
|
||||
// `host.started` is emitted so operator hooks observe the full lifecycle (RFC §6).
|
||||
|
||||
@@ -292,7 +292,7 @@ fn build_server_config(
|
||||
key_pem: &str,
|
||||
mandatory: bool,
|
||||
) -> Result<Arc<ServerConfig>> {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
// PEM parsing via rustls-pki-types (the same `PemObject` path punktfunk-core/quic.rs uses),
|
||||
// so we don't pull the unmaintained `rustls-pemfile`.
|
||||
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
|
||||
|
||||
@@ -811,11 +811,12 @@ fn webhook_host_is_internal(url: &str) -> bool {
|
||||
fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
|
||||
// TLS is verified (ureq's default rustls roots); redirects are never followed, so a
|
||||
// compromised receiver can't bounce the POST cross-origin (RFC §9.5).
|
||||
let agent = ureq::builder()
|
||||
.redirects(0)
|
||||
.timeout(WEBHOOK_TIMEOUT)
|
||||
.build();
|
||||
let mut req = agent.post(url).set("Content-Type", "application/json");
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.max_redirects(0)
|
||||
.timeout_global(Some(WEBHOOK_TIMEOUT))
|
||||
.build()
|
||||
.into();
|
||||
let mut req = agent.post(url).header("Content-Type", "application/json");
|
||||
if let Some(path) = secret_file {
|
||||
match std::fs::read(path) {
|
||||
Ok(secret) => {
|
||||
@@ -829,7 +830,7 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
|
||||
};
|
||||
mac.update(json.as_bytes());
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
req = req.set("X-Punktfunk-Signature", &format!("sha256={sig}"));
|
||||
req = req.header("X-Punktfunk-Signature", &format!("sha256={sig}"));
|
||||
}
|
||||
Err(e) => {
|
||||
// A configured-but-unreadable secret means the operator WANTS signing —
|
||||
@@ -840,9 +841,9 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
match req.send_string(json) {
|
||||
Ok(resp) => tracing::debug!(url, status = resp.status(), "webhook delivered"),
|
||||
Err(ureq::Error::Status(code, _)) => {
|
||||
match req.send(json) {
|
||||
Ok(resp) => tracing::debug!(url, status = resp.status().as_u16(), "webhook delivered"),
|
||||
Err(ureq::Error::StatusCode(code)) => {
|
||||
tracing::warn!(url, status = code, "webhook rejected by receiver")
|
||||
}
|
||||
Err(e) => tracing::warn!(url, error = %e, "webhook delivery failed"),
|
||||
|
||||
@@ -86,14 +86,21 @@ fn warm_art_once() {
|
||||
/// HTTP GET + parse JSON with a bounded timeout. `None` on any network/parse failure (best-effort —
|
||||
/// art is non-essential, so a failure just leaves the title-only card).
|
||||
fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(std::time::Duration::from_secs(10)))
|
||||
// Don't follow redirects — a redirect target (`3xx` → `http://169.254.169.254/…` or an
|
||||
// internal host) would be an SSRF pivot from the privileged host. Matches the webhook path
|
||||
// (security-review 2026-07-17). A rare legitimately-redirecting CDN just yields no art.
|
||||
.redirects(0)
|
||||
.build();
|
||||
let body = agent.get(url).call().ok()?.into_string().ok()?;
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.into();
|
||||
let body = agent
|
||||
.get(url)
|
||||
.call()
|
||||
.ok()?
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.ok()?;
|
||||
serde_json::from_str(&body).ok()
|
||||
}
|
||||
|
||||
@@ -103,7 +110,6 @@ fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
||||
/// network/decoder error, or empty body. Blocking (ureq) — call off the async runtime.
|
||||
pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||
use base64::Engine as _;
|
||||
use std::io::Read as _;
|
||||
if let Some(rest) = url.strip_prefix("data:") {
|
||||
// data:[<mediatype>][;base64],<payload>
|
||||
let (meta, data) = rest.split_once(',')?;
|
||||
@@ -125,22 +131,27 @@ pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
return None;
|
||||
}
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(std::time::Duration::from_secs(10)))
|
||||
// Don't follow redirects (SSRF pivot): this is called on launcher-cache- and custom-entry-
|
||||
// supplied URLs, so a `3xx` to an internal/metadata endpoint must not be chased by the
|
||||
// privileged host. Matches the webhook path (security-review 2026-07-17).
|
||||
.redirects(0)
|
||||
.build();
|
||||
let resp = agent.get(url).call().ok()?;
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.into();
|
||||
let mut resp = agent.get(url).call().ok()?;
|
||||
let ctype = resp
|
||||
.header("Content-Type")
|
||||
.headers()
|
||||
.get("Content-Type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("image/jpeg")
|
||||
.to_string();
|
||||
let mut bytes = Vec::new();
|
||||
resp.into_reader()
|
||||
.take(8 * 1024 * 1024)
|
||||
.read_to_end(&mut bytes)
|
||||
// The 8 MiB cap is now the body reader's own limit rather than a `take()` on the stream.
|
||||
let bytes = resp
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(8 * 1024 * 1024)
|
||||
.read_to_vec()
|
||||
.ok()?;
|
||||
(!bytes.is_empty()).then_some((bytes, ctype))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
//! itself would land it outside the captured session and outside that lifetime.
|
||||
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The whole ask, end to end. A plugin resolving one of its own entries is a local lookup against
|
||||
@@ -96,23 +95,26 @@ pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let agent = ureq::AgentBuilder::new().timeout(ASK_TIMEOUT).build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(ASK_TIMEOUT))
|
||||
.build()
|
||||
.into();
|
||||
// Loopback + the plugin's own per-boot secret, exactly what the console proxy presents. The
|
||||
// registration stores a PORT, never an address (mgmt::plugins D5), so this can only ever dial
|
||||
// this machine.
|
||||
// `send_string` + an explicit content type rather than `send_json`: that one needs ureq's `json`
|
||||
// `send` with an explicit content type rather than `send_json`: that one needs ureq's `json`
|
||||
// feature, and the body is one field.
|
||||
let body = serde_json::json!({ "entry": key }).to_string();
|
||||
let resp = match agent
|
||||
.post(&format!("http://127.0.0.1:{}/__launch", cred.port))
|
||||
.set("Authorization", &format!("Bearer {}", cred.secret))
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&body)
|
||||
.header("Authorization", &format!("Bearer {}", cred.secret))
|
||||
.header("Content-Type", "application/json")
|
||||
.send(&body)
|
||||
{
|
||||
Ok(r) => r,
|
||||
// A plugin that does not know the entry says so with a 404 — the answer a FORGED entry gets,
|
||||
// and the reason planting one is not enough to make the host run anything.
|
||||
Err(ureq::Error::Status(404, _)) => {
|
||||
Err(ureq::Error::StatusCode(404)) => {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
@@ -120,7 +122,7 @@ pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(ureq::Error::Status(code, _)) => {
|
||||
Err(ureq::Error::StatusCode(code)) => {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
entry = key,
|
||||
@@ -139,15 +141,20 @@ pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
if let Err(e) = resp
|
||||
.into_reader()
|
||||
.take((MAX_BODY + 1) as u64)
|
||||
.read_to_end(&mut buf)
|
||||
let mut resp = resp;
|
||||
// cap+1 so an over-cap answer is caught by the length check below rather than truncated.
|
||||
let buf = match resp
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit((MAX_BODY + 1) as u64)
|
||||
.read_to_vec()
|
||||
{
|
||||
tracing::warn!(plugin, entry = key, error = %e, "plugin launch: reading the answer failed");
|
||||
return None;
|
||||
}
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(plugin, entry = key, error = %e, "plugin launch: reading the answer failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if buf.len() > MAX_BODY {
|
||||
tracing::warn!(
|
||||
plugin,
|
||||
@@ -226,7 +233,7 @@ fn validate_reply(plugin: &str, key: &str, reply: LaunchReply) -> Option<PluginL
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// A one-shot HTTP/1.1 stub on an ephemeral loopback port. Returns the port and a handle that
|
||||
/// yields the raw request text — so the assertions about what the HOST sent (method, path,
|
||||
@@ -274,13 +281,27 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn asks_the_registered_plugin_and_takes_its_answer() {
|
||||
let (port, server) =
|
||||
stub_plugin(200, r#"{"command":"retroarch 'smw.sfc'","cwd":"/opt/emu"}"#);
|
||||
// The cwd has to be absolute FOR THE HOST PLATFORM: `/opt/emu` has no drive letter, so
|
||||
// `Path::is_absolute` is false on Windows and `validate_reply` refuses the recipe — this
|
||||
// test could never pass there. Same split as `a_working_directory_must_be_absolute`.
|
||||
// (The `\\` is JSON escaping; the decoded value is `C:\emu`.)
|
||||
let (answer, cwd) = if cfg!(windows) {
|
||||
(
|
||||
r#"{"command":"retroarch 'smw.sfc'","cwd":"C:\\emu"}"#,
|
||||
r"C:\emu",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r#"{"command":"retroarch 'smw.sfc'","cwd":"/opt/emu"}"#,
|
||||
"/opt/emu",
|
||||
)
|
||||
};
|
||||
let (port, server) = stub_plugin(200, answer);
|
||||
crate::mgmt::register_ui_for_test("stub-launcher", port, "s3cr3t");
|
||||
|
||||
let got = ask_plugin_launch("stub-launcher", "snes/smw.sfc").expect("a recipe");
|
||||
assert_eq!(got.command, "retroarch 'smw.sfc'");
|
||||
assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new("/opt/emu")));
|
||||
assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new(cwd)));
|
||||
|
||||
let req = server.join().expect("stub thread");
|
||||
assert!(req.starts_with("POST /__launch "), "request was {req:?}");
|
||||
|
||||
@@ -148,6 +148,9 @@ use spike::{Options, Source};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
// Before anything can reach an HTTPS call (the cover-art warmer, webhooks, the plugin-store
|
||||
// catalog, the update downloader all build default `ureq` agents).
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
let filter =
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into());
|
||||
// `service run` is launched by the SCM with no console — log to a file instead of stderr.
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
use super::index::{Index, MAX_INDEX_BYTES};
|
||||
use super::sources::Source;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -41,19 +40,20 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
|
||||
if !source.url.starts_with("https://") {
|
||||
return Fetched::Failed("source url must be https".into());
|
||||
}
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(FETCH_TIMEOUT))
|
||||
// A signed document doesn't need many hops to reach us; a redirect chain is a good way to
|
||||
// waste a host's time.
|
||||
.redirects(3)
|
||||
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
|
||||
.build();
|
||||
.max_redirects(3)
|
||||
.user_agent(format!("punktfunk-host/{}", super::index::host_version()))
|
||||
.build()
|
||||
.into();
|
||||
|
||||
let mut req = agent.get(&source.url);
|
||||
if let Some(tag) = etag {
|
||||
req = req.set("If-None-Match", tag);
|
||||
req = req.header("If-None-Match", tag);
|
||||
}
|
||||
let resp = match req.call() {
|
||||
let mut resp = match req.call() {
|
||||
// `ureq` only turns status >= 400 into `Err(Status)`, so a conditional request's 304
|
||||
// arrives here as **Ok with an empty body** — not as an error. Reading it as an error arm
|
||||
// (the intuitive reading) means every refresh after the first one verifies a signature
|
||||
@@ -61,13 +61,17 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
|
||||
// never picking up a new entry. Found on-glass; pinned by `ureq_returns_304_as_ok`.
|
||||
Ok(r) if r.status() == 304 => return Fetched::NotModified,
|
||||
Ok(r) => r,
|
||||
Err(ureq::Error::Status(code, _)) => {
|
||||
Err(ureq::Error::StatusCode(code)) => {
|
||||
return Fetched::Failed(format!("index fetch returned HTTP {code}"))
|
||||
}
|
||||
Err(e) => return Fetched::Failed(format!("index fetch failed: {e}")),
|
||||
};
|
||||
let new_etag = resp.header("etag").map(str::to_string);
|
||||
let body = match read_capped(resp) {
|
||||
let new_etag = resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = match read_capped(&mut resp) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Fetched::Failed(e),
|
||||
};
|
||||
@@ -76,7 +80,7 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
|
||||
let keys = source.keys();
|
||||
if !keys.is_empty() {
|
||||
let sig = match agent.get(&source.sig_url()).call() {
|
||||
Ok(r) => match read_capped(r) {
|
||||
Ok(mut r) => match read_capped(&mut r) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Fetched::Failed(format!("signature: {e}")),
|
||||
},
|
||||
@@ -105,11 +109,16 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
|
||||
}
|
||||
|
||||
/// Read a response body, refusing anything past the cap without buffering it.
|
||||
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
|
||||
let mut buf = Vec::new();
|
||||
resp.into_reader()
|
||||
.take((MAX_INDEX_BYTES + 1) as u64)
|
||||
.read_to_end(&mut buf)
|
||||
fn read_capped(resp: &mut ureq::http::Response<ureq::Body>) -> Result<Vec<u8>, String> {
|
||||
// Limit is cap+1 so a body of exactly cap+1 comes back intact and is rejected by the length
|
||||
// check below with our own message; anything larger trips ureq's own limit error. Either way it
|
||||
// is an Err — unlike ureq 2's `take()`, which truncated silently and then failed the signature
|
||||
// check with a message that pointed at the wrong thing.
|
||||
let buf = resp
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit((MAX_INDEX_BYTES + 1) as u64)
|
||||
.read_to_vec()
|
||||
.map_err(|e| format!("reading the response body failed: {e}"))?;
|
||||
if buf.len() > MAX_INDEX_BYTES {
|
||||
return Err(format!("response exceeds the {MAX_INDEX_BYTES}-byte cap"));
|
||||
@@ -257,18 +266,34 @@ mod tests {
|
||||
/// on someone's host.
|
||||
#[test]
|
||||
fn ureq_returns_304_as_ok() {
|
||||
use std::io::Write as _;
|
||||
use std::io::{Read as _, Write as _};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
if let Ok((mut sock, _)) = listener.accept() {
|
||||
// Drain the request BEFORE answering. Closing a socket that still has unread
|
||||
// received data makes Windows send an RST rather than a FIN, which destroys the
|
||||
// response we just wrote — the client then sees a transport error (os error 10053)
|
||||
// instead of the 304 this test exists to pin. A GET has no body, so the header
|
||||
// terminator is the whole request.
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
while let Ok(n) = sock.read(&mut chunk) {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = sock.write_all(b"HTTP/1.1 304 Not Modified\r\nETag: \"x\"\r\n\r\n");
|
||||
let _ = sock.flush();
|
||||
}
|
||||
});
|
||||
|
||||
let resp = ureq::get(&format!("http://{addr}/index.json"))
|
||||
.set("If-None-Match", "\"x\"")
|
||||
.header("If-None-Match", "\"x\"")
|
||||
.call();
|
||||
let _ = server.join();
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use super::index::{scope_of, Entry};
|
||||
use super::manifest::{self, Record, Tier};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -564,25 +564,27 @@ fn registry_integrity(registry: &str, pkg: &str, version: &str) -> Result<String
|
||||
let base = registry.trim_end_matches('/');
|
||||
// npm registry convention: the scope separator is percent-encoded in the packument path.
|
||||
let url = format!("{base}/{}", pkg.replace('/', "%2f"));
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirects(3)
|
||||
.user_agent(&format!("punktfunk-host/{}", super::index::host_version()))
|
||||
.build();
|
||||
let resp = agent
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(20)))
|
||||
.max_redirects(3)
|
||||
.user_agent(format!("punktfunk-host/{}", super::index::host_version()))
|
||||
.build()
|
||||
.into();
|
||||
let mut resp = agent
|
||||
.get(&url)
|
||||
.set("Accept", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.call()
|
||||
.map_err(|e| match e {
|
||||
ureq::Error::Status(404, _) => {
|
||||
ureq::Error::StatusCode(404) => {
|
||||
anyhow::anyhow!("the registry does not know this package")
|
||||
}
|
||||
other => anyhow::anyhow!("registry request failed: {other}"),
|
||||
})?;
|
||||
let mut body = Vec::new();
|
||||
resp.into_reader()
|
||||
.take(16 * 1024 * 1024)
|
||||
.read_to_end(&mut body)
|
||||
let body = resp
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(16 * 1024 * 1024)
|
||||
.read_to_vec()
|
||||
.context("read the registry response")?;
|
||||
let doc: serde_json::Value =
|
||||
serde_json::from_slice(&body).context("registry returned invalid JSON")?;
|
||||
|
||||
@@ -168,27 +168,34 @@ fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Resu
|
||||
if !url.starts_with("https://") {
|
||||
return Err("installer url must be https".into());
|
||||
}
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(std::time::Duration::from_secs(15))
|
||||
.redirects(3)
|
||||
.user_agent(&format!(
|
||||
// Connect timeout only, deliberately no global one: this streams an installer that is tens of
|
||||
// MB, and a whole-request deadline would abort a slow-but-healthy download.
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(std::time::Duration::from_secs(15)))
|
||||
.max_redirects(3)
|
||||
.user_agent(format!(
|
||||
"punktfunk-host/{} (update-apply)",
|
||||
env!("PUNKTFUNK_VERSION")
|
||||
))
|
||||
.build();
|
||||
.build()
|
||||
.into();
|
||||
|
||||
let existing = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0);
|
||||
let mut req = agent.get(url);
|
||||
if existing > 0 {
|
||||
req = req.set("Range", &format!("bytes={existing}-"));
|
||||
req = req.header("Range", &format!("bytes={existing}-"));
|
||||
}
|
||||
let resp = req.call().map_err(|e| match e {
|
||||
ureq::Error::Status(code, _) => format!("download returned HTTP {code}"),
|
||||
ureq::Error::StatusCode(code) => format!("download returned HTTP {code}"),
|
||||
other => format!("download failed: {other}"),
|
||||
})?;
|
||||
|
||||
let resumed = resp.status() == 206;
|
||||
let content_len: Option<u64> = resp.header("content-length").and_then(|v| v.parse().ok());
|
||||
let content_len: Option<u64> = resp
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse().ok());
|
||||
let total = content_len.map(|l| if resumed { existing + l } else { l });
|
||||
if let Some(t) = total {
|
||||
preflight_disk(part, t.saturating_mul(DISK_MARGIN))?;
|
||||
@@ -211,7 +218,9 @@ fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Resu
|
||||
};
|
||||
progress(received, total);
|
||||
|
||||
let mut reader = resp.into_reader();
|
||||
// Unlimited reader, not `read_to_vec` — the installer is streamed to disk in 64 KiB chunks so
|
||||
// it never lands in memory, and ureq 3's body-read caps do not apply to this path.
|
||||
let mut reader = resp.into_body().into_reader();
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = reader.read(&mut buf).map_err(|e| format!("read: {e}"))?;
|
||||
@@ -234,7 +243,7 @@ fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option<u64>)) -> Resu
|
||||
|
||||
fn verify_sha256(path: &Path, expected_hex: &str) -> Result<(), String> {
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("open for hashing: {e}"))?;
|
||||
let mut ctx = ring::digest::Context::new(&ring::digest::SHA256);
|
||||
let mut ctx = aws_lc_rs::digest::Context::new(&aws_lc_rs::digest::SHA256);
|
||||
let mut buf = [0u8; 128 * 1024];
|
||||
loop {
|
||||
let n = file
|
||||
@@ -366,7 +375,7 @@ pub(crate) fn verify_authenticode(path: &Path, pins: &[String]) -> Result<(), St
|
||||
// cert context above; the slice is consumed (hashed) before the state is closed.
|
||||
let der =
|
||||
unsafe { std::slice::from_raw_parts(leaf.pbCertEncoded, leaf.cbCertEncoded as usize) };
|
||||
let fp = hex(ring::digest::digest(&ring::digest::SHA256, der).as_ref());
|
||||
let fp = hex(aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, der).as_ref());
|
||||
if !pins.iter().any(|p| p.eq_ignore_ascii_case(&fp)) {
|
||||
return Err(format!(
|
||||
"installer signing-leaf fingerprint {fp} matches none of the manifest's \
|
||||
|
||||
@@ -22,17 +22,20 @@ anyhow = "1"
|
||||
[target.'cfg(any(windows, target_os = "linux"))'.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Loopback HTTPS poll of GET /api/v1/local/summary. Same sync ureq + rustls(ring) stack and
|
||||
# custom-verifier pattern as the Linux client's library fetch (crates/pf-client-core/src/library.rs) —
|
||||
# but ring-only (no default aws-lc-rs provider: it needs a C toolchain per target and the agent
|
||||
# pins the ring provider explicitly anyway).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
# Loopback HTTPS poll of GET /api/v1/local/summary. Same sync ureq + rustls stack and
|
||||
# custom-verifier pattern as the Linux client's library fetch (crates/pf-client-core/src/library.rs),
|
||||
# on the same aws-lc-rs backend as the rest of the tree (the agent pins the provider explicitly).
|
||||
# ⚠ `rustls-no-provider`, NEVER the default `rustls` feature — that one pulls `_ring`.
|
||||
ureq = { version = "3", default-features = false, features = [
|
||||
"rustls-no-provider",
|
||||
"rustls-webpki-roots",
|
||||
] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "prefer-post-quantum", "logging", "std", "tls12"] }
|
||||
# The one shared cert-fingerprint pin verifier (`punktfunk_core::tls::PinVerify`) + fingerprint
|
||||
# hash, instead of the tray hand-rolling its own copy on a trust boundary. The light `tls` feature
|
||||
# is rustls + sha2 only (no QUIC runtime / tokio), so this stays a lean helper; core is a pure-Rust
|
||||
# leaf (no C toolchain), unlike the host dependency ruled out above.
|
||||
punktfunk-core = { path = "../punktfunk-core", default-features = false, features = ["tls"] }
|
||||
# is rustls + sha2 only (no QUIC runtime / tokio), so this stays a lean helper — much smaller than
|
||||
# the host dependency ruled out above, though rustls's aws-lc-rs backend does mean a C compiler.
|
||||
punktfunk-core = { path = "../punktfunk-core", default-features = false, features = ["tls", "ureq-tls"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses.
|
||||
|
||||
@@ -79,6 +79,10 @@ fn parse_args() -> anyhow::Result<Args> {
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// punktfunk-core is a Windows/Linux-only dependency here (the macOS build is a stub), so the
|
||||
// provider install follows the same cfg as `run`.
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
punktfunk_core::tls::install_default_provider();
|
||||
let args = parse_args()?;
|
||||
run(args)
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ fn poll_loop(
|
||||
fn probe_console(agent: &ureq::Agent, url: &str) -> bool {
|
||||
match agent.get(url).call() {
|
||||
Ok(_) => true,
|
||||
Err(ureq::Error::Status(..)) => true,
|
||||
Err(ureq::Error::StatusCode(..)) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
@@ -262,7 +262,13 @@ fn probe_console(agent: &ureq::Agent, url: &str) -> bool {
|
||||
// ── Summary fetch (loopback HTTPS) ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
|
||||
let body = agent.get(url).call().ok()?.into_string().ok()?;
|
||||
let body = agent
|
||||
.get(url)
|
||||
.call()
|
||||
.ok()?
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.ok()?;
|
||||
serde_json::from_str(&body).ok()
|
||||
}
|
||||
|
||||
@@ -310,25 +316,29 @@ pub fn punktfunk_config_dir() -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A sync HTTPS agent over the same rustls(ring) stack the rest of the workspace uses, with a
|
||||
/// A sync HTTPS agent over the same rustls(aws-lc-rs) stack the rest of the workspace uses, with a
|
||||
/// pin-or-accept-any verifier (the Linux client's `PinVerify` pattern, `library.rs`).
|
||||
fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let cfg = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.expect("rustls default protocol versions")
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::new(pin)))
|
||||
.with_no_client_auth();
|
||||
ureq::AgentBuilder::new()
|
||||
.tls_config(Arc::new(cfg))
|
||||
.timeout_connect(Duration::from_secs(2))
|
||||
.timeout(Duration::from_secs(2))
|
||||
// No redirect-following. Neither user of this agent wants it: the summary is a terminal
|
||||
// JSON route, and the console probe treats any HTTP answer (302 included) as "up", so
|
||||
// chasing the hop only spends the 2 s budget re-rendering a page nobody reads.
|
||||
.redirects(0)
|
||||
.build()
|
||||
// ureq's `TlsConfig` cannot install a custom verifier, so the agent wraps this `ClientConfig`
|
||||
// directly (the glue lives in punktfunk-core, shared with the desktop client's library fetch).
|
||||
punktfunk_core::tls::ureq_agent::agent(
|
||||
Arc::new(cfg),
|
||||
ureq::Agent::config_builder()
|
||||
.timeout_connect(Some(Duration::from_secs(2)))
|
||||
.timeout_global(Some(Duration::from_secs(2)))
|
||||
// No redirect-following. Neither user of this agent wants it: the summary is a
|
||||
// terminal JSON route, and the console probe treats any HTTP answer (302 included) as
|
||||
// "up", so chasing the hop only spends the 2 s budget re-rendering a page nobody reads.
|
||||
.max_redirects(0)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Service-manager probe ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -161,21 +161,27 @@ The only settings that matter (GPU zero-copy is on by default):
|
||||
```sh
|
||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
||||
PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session — SDR, and no cursor (see below)
|
||||
```
|
||||
|
||||
### Gaming Mode: attach vs managed
|
||||
|
||||
For Gaming Mode there are two models (pick one; the shipped default is **attach**):
|
||||
For Gaming Mode there are two models. The template forces **neither** — the host picks per connect,
|
||||
and on Bazzite (which ships `gamescope-session-plus`) that is **managed**:
|
||||
|
||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the template's default) — the **box** owns its
|
||||
gamescope session on its own display, and the host attaches to whatever's live without ever
|
||||
tearing it down (on a headless box, a box-owned autologin session is restarted at the client's
|
||||
resolution on a mismatch; with a display connected it streams at the box's own mode). Switching
|
||||
Desktop ↔ Game is rock-solid.
|
||||
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host takes the
|
||||
box's gamescope over and relaunches it **headless** at the *client's* exact resolution and
|
||||
refresh — Game Mode on the virtual screen — restoring the box on idle.
|
||||
- **Managed** (what you get by default here) — the host takes the box's gamescope over and
|
||||
relaunches it **headless** at the *client's* exact resolution and refresh — Game Mode on the
|
||||
virtual screen — restoring the box on idle. This is the model that gives the client a display of
|
||||
its **own**, and the only one under which a game launched from a client's library gets a
|
||||
dedicated session. It needs the [`punktfunk` group](#allow-controller-input): the takeover stops
|
||||
the display manager for the length of the stream, and without that grant it cannot.
|
||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session on its own
|
||||
display, and the host attaches to whatever's live without ever tearing it down (on a headless
|
||||
box, a box-owned autologin session is restarted at the client's resolution on a mismatch; with a
|
||||
display connected it streams at the box's own mode). Switching Desktop ↔ Game is rock-solid, and
|
||||
the cost is that a box with a screen attached serves the client a **mirror** of that screen
|
||||
rather than its own display. Setting it also outranks a dedicated game session.
|
||||
|
||||
`=0` turns the attach override off, the same as removing the line.
|
||||
|
||||
Full treatment: [Steam / gamescope → How the host gets a
|
||||
gamescope](/docs/gamescope#how-the-host-gets-a-gamescope).
|
||||
@@ -227,14 +233,17 @@ These apply to the **Gaming Mode (gamescope)** path; the KDE Desktop path is una
|
||||
headless capture can deadlock; between the two, capture works but the Steam overlay (Shift+Tab /
|
||||
the Quick Access Menu) is never painted into the captured node. Bazzite's current gamescope is
|
||||
past both; this only bites if you've pinned an old one.
|
||||
- **The template pins attach, and that costs you both the cursor and HDR.** The sysext ships the
|
||||
`punktfunk-gamescope` build, but it only reaches a session the host starts itself — and the
|
||||
`host.env` template above sets `PUNKTFUNK_GAMESCOPE_ATTACH=1`, where the live session is
|
||||
Bazzite's own stock gamescope. Comment that line out and let the managed default take over: you
|
||||
get the compositor-drawn pointer and real HDR. To stay on attach instead, set
|
||||
`PUNKTFUNK_GAMESCOPE_HDR=0` and `PUNKTFUNK_GAMESCOPE_BIN=/usr/bin/gamescope`. Why each half
|
||||
breaks: [gamescope → Known limits](/docs/gamescope#known-limits) for the cursor,
|
||||
- **Forcing attach costs you the cursor, HDR and your own display.** The sysext ships the
|
||||
`punktfunk-gamescope` build, but it only reaches a session the host starts itself — under
|
||||
`PUNKTFUNK_GAMESCOPE_ATTACH=1` the live session is Bazzite's own stock gamescope. The managed
|
||||
default gets you the compositor-drawn pointer, real HDR and a display of the client's own. If you
|
||||
deliberately stay on attach, also set `PUNKTFUNK_GAMESCOPE_HDR=0` and
|
||||
`PUNKTFUNK_GAMESCOPE_BIN=/usr/bin/gamescope`. Why each half breaks:
|
||||
[gamescope → Known limits](/docs/gamescope#known-limits) for the cursor,
|
||||
[HDR → Linux + gamescope](/docs/hdr#linux--gamescope) for the failed connect.
|
||||
⚠ Older templates set `PUNKTFUNK_GAMESCOPE_ATTACH=1` for you — if you copied one, delete that
|
||||
line from `~/.config/punktfunk/host.env`, because an upgrade never rewrites a file you already
|
||||
have.
|
||||
|
||||
Those are the two that bite on Bazzite. The full set — touch, mouse modes, the clipboard — is on
|
||||
[gamescope → Known limits](/docs/gamescope#known-limits).
|
||||
|
||||
@@ -87,7 +87,7 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
|
||||
|
||||
| Setting | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. |
|
||||
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` · `0` *(unset = auto)* | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode — i.e. the client is served a **mirror**, not a display of its own. Setting this also outranks a dedicated game session. No template ships it set; `=0` is the same as leaving it out. |
|
||||
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it **headless** at the *client's* exact resolution — Game Mode on the virtual screen — restoring the box on idle. |
|
||||
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). |
|
||||
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: Debian
|
||||
description: Install the Punktfunk host on Debian 13 with apt — including LMDE and Linux Mint.
|
||||
---
|
||||
|
||||
Install a Punktfunk host on **Debian 13 ("trixie") or newer** from the apt registry. This page
|
||||
covers the distro-level setup — GPU driver, package, gamepad access. How the host creates its
|
||||
virtual display and injects input is desktop-specific, so pick your desktop on the
|
||||
[configure pages](#configure-your-desktop) afterward rather than here.
|
||||
|
||||
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||
|
||||
> **Which releases.** The host package needs **glibc 2.39 or newer**; Debian 13 has 2.41, so it
|
||||
> installs and runs there. **Debian 12 (bookworm) has glibc 2.36 and cannot install it** — build
|
||||
> from source ([Ubuntu appendix](/docs/ubuntu#appendix--build-from-source), which applies here too)
|
||||
> or upgrade. Check yours with `ldd --version`.
|
||||
|
||||
> **The desktop client is not packaged for Debian yet.** `punktfunk-client` is built on Ubuntu 26.04
|
||||
> and floors at `libc6 >= 2.43` (Debian 13 has 2.41), on top of needing GTK4 ≥ 4.20. On a Debian
|
||||
> box, stream *to* it with a [different client](/docs/install-client) — the Flatpak, or a build from
|
||||
> source. The **host**, the **web console** and the **plugin runner** all install normally.
|
||||
|
||||
## What works on Debian 13
|
||||
|
||||
| Package | Debian 13 | What it is |
|
||||
|---|---|---|
|
||||
| `punktfunk-host` | ✅ | The streaming host |
|
||||
| `punktfunk-web` | ✅ | The browser management console |
|
||||
| `punktfunk-scripting` | ✅ | The plugin/script runner |
|
||||
| `punktfunk-gamescope` | ✅ | The patched gamescope (HDR + cursor + real refresh) |
|
||||
| `punktfunk-client` | ❌ | Desktop client — `libc6 >= 2.43`, see above |
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
On **NVIDIA**, the driver lives in Debian's `contrib` / `non-free` / `non-free-firmware`
|
||||
components, which a default install does not enable. Debian 13 keeps its sources in the deb822
|
||||
format, so add them there and refresh:
|
||||
|
||||
```sh
|
||||
sudo sed -i 's/^Components: .*/Components: main contrib non-free non-free-firmware/' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
sudo apt update
|
||||
sudo apt install nvidia-driver firmware-misc-nonfree
|
||||
```
|
||||
|
||||
Debian 13 ships driver 550, comfortably above the [535 floor](/docs/requirements).
|
||||
|
||||
Reboot, then confirm the driver and KMS modeset — Wayland on NVIDIA needs `modeset=1`:
|
||||
|
||||
```sh
|
||||
nvidia-smi
|
||||
cat /sys/module/nvidia_drm/parameters/modeset # should print Y
|
||||
```
|
||||
|
||||
If modeset is not `Y`:
|
||||
|
||||
```sh
|
||||
echo 'options nvidia-drm modeset=1' | sudo tee /etc/modprobe.d/nvidia-drm.conf
|
||||
sudo update-initramfs -u && sudo reboot
|
||||
```
|
||||
|
||||
> **Secure Boot:** with Secure Boot enabled, Debian's DKMS-built NVIDIA module must be signed and
|
||||
> its key enrolled before it will load. If `nvidia-smi` can't talk to the driver, enrol the MOK
|
||||
> (`sudo mokutil --import /var/lib/dkms/mok.pub`, reboot, choose **Enrol MOK**) or disable Secure
|
||||
> Boot in firmware.
|
||||
|
||||
On **AMD/Intel** none of the NVIDIA steps apply. Encode runs on the Mesa stack: **Vulkan Video** for
|
||||
HEVC and AV1 (`mesa-vulkan-drivers`), with **VAAPI** for H.264 and as the fallback —
|
||||
`mesa-va-drivers` on AMD, `intel-media-va-driver` on Intel (the latter is in `non-free`).
|
||||
|
||||
## 2. Install the host (apt)
|
||||
|
||||
The registry is public — no auth needed, just trust its signing key:
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key \
|
||||
| sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null
|
||||
|
||||
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/punktfunk.list
|
||||
|
||||
sudo apt update
|
||||
sudo apt install punktfunk-host
|
||||
```
|
||||
|
||||
`punktfunk-host` `Recommends` the browser console (`punktfunk-web`), so apt pulls it in by default.
|
||||
The NVIDIA driver is **not** a dependency — you installed it out of band in step 1. Later updates
|
||||
are `sudo apt update && sudo apt upgrade`; restart the running host afterwards so it picks up the
|
||||
new binary:
|
||||
|
||||
```sh
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
The `stable` component above is the stable channel. To track pre-release builds instead, see
|
||||
[Release Channels](/docs/channels).
|
||||
|
||||
## 3. Grant gamepad access
|
||||
|
||||
Virtual gamepads inject through `/dev/uinput`, gated by the `input` group. Add yourself and re-login:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG input "$USER" # re-login to apply
|
||||
```
|
||||
|
||||
Also join `punktfunk` if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro) —
|
||||
it reaches games as a real USB device over usbip, which is what makes Steam Input adopt it. Join it
|
||||
only on a machine you trust: writing the usbip `attach` file can materialise arbitrary emulated USB
|
||||
hardware.
|
||||
|
||||
```sh
|
||||
sudo usermod -aG punktfunk "$USER" # re-login to apply
|
||||
```
|
||||
|
||||
## 4. Check it installed
|
||||
|
||||
```sh
|
||||
punktfunk-host --version # the binary is on PATH
|
||||
punktfunk-host detect-conflicts # exits 1 if Sunshine/Apollo is also installed
|
||||
```
|
||||
|
||||
Two hosts on one machine is the most common reason a clean install never streams — see
|
||||
[Troubleshooting](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed).
|
||||
|
||||
## 5. Open the firewall (if you have one)
|
||||
|
||||
**Debian ships no firewall enabled by default**, so out of the box there is nothing to open. If you
|
||||
run one, the package installs the openers:
|
||||
|
||||
```sh
|
||||
# ufw:
|
||||
sudo ufw allow punktfunk-native
|
||||
|
||||
# firewalld:
|
||||
sudo firewall-cmd --reload # load the installed definitions
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
Add `punktfunk-gamestream` for Moonlight compat and `punktfunk-web` (TCP 47992) to reach the console
|
||||
from another device. Full port lists are in
|
||||
[`packaging/debian/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md#firewall).
|
||||
|
||||
## Cinnamon, Linux Mint and LMDE
|
||||
|
||||
**A Cinnamon desktop cannot host a virtual display, and no setting changes that.** Punktfunk gives
|
||||
each client its own screen at that device's exact resolution by asking the compositor to create a
|
||||
virtual output. Cinnamon's compositor, **Muffin**, has no such API: it forked from Mutter 3.36, and
|
||||
its `org.cinnamon.Muffin.ScreenCast` interface offers only `RecordMonitor` and `RecordWindow` —
|
||||
never the `RecordVirtual` that Mutter gained in 42. Its portal backend
|
||||
(`xdg-desktop-portal-xapp`) implements no ScreenCast either, so the route that serves Sway and
|
||||
Hyprland is closed too. This is upstream's to fix, not a Punktfunk setting.
|
||||
|
||||
**Which Mint you run decides whether there is any route at all:**
|
||||
|
||||
| Edition | Base | Can it host? |
|
||||
|---|---|---|
|
||||
| **LMDE 7 "Gigi"** | Debian 13 | ✅ Yes — via gamescope (below) |
|
||||
| **Linux Mint 22.x** ("Wilma"…"Zena") | Ubuntu 24.04 | ❌ No — see [below](#linux-mint-22x-cannot-host-yet) |
|
||||
| **Linux Mint 23** | Ubuntu 26.04 | ✅ Expected — due December 2026 |
|
||||
|
||||
On **LMDE 7**, what works is **gamescope**: the host starts its own headless gamescope for each
|
||||
connecting client and runs the game inside it, so it needs no desktop compositor at all. Your
|
||||
Cinnamon session keeps running untouched; the stream is the game, not the desktop.
|
||||
|
||||
```sh
|
||||
sudo apt install punktfunk-gamescope # LMDE 7 / Debian 13 — not available on Mint 22.x
|
||||
echo 'PUNKTFUNK_COMPOSITOR=gamescope' >> ~/.config/punktfunk/host.env
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
The pin is required: auto-detection reads the live session, finds Cinnamon, and stops with an error
|
||||
rather than guessing. Set a game to launch with
|
||||
[`PUNKTFUNK_GAMESCOPE_APP`](/docs/gamescope) or per-session launch commands, then see
|
||||
[Steam / gamescope](/docs/gamescope) for the rest.
|
||||
|
||||
> **Install `punktfunk-gamescope`, not Debian's.** Debian ships **no** `gamescope` package at all,
|
||||
> and the patched build is what gives the stream HDR, a visible cursor, and the client's real
|
||||
> refresh rate instead of a hardcoded 60 Hz.
|
||||
|
||||
If you want to stream the **desktop** from an LMDE box, the answer today is to log into a GNOME or
|
||||
Sway session instead — Debian 13 ships GNOME 48.7 and sway 1.10, both above the
|
||||
[floors](/docs/requirements). (Debian 13's KDE is KWin **6.3.6**, below the 6.5.6 floor, so Plasma
|
||||
is not an option there yet.)
|
||||
|
||||
### Linux Mint 22.x cannot host yet
|
||||
|
||||
**On Linux Mint 22.x — the current mainstream release, and every version until Mint 23 in December
|
||||
2026 — there is no working configuration.** `punktfunk-host` will install, which makes this easy to
|
||||
miss, but nothing on the box can produce a stream:
|
||||
|
||||
- **Cinnamon** cannot host a virtual display (above).
|
||||
- **gamescope is not available and cannot be made available.** Ubuntu 24.04 packages no gamescope,
|
||||
and the patched `punktfunk-gamescope` cannot run there either: 24.04 is short of what the build
|
||||
needs on *five* libraries — wayland 1.22.0 (needs ≥ 1.23.1), libinput 1.25 (≥ 1.26), libavif
|
||||
1.0.4 (≥ 1.2.1), pixman 0.42 (≥ 0.44), and no `libdisplay-info2` or `libxcb-errors0` at all.
|
||||
- **Switching desktop does not rescue it.** Ubuntu 24.04 ships KWin **5.27** (floor 6.5.6) and GNOME
|
||||
Shell **46** (floor 48). Only `sway` 1.9 is even a candidate, and that means giving up Cinnamon.
|
||||
|
||||
If you want to run a host on Mint hardware today, use **LMDE 7** — it is the same desktop on a
|
||||
Debian 13 base, where gamescope works. Otherwise wait for **Mint 23** (Ubuntu 26.04 base), where
|
||||
both the patched gamescope and the newer compositors are available.
|
||||
|
||||
## Configure your desktop
|
||||
|
||||
How the host creates its virtual display and injects input depends on your desktop, not your distro:
|
||||
|
||||
- [KDE Plasma (KWin)](/docs/kde)
|
||||
- [GNOME (Mutter)](/docs/gnome)
|
||||
- [Steam / gamescope](/docs/gamescope)
|
||||
- [Hyprland](/docs/hyprland)
|
||||
- [Sway / wlroots](/docs/sway)
|
||||
|
||||
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect your first
|
||||
[client](/docs/clients). To run the host at boot — including fully **headless** — see
|
||||
[Running as a Service](/docs/running-as-a-service).
|
||||
|
||||
## Next steps
|
||||
|
||||
- **Keep it current** — [Updating the Host](/docs/updating).
|
||||
- **Remove it again** — [Uninstalling](/docs/uninstall).
|
||||
- **Something not working?** — [Troubleshooting](/docs/troubleshooting).
|
||||
- **Build from source** (Debian 12, or tracking `main`) — the
|
||||
[Ubuntu appendix](/docs/ubuntu#appendix--build-from-source) applies unchanged; Debian 13's
|
||||
`libavcodec-dev` is new enough to build against.
|
||||
@@ -18,8 +18,8 @@ from the install guide for your OS: [Bazzite](/docs/bazzite) or [SteamOS (Host)]
|
||||
## How the host gets a gamescope
|
||||
|
||||
There are three models; the host picks one per session, and you rarely have to. With **nothing
|
||||
set**, a box that has gamescope session infrastructure (Bazzite, SteamOS, Nobara) gets **managed**;
|
||||
the [Bazzite template](/docs/bazzite) ships with **attach** chosen instead.
|
||||
set** — which is what every shipped template does — a box that has gamescope session infrastructure
|
||||
(Bazzite, SteamOS, Nobara) gets **managed**.
|
||||
|
||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session and decides
|
||||
Gaming vs Desktop via the normal Steam UI. Game Mode stays on the box's own (physical) display;
|
||||
@@ -189,7 +189,11 @@ own name and does **not** replace your system gamescope — your Gaming Mode kee
|
||||
- **Bazzite / Fedora Atomic** — included in the Punktfunk sysext; `punktfunk-sysext update` gets it.
|
||||
- **Fedora, Nobara and other RPM boxes** — `sudo dnf install punktfunk-gamescope` from the same
|
||||
Punktfunk repo the host comes from.
|
||||
- **Debian / Ubuntu** — `sudo apt install punktfunk-gamescope` from the Punktfunk apt repo.
|
||||
- **Debian 13 and Ubuntu 26.04** — `sudo apt install punktfunk-gamescope` from the Punktfunk apt
|
||||
repo. It is the only gamescope those two have: Debian packages none at all, and Ubuntu 26.04's
|
||||
is 3.16.20 — below the [3.16.22 floor](/docs/requirements), so headless capture deadlocks
|
||||
against the PipeWire 1.6 that same release ships. **Ubuntu 24.04 cannot run it**: the build needs
|
||||
wayland ≥ 1.23.1 and noble has 1.22.0, so build from source or move to a newer release.
|
||||
- **Arch** — the `punktfunk-gamescope` package.
|
||||
- **SteamOS (Steam Deck installer)** — built and wired automatically by
|
||||
`scripts/steamdeck/install.sh` / `update.sh`.
|
||||
@@ -208,8 +212,8 @@ you and the two ways out.
|
||||
|
||||
The cursor is the half this page owns. The host leaves the pointer to the compositor whenever the
|
||||
installed build can paint it (below) — so on an attached session, which can't, nothing draws it and
|
||||
the stream has no cursor at all. Commenting
|
||||
`PUNKTFUNK_GAMESCOPE_ATTACH=1` out of the [Bazzite template](/docs/bazzite) and letting the managed
|
||||
the stream has no cursor at all. Removing
|
||||
`PUNKTFUNK_GAMESCOPE_ATTACH=1` from your `host.env` and letting the managed
|
||||
default take over fixes that along with HDR. To stay on attach, point `PUNKTFUNK_GAMESCOPE_BIN` at
|
||||
your distro's own `gamescope` (`/usr/bin/gamescope`) instead: the host goes back to compositing the
|
||||
cursor itself, and — since the HDR answer comes from the same binary — stops attempting HDR too.
|
||||
|
||||
@@ -76,12 +76,13 @@ available, and offers it. The attached session can't answer that negotiation, so
|
||||
with no picture, the host latches an SDR downgrade for the rest of its life, and the next connect
|
||||
streams — in SDR.
|
||||
|
||||
That is exactly what the [Bazzite](/docs/bazzite) template ships: it pins attach *and* the sysext
|
||||
installs `punktfunk-gamescope`. Either comment `PUNKTFUNK_GAMESCOPE_ATTACH=1` out and let the managed
|
||||
default take over (you get HDR and the compositor-drawn cursor), or stay on attach and set
|
||||
`PUNKTFUNK_GAMESCOPE_HDR=0` so the failed attempt never happens. Staying on attach also leaves the
|
||||
stream with no cursor; [HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that
|
||||
half.
|
||||
That combination bites on [Bazzite](/docs/bazzite), where the sysext installs `punktfunk-gamescope`
|
||||
alongside a stock session gamescope. No template pins attach any more, so the managed default gets
|
||||
you HDR and the compositor-drawn cursor — but an older template did, and an upgrade never rewrites a
|
||||
`host.env` you already have, so check yours for `PUNKTFUNK_GAMESCOPE_ATTACH=1` and delete the line.
|
||||
If you deliberately stay on attach, set `PUNKTFUNK_GAMESCOPE_HDR=0` so the failed attempt never
|
||||
happens. Staying on attach also leaves the stream with no cursor;
|
||||
[HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that half.
|
||||
|
||||
SDR content rides the same PQ container — the desktop, the Steam overlay, an SDR game — mapped in at
|
||||
`PUNKTFUNK_GAMESCOPE_SDR_NITS` (gamescope's own default is 400). That is the knob when white looks
|
||||
|
||||
@@ -36,7 +36,7 @@ stream and links out to the detail as you need it. The rest of these are for whe
|
||||
<Card title="Quick Start" href="/docs/quickstart" description="From nothing to streaming: set up a host and connect your first client." />
|
||||
<Card title="How It Works" href="/docs/how-it-works" description="The ideas behind Punktfunk in a few minutes — virtual displays, the two protocols, pairing." />
|
||||
<Card title="Support Matrix" href="/docs/support-matrix" description="What works where — every host desktop, GPU and client app, each cell read out of the code that decides it." />
|
||||
<Card title="Install the Host" href="/docs/install" description="Add the repo and install the package — Ubuntu, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows." />
|
||||
<Card title="Install the Host" href="/docs/install" description="Add the repo and install the package — Ubuntu, Debian, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows." />
|
||||
<Card title="Connect a Client" href="/docs/clients" description="Stream with the native app for your device — macOS, Linux, Windows, Android — or any Moonlight client." />
|
||||
<Card title="Your Game Library" href="/docs/game-library" description="The host finds your installed games by itself — browse a paired host and launch a title straight into the stream." />
|
||||
<Card title="API Reference" href="/api" description="Interactive OpenAPI reference for the host's management REST API — status, devices, pairing, library." />
|
||||
@@ -46,7 +46,7 @@ stream and links out to the detail as you need it. The rest of these are for whe
|
||||
|
||||
- A **host** with an NVIDIA, AMD, or Intel GPU (there's a software encoder to fall back on without
|
||||
one) — either a **Linux** machine running one of the [supported setups](/docs/requirements)
|
||||
(**Ubuntu**, **Fedora**, **Arch**, **Bazzite**, or **SteamOS**), or a
|
||||
(**Ubuntu**, **Debian**, **Fedora**, **Arch**, **Bazzite**, or **SteamOS**), or a
|
||||
**[Windows](/docs/windows-host) PC**. What each combination can actually do — codecs, HDR, 4:4:4,
|
||||
cursor, input — is in the [Support matrix](/docs/support-matrix).
|
||||
- A **client device** to stream to — there are native apps for **macOS, iOS/iPadOS, tvOS, Linux,
|
||||
|
||||
@@ -16,13 +16,20 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
|
||||
|
||||
| Distro | Package manager | One-command happy path | Guide |
|
||||
|--------|-----------------|------------------------|-------|
|
||||
| **Ubuntu** | apt | `sudo apt install punktfunk-host` | [Ubuntu](/docs/ubuntu) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
|
||||
| **Ubuntu 26.04+** ¹ | apt | `sudo apt install punktfunk-host` | [Ubuntu](/docs/ubuntu) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
|
||||
| **Debian 13+** (incl. LMDE) | apt | `sudo apt install punktfunk-host` | [Debian](/docs/debian) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
|
||||
| **Bazzite / Fedora Atomic** | systemd-sysext | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` (no layering, no reboot) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) |
|
||||
| **Fedora (dnf)** | dnf / rpm-ostree | `sudo dnf install punktfunk` | [Fedora](/docs/fedora) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
|
||||
| **Arch** | pacman | `sudo pacman -Syu punktfunk-host` (binary repo — always a full `-Syu`, never `-Sy`) | [Arch Linux](/docs/arch) · [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
|
||||
| **SteamOS (host)** | on-device script | clone the repo, then `bash ~/punktfunk/scripts/steamdeck/install.sh` (builds on-device) | [SteamOS (Host)](/docs/steamos-host) |
|
||||
| **NixOS / Nix** | nix flake | `nix run git+https://git.unom.io/unom/punktfunk#punktfunk-host -- serve --gamestream` | [NixOS](#nixos) · [packaging/nix](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/nix/README.md) |
|
||||
|
||||
> ¹ **Ubuntu 24.04 LTS installs the package but cannot host.** It ships no compositor that meets
|
||||
> the [version floors](/docs/requirements) — KWin 5.27 against 6.5.6, GNOME Shell 46 against 48 —
|
||||
> and no `gamescope`. Use 26.04 or newer. This is also why
|
||||
> [Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet); LMDE 7 (Debian 13)
|
||||
> can.
|
||||
|
||||
Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a
|
||||
one-time step covered in the linked guide; after that, normal `apt upgrade` / `dnf upgrade` /
|
||||
`pacman -Syu` (or `sudo punktfunk-sysext update` on Bazzite) tracks new builds. On **NixOS** there
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"requirements",
|
||||
"install",
|
||||
"ubuntu",
|
||||
"debian",
|
||||
"fedora",
|
||||
"arch",
|
||||
"bazzite",
|
||||
|
||||
@@ -13,9 +13,34 @@ is also available. Setup splits along two axes: you **install** the package per
|
||||
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||
|
||||
## The floor for a working host
|
||||
|
||||
**On apt distros that means Ubuntu 26.04 or newer, or Debian 13 or newer.** Both are supported
|
||||
targets and both install from the same repository.
|
||||
|
||||
This floor is about the **desktop**, not the package. A host needs a compositor that can create a
|
||||
virtual display, and those have version floors of their own ([below](#desktop-session)). Older
|
||||
releases will happily install `punktfunk-host` and then have nothing that can produce a stream —
|
||||
so read this as the real requirement, not the package's:
|
||||
|
||||
| Release | Package installs | Can actually host |
|
||||
|---|---|---|
|
||||
| **Ubuntu 26.04+** | ✅ | ✅ KWin 6.5+, GNOME 48+, gamescope |
|
||||
| **Debian 13+** | ✅ | ✅ GNOME 48.7, sway 1.10, gamescope (its KWin 6.3.6 is below the floor) |
|
||||
| Ubuntu 24.04 LTS | ✅ | ❌ KWin 5.27 (floor 6.5.6), GNOME 46 (floor 48), no gamescope available |
|
||||
| Debian 12 | ❌ glibc 2.36 | ❌ |
|
||||
|
||||
Ubuntu 24.04 is called out because the package *does* install there — it is built on 24.04 precisely
|
||||
so one package spans the range — which makes the gap easy to mistake for a bug. It is not: 24.04
|
||||
ships no compositor new enough, and no `gamescope` (the patched
|
||||
[`punktfunk-gamescope`](/docs/gamescope) cannot run there either — 24.04 is too old on wayland,
|
||||
libinput, libavif and pixman). The same gap is why
|
||||
[Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet).
|
||||
|
||||
**Distros — install the package:**
|
||||
|
||||
- [Ubuntu](/docs/ubuntu)
|
||||
- [Ubuntu](/docs/ubuntu) — 26.04 or newer for a working host
|
||||
- [Debian](/docs/debian) — 13 or newer, including LMDE
|
||||
- [Fedora](/docs/fedora)
|
||||
- [Arch](/docs/arch)
|
||||
- [Bazzite](/docs/bazzite)
|
||||
|
||||
@@ -71,6 +71,7 @@ mechanics. The one exception is a gamescope the host only *attaches* to, which k
|
||||
| gamescope (SteamOS · Bazzite) | ✅ ⁴ | ⚠️ ⁵ | ✅ ⁶ |
|
||||
| sway ⁹ | ✅ | ✅ | ⚠️ ⁷ |
|
||||
| Hyprland | ⚠️ ⁸ | ✅ | ⚠️ ⁷ |
|
||||
| Cinnamon (Mint · LMDE) | ❌ ¹⁰ | ❌ ¹⁰ | ❌ ¹⁰ |
|
||||
| macOS / anything else | ❌ | ❌ | ❌ |
|
||||
|
||||
1. Punktfunk's own IddCx display driver. It requires **Windows 11 22H2 (build 22621) or newer** —
|
||||
@@ -108,6 +109,12 @@ mechanics. The one exception is a gamescope the host only *attaches* to, which k
|
||||
(River, dwl, …) cannot host — the session fails at `swaymsg get_outputs`. Their input would work
|
||||
(they do have the wlroots virtual pointer and keyboard protocols), but with no video there is no
|
||||
stream. See [Sway / wlroots](/docs/sway).
|
||||
10. Cinnamon's compositor **Muffin** exposes no virtual-output API and no monitor-capture route we
|
||||
can reach: it forked from Mutter 3.36, so `org.cinnamon.Muffin.ScreenCast` has only
|
||||
`RecordMonitor` / `RecordWindow` and never Mutter 42's `RecordVirtual`, and its portal backend
|
||||
(`xdg-desktop-portal-xapp`) implements no ScreenCast at all. Nothing in Punktfunk can change
|
||||
this. A Mint or LMDE box can still stream **games** through a headless gamescope, which needs no
|
||||
desktop compositor — see [Debian → Cinnamon](/docs/debian#cinnamon-linux-mint-and-lmde).
|
||||
|
||||
### Input, cursor and HDR
|
||||
|
||||
|
||||
@@ -18,11 +18,18 @@ desktop on the [configure pages](#configure-your-desktop) afterward rather than
|
||||
> and needs GTK4 ≥ 4.20 and SDL3, so it installs on **26.04 or newer** only — the host has no such
|
||||
> limit.
|
||||
|
||||
> **Debian isn't a supported target.** The packages are built on Ubuntu images and their dependencies
|
||||
> are resolved against Ubuntu's package names, and nothing in CI builds or tests on Debian. Debian 12
|
||||
> (bookworm) is below the glibc floor and cannot install them at all. A newer Debian may work, but
|
||||
> it's untested — build from source ([appendix](#appendix--build-from-source)) if you want to try.
|
||||
> The `debian` in the repository URL below is the *package format*, not a supported distro.
|
||||
> ⚠ **On 24.04 LTS, the package installs but the distro gives it no compositor to drive.** The
|
||||
> host `.deb` is built for 24.04 and installs cleanly — but streaming needs a compositor that meets
|
||||
> the [version floors](/docs/requirements), and stock 24.04 meets none of them: KWin **5.27** (floor
|
||||
> 6.5.6), GNOME Shell **46** (floor 48), and no `gamescope` package at all (nor can the patched one
|
||||
> run there — 24.04 is too old on wayland, libinput, libavif and pixman). `sway` 1.9 is the only
|
||||
> candidate. **For a working host, use 26.04**, where the patched gamescope and current KDE/GNOME
|
||||
> are all available. This is why [Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet).
|
||||
|
||||
> **On Debian**, see [Debian](/docs/debian) — the host, console and plugin runner are supported and
|
||||
> CI-tested on **Debian 13**; the desktop client is not packaged for it yet. Debian 12 (bookworm) is
|
||||
> below the glibc floor and cannot install anything here. Note that the `debian` in the repository
|
||||
> URL below is the *package format*, and is the same URL for both distros.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
|
||||
+16
-1
@@ -126,8 +126,23 @@ build() {
|
||||
fi
|
||||
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
||||
# built AND run with bun.
|
||||
#
|
||||
# Build-if-missing, matching packaging/debian/build-web-deb.sh. An in-tree CI build runs with
|
||||
# PF_SRCDIR pointing at the workspace, so when arch.yml has restored web/.output from the shared
|
||||
# actions cache it is already sitting right here and rebuilding it would be pure duplication —
|
||||
# this console was being built six times per push across the fleet. A local `makepkg` has no
|
||||
# .output and builds exactly as before.
|
||||
#
|
||||
# The Bun.serve assertion covers BOTH paths: a reused bundle is one more thing that can be wrong,
|
||||
# and node cannot run a Bun.serve entry, so shipping the wrong preset is a dead console.
|
||||
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||
( cd web && bun install --frozen-lockfile && bun run build )
|
||||
if [ -f web/.output/server/index.mjs ]; then
|
||||
echo "==> reusing the already-built web console at web/.output"
|
||||
else
|
||||
( cd web && bun install --frozen-lockfile && bun run build )
|
||||
fi
|
||||
grep -q 'Bun\.serve' web/.output/server/index.mjs \
|
||||
|| { echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry" >&2; exit 1; }
|
||||
fi
|
||||
# Plugin/script runner (opt-in): one self-contained JS via `bun build --target=bun` (effect + the
|
||||
# SDK inlined; the dynamic plugin import stays a runtime import). bun is also its vendored runtime.
|
||||
|
||||
@@ -261,9 +261,6 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||
# PUNKTFUNK_ZEROCOPY=0
|
||||
|
||||
#RUST_LOG=info
|
||||
|
||||
# Gaming Mode = ATTACH: the box owns its gamescope session; the host captures + follows it.
|
||||
PUNKTFUNK_GAMESCOPE_ATTACH=1
|
||||
```
|
||||
|
||||
**What each knob means and why these are the Bazzite defaults:**
|
||||
@@ -272,7 +269,7 @@ PUNKTFUNK_GAMESCOPE_ATTACH=1
|
||||
|---|---|---|
|
||||
| *(no compositor / no anchors)* | — | The host **auto-detects** the live session per connect (Gaming Mode gamescope vs the KDE desktop) and follows switches mid-stream; a `systemctl --user` service inherits the right `XDG_RUNTIME_DIR` and the host derives the bus itself. Pinning `PUNKTFUNK_COMPOSITOR` or hardcoding uid-1000 anchors only breaks this — leave them out. |
|
||||
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` | Create a per-client virtual output at the client's exact WxH@Hz (the flagship "native resolution, no scaling" mode), vs. `portal` which captures an existing monitor. |
|
||||
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | Gaming Mode model: the **box** owns its gamescope session; the host attaches to whatever's live and never tears it down. Swap for `PUNKTFUNK_GAMESCOPE_MANAGED=1` to have the host relaunch the gaming session headless at the **client's** exact mode instead (see the template's comments). |
|
||||
| `PUNKTFUNK_GAMESCOPE_ATTACH` | *(unset)* | Gaming Mode model. Left unset the host picks one per connect, which on Bazzite is **managed**: it relaunches the gaming session headless at the **client's** exact mode, so the client gets a display of its own. Set `=1` to force **attach** instead — the box keeps its session and the client is served a mirror of the box's own screen at the box's mode (see the template's comments). |
|
||||
| `PUNKTFUNK_ZEROCOPY` | `on` *(default)* | GPU zero-copy capture (dmabuf → CUDA → NVENC), on by default. Falls back to CPU automatically if unavailable; set `0` to force the CPU path. |
|
||||
| `RUST_LOG` | (commented) | Uncomment `RUST_LOG=info` for verbose logs while debugging. |
|
||||
|
||||
|
||||
+22
-12
@@ -20,19 +20,29 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||
# PUNKTFUNK_COMPOSITOR=kwin|mutter|wlroots|gamescope
|
||||
# PUNKTFUNK_INPUT_BACKEND=libei|wlr|gamescope|uinput
|
||||
#
|
||||
# GAME MODE = ATTACH (the box owns its session; the host follows). The box decides whether it's in
|
||||
# Steam Gaming Mode or a Desktop — you switch with the normal Steam UI / "Switch to Desktop". The
|
||||
# host just ATTACHES to whatever's live and captures it; it never tears the session down. So
|
||||
# switching Desktop<->Game is rock-solid, and when you disconnect the box STAYS in its current
|
||||
# mode — reconnecting drops you right back where you were. On a resolution mismatch the host
|
||||
# restarts the box's own game-mode session at the CLIENT's resolution (a foreign/bare gamescope
|
||||
# instead streams at its own mode).
|
||||
PUNKTFUNK_GAMESCOPE_ATTACH=1
|
||||
# GAME MODE: nothing here forces a model — the host picks one per connect. On this box (Bazzite
|
||||
# ships gamescope-session-plus) that is the MANAGED model: on connect the host takes the box's
|
||||
# gaming session over and relaunches it headless at the CLIENT's exact resolution, then restores
|
||||
# the box's own session on a debounced idle. That is what gives the client a display of its OWN
|
||||
# instead of a copy of the TV, and it is also what lets a game launched from a client's library get
|
||||
# a dedicated session to itself.
|
||||
#
|
||||
# Opt OUT to the MANAGED model instead (host tears the box's gamescope down on connect and launches
|
||||
# its OWN at the CLIENT's exact resolution; restores on a debounced idle). Client-mode-following, but
|
||||
# it does not coexist with a box-owned game-mode session — pick one:
|
||||
# PUNKTFUNK_GAMESCOPE_MANAGED=1 # (and remove PUNKTFUNK_GAMESCOPE_ATTACH above)
|
||||
# ⚠ The managed takeover has to stop the display manager for the length of the stream, and that is
|
||||
# privileged: it works for members of the `punktfunk` group and nobody else. Join it once —
|
||||
# `sudo usermod -aG punktfunk "$USER"`, then log out and back in. Skip it and the takeover cannot
|
||||
# stop SDDM, which relogin-loops against it for the whole stream and can starve the game. The host
|
||||
# checks at startup and says so in its log.
|
||||
#
|
||||
# Opt IN to the ATTACH model if you would rather the BOX keep ownership: the host captures whatever
|
||||
# gamescope is live and never tears it down, so Desktop<->Game switching is rock-solid and the box
|
||||
# stays in its current mode when you disconnect. The cost is that a box driving a physical display
|
||||
# streams at the BOX's own mode, MIRRORED — the client does not get its own display — and this
|
||||
# override outranks a dedicated game session too. Remove the line to go back to auto (`=0` also
|
||||
# turns it off; any other value is on):
|
||||
# PUNKTFUNK_GAMESCOPE_ATTACH=1
|
||||
#
|
||||
# Force the MANAGED model on a box where the auto-detection finds no session infrastructure:
|
||||
# PUNKTFUNK_GAMESCOPE_MANAGED=1
|
||||
#
|
||||
# Follow a Gaming<->Desktop switch MID-STREAM (rebuild the backend in place, no reconnect). This is
|
||||
# ON BY DEFAULT on Bazzite/SteamOS (the host detects the platform); set =0 to disable it:
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# punktfunk-host — Debian/Ubuntu package (apt)
|
||||
|
||||
> **Which distros the published packages install on** — measured by installing them, not inferred
|
||||
> from the build image (`scripts/ci/deb-install-smoke.sh` asserts this on every run):
|
||||
>
|
||||
> | | Ubuntu 24.04 | Ubuntu 26.04 | Debian 13 | Debian 12 |
|
||||
> |---|---|---|---|---|
|
||||
> | `punktfunk-host` | ✅ | ✅ | ✅ | ❌ glibc 2.36 < 2.39 |
|
||||
> | `punktfunk-web` / `punktfunk-scripting` | ✅ | ✅ | ✅ | ✅ |
|
||||
> | `punktfunk-gamescope` | ❌ wayland 1.22 | ✅ | ✅ | ❌ |
|
||||
> | `punktfunk-client` | ❌ `libc6 >= 2.43` | ✅ | ❌ `libc6 >= 2.43` | ❌ |
|
||||
>
|
||||
> Debian 13 is a supported host target ([docs](https://docs.punktfunk.unom.io/docs/debian)); the
|
||||
> client is the one gap, since it is built on 26.04 and floors at that release's glibc.
|
||||
|
||||
`punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public
|
||||
`unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds
|
||||
and publishes on every push to `main` (a rolling `<next-minor>~ciN.g<sha>` build — the base is
|
||||
@@ -36,6 +49,37 @@ dropped from `Depends`). The result is **one** host `.deb` that installs on **Ub
|
||||
on 26.04 (the native client needs SDL3 / GTK4 ≥ 4.20, absent on 24.04) — install the client on the box
|
||||
you stream *to*, which is independent of the host's distro.
|
||||
|
||||
## `punktfunk-gamescope` is built on Debian 13, not Ubuntu
|
||||
|
||||
The patched gamescope has its own job (`build-publish-gamescope`) in a **Debian 13** image
|
||||
(`ci/gamescope-trixie.Dockerfile`), and that is not a preference — it is the only apt distro the
|
||||
tree configures on. Built in the noble host image, as it was until 2026-08, it failed every single
|
||||
run:
|
||||
|
||||
```
|
||||
wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
|
||||
subprojects/wlroots/meson.build:96:17: ERROR: Dependency 'wayland-server' is required but not found
|
||||
```
|
||||
|
||||
Our pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1; noble ships 1.22.0 (and has
|
||||
no `libxcb-errors-dev`, and only libdisplay-info 0.1.1). Because every rung of that path was a
|
||||
`::warning::` returning 0, **v0.26.0 and v0.27.0 both shipped with no gamescope .deb** while the
|
||||
release notes and docs-site said it was apt-installable. Debian 13 has wayland 1.23.1 exactly —
|
||||
the oldest apt base that works.
|
||||
|
||||
Two things make the one package serve both Debian 13 and Ubuntu 26.04:
|
||||
|
||||
- **`--extra-fallback libdisplay-info`** (see `packaging/gamescope/build-punktfunk-gamescope.sh`).
|
||||
Linked against the distro copy, the package picks up `Depends: libdisplay-info2 (>= 0.2.0)` on
|
||||
trixie — and Ubuntu 26.04 carries libdisplay-info **3** (0.3.0), so apt refuses it there.
|
||||
gamescope vendors the library as a submodule, so the vendored build drops the dependency. Same
|
||||
reasoning the script already applies to wlroots: a binary we ship must not follow the build
|
||||
host's shared libraries.
|
||||
- The **static C++ runtime** the build script already forces, so `libstdc++` never appears in
|
||||
`NEEDED`. The binary asks only for `GLIBC_2.38`.
|
||||
|
||||
**Ubuntu 24.04 gets no gamescope package** and cannot: the wayland floor is a runtime one too.
|
||||
|
||||
## Install on a host (one-time)
|
||||
|
||||
The registry is public, so no apt auth is needed — just trust the repo's signing key:
|
||||
|
||||
@@ -28,7 +28,7 @@ set -euo pipefail
|
||||
GAMESCOPE_REV="5fb8dce4a09d0a68d097b9faf9513782106bc843"
|
||||
GAMESCOPE_REPO="https://github.com/ValveSoftware/gamescope.git"
|
||||
|
||||
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1
|
||||
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1 EXTRA_FALLBACK=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--rev) REV="${2:?}"; shift 2 ;;
|
||||
@@ -37,6 +37,15 @@ while [ $# -gt 0 ]; do
|
||||
--srcdir) SRCDIR="${2:?}"; shift 2 ;;
|
||||
--jobs) JOBS="${2:?}"; shift 2 ;;
|
||||
--no-setcap) SETCAP=0; shift ;;
|
||||
# Extra `force_fallback_for` entries, comma-separated, appended to the mandatory three below.
|
||||
# Exists for ONE package family: the .deb has to install on both Debian 13 and Ubuntu 26.04,
|
||||
# and those two disagree on the libdisplay-info SONAME (0.2.0 -> libdisplay-info2 vs 0.3.0 ->
|
||||
# libdisplay-info3), so a package built against either one is uninstallable on the other.
|
||||
# Vendoring it makes ONE .deb serve both. Opt-in rather than baked in, so the Arch/Fedora/nix
|
||||
# packages — which have no such split and are shipping fine — keep producing exactly the binary
|
||||
# they produce today. Its only caller is the `build-publish-gamescope` job in
|
||||
# .gitea/workflows/deb.yml, which passes `--extra-fallback libdisplay-info`.
|
||||
--extra-fallback) EXTRA_FALLBACK="${2:?}"; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
@@ -111,7 +120,7 @@ export LDFLAGS="${LDFLAGS:-} -static-libstdc++ -static-libgcc"
|
||||
meson setup "$BUILD" "$SRCDIR" \
|
||||
--prefix="$PREFIX" \
|
||||
--buildtype=release \
|
||||
-Dforce_fallback_for=libliftoff,vkroots,wlroots \
|
||||
-Dforce_fallback_for="libliftoff,vkroots,wlroots${EXTRA_FALLBACK:+,$EXTRA_FALLBACK}" \
|
||||
-Dpipewire=enabled \
|
||||
-Denable_tests=false \
|
||||
-Denable_openvr_support=false \
|
||||
|
||||
@@ -15,6 +15,23 @@ PF_RELEASE="${PF_RELEASE:-1}"
|
||||
# builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works.
|
||||
WEB_OPT=()
|
||||
[ "${PF_WITH_WEB:-0}" = "1" ] && WEB_OPT=(--with web)
|
||||
# PF_PREBUILT_WEB_OUTPUT: an already-built web/.output to package instead of building the console a
|
||||
# second time (see the pf_prebuilt_web note in the spec's %build). CI restores one from the shared
|
||||
# actions cache and points this at it; a plain build leaves it unset and nothing changes.
|
||||
# Deliberately validated HERE rather than trusted: an empty or half-restored cache directory must
|
||||
# fall back to building, not produce a console-less package. The spec re-asserts the Bun.serve
|
||||
# marker on whatever it ends up with.
|
||||
PREBUILT_WEB_OPT=()
|
||||
if [ "${PF_WITH_WEB:-0}" = "1" ] && [ -n "${PF_PREBUILT_WEB_OUTPUT:-}" ]; then
|
||||
if [ -f "${PF_PREBUILT_WEB_OUTPUT}/server/index.mjs" ]; then
|
||||
# rpmbuild resolves the macro inside the extracted tarball dir, so it must be absolute.
|
||||
PREBUILT_WEB_ABS="$(cd "$PF_PREBUILT_WEB_OUTPUT" && pwd)"
|
||||
PREBUILT_WEB_OPT=(--define "pf_prebuilt_web ${PREBUILT_WEB_ABS}")
|
||||
echo "==> reusing prebuilt web console: $PREBUILT_WEB_ABS"
|
||||
else
|
||||
echo "==> PF_PREBUILT_WEB_OUTPUT=$PF_PREBUILT_WEB_OUTPUT has no server/index.mjs — building the console" >&2
|
||||
fi
|
||||
fi
|
||||
# PF_WITH_SCRIPTING=1 builds the punktfunk-scripting subpackage (the plugin/script runner). Same bun
|
||||
# requirement as web; default off so a bare `rpmbuild`/COPR still works.
|
||||
SCRIPTING_OPT=()
|
||||
@@ -61,6 +78,7 @@ fi
|
||||
# rust-toolchain.toml's pinned channel works) and the -devel libs via dnf, neither of which
|
||||
# rpmbuild's RPM-level check sees — skip it; a genuinely missing dep fails the compile/link.
|
||||
rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" "${HOST_OPT[@]}" \
|
||||
"${PREBUILT_WEB_OPT[@]}" \
|
||||
--define "_topdir $TOP" \
|
||||
--define "pf_version ${PF_VERSION}" \
|
||||
--define "pf_release ${PF_RELEASE}" \
|
||||
|
||||
@@ -265,9 +265,30 @@ cargo build --release --locked -p punktfunk-tray
|
||||
%endif
|
||||
|
||||
%if %{with web}
|
||||
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
||||
# TLS entry). bun is both the build tool AND the runtime (vendored in %%install below).
|
||||
# Management web console: the Nitro SSR bundle (the `bun` preset + our Bun.serve TLS entry). bun is
|
||||
# both the build tool AND the runtime (vendored in %%install below).
|
||||
#
|
||||
# `pf_prebuilt_web` (optional, absolute path to an already-built web/.output) lets CI hand over a
|
||||
# bundle it has already produced instead of building a second, identical one here. It exists because
|
||||
# this console was being rebuilt SIX times per push — ci.yml, deb, both RPM legs, arch, and the
|
||||
# docker app image — at ~2.5 min each. rpm.yml restores it from the shared actions cache and passes
|
||||
# this macro; see build-rpm.sh. Undefined (a plain rpmbuild, or COPR) takes the build path exactly
|
||||
# as before, so nothing outside CI changes.
|
||||
#
|
||||
# It has to be a MACRO carrying an absolute path, not simply a pre-populated web/.output: this spec
|
||||
# builds from the `git archive` tarball build-rpm.sh generates, and web/.output is gitignored — it
|
||||
# is not in the tarball and cannot be, so there is nothing here to find without being told where to
|
||||
# look.
|
||||
%if %{defined pf_prebuilt_web}
|
||||
echo "==> reusing the prebuilt web console from %{pf_prebuilt_web}"
|
||||
mkdir -p web/.output
|
||||
cp -a %{pf_prebuilt_web}/. web/.output/
|
||||
%else
|
||||
(cd web && bun install --frozen-lockfile && bun run build)
|
||||
%endif
|
||||
# Asserted for BOTH paths on purpose. This is the check that says the artifact about to be packaged
|
||||
# is the bun preset (node cannot run Bun.serve) — a handed-over bundle deserves it at least as much
|
||||
# as a freshly built one, because a cache is one more place a wrong artifact can come from.
|
||||
if ! grep -q 'Bun\.serve' web/.output/server/index.mjs; then
|
||||
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry" >&2
|
||||
exit 1
|
||||
|
||||
Generated
-18
@@ -407,7 +407,6 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"pf-driver-proto",
|
||||
"pf-umdf-util",
|
||||
"wdk",
|
||||
"wdk-build",
|
||||
"wdk-sys",
|
||||
]
|
||||
@@ -418,7 +417,6 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"pf-driver-proto",
|
||||
"pf-umdf-util",
|
||||
"wdk",
|
||||
"wdk-build",
|
||||
"wdk-sys",
|
||||
]
|
||||
@@ -437,7 +435,6 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"pf-driver-proto",
|
||||
"thiserror",
|
||||
"wdk",
|
||||
"wdk-build",
|
||||
"wdk-iddcx",
|
||||
"wdk-sys",
|
||||
@@ -450,7 +447,6 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"pf-driver-proto",
|
||||
"pf-umdf-util",
|
||||
"wdk",
|
||||
"wdk-build",
|
||||
"wdk-sys",
|
||||
]
|
||||
@@ -754,19 +750,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "wdk"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd496a19ec75c3d98f8be805f62ebde4651fc01babf681b832d8bae9c584d25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wdk-build",
|
||||
"wdk-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wdk-build"
|
||||
version = "0.5.1"
|
||||
@@ -818,7 +801,6 @@ name = "wdk-probe"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"pf-driver-proto",
|
||||
"wdk",
|
||||
"wdk-build",
|
||||
"wdk-sys",
|
||||
]
|
||||
|
||||
@@ -27,7 +27,12 @@ unsafe_op_in_unsafe_fn = "deny"
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
|
||||
[workspace.dependencies]
|
||||
wdk = "0.4.1"
|
||||
# NOTE: the high-level `wdk` crate (0.4.1) used to be declared here and taken by five driver
|
||||
# crates. None of them ever referenced `wdk::` — every driver goes through `wdk_sys`, including
|
||||
# the WDF call macro, which wdk-sys re-exports (see pf-umdf-util, a full WDF crate that never
|
||||
# declared `wdk` either). Removing it drops a normal (linked) dependency from all five.
|
||||
# `tracing`/`tracing-subscriber` are still in the lock afterwards, but only as wdk-sys BUILD
|
||||
# dependencies — build-script machinery, not code in the shipped DLLs.
|
||||
wdk-sys = "0.5.1"
|
||||
wdk-build = "0.5.1"
|
||||
wdk-iddcx = { path = "wdk-iddcx" }
|
||||
@@ -36,8 +41,8 @@ pf-driver-proto = { path = "../../../crates/pf-driver-proto" }
|
||||
|
||||
# Vendored windows-drivers-rs 0.5.1 (the published, self-contained crates) + an added `iddcx`
|
||||
# ApiSubset (M1 — bindgens iddcx/1.10/IddCx.h reusing wdk_default for WDF type-identity). Redirect ALL
|
||||
# wdk-sys/wdk-build refs (incl. wdk 0.4.1's transitive deps) to the patched copies so there is exactly
|
||||
# one (iddcx-capable) wdk-sys in the graph. Pinned; do not chase upstream.
|
||||
# wdk-sys/wdk-build refs to the patched copies so there is exactly one (iddcx-capable) wdk-sys in the
|
||||
# graph. Pinned; do not chase upstream.
|
||||
[patch.crates-io]
|
||||
wdk-build = { path = "vendor/wdk-build" }
|
||||
wdk-sys = { path = "vendor/wdk-sys" }
|
||||
|
||||
@@ -23,7 +23,6 @@ crate-type = ["cdylib"]
|
||||
wdk-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
wdk.workspace = true
|
||||
wdk-sys.workspace = true
|
||||
pf-driver-proto.workspace = true
|
||||
pf-umdf-util.workspace = true
|
||||
@@ -31,7 +30,7 @@ pf-umdf-util.workspace = true
|
||||
[features]
|
||||
default = ["hid"]
|
||||
hid = ["wdk-sys/hid"]
|
||||
nightly = ["wdk-sys/nightly", "wdk/nightly"]
|
||||
nightly = ["wdk-sys/nightly"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -21,7 +21,6 @@ crate-type = ["cdylib"]
|
||||
wdk-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
wdk.workspace = true
|
||||
wdk-sys.workspace = true
|
||||
pf-driver-proto.workspace = true
|
||||
pf-umdf-util.workspace = true
|
||||
@@ -29,7 +28,7 @@ pf-umdf-util.workspace = true
|
||||
[features]
|
||||
default = ["hid"]
|
||||
hid = ["wdk-sys/hid"]
|
||||
nightly = ["wdk-sys/nightly", "wdk/nightly"]
|
||||
nightly = ["wdk-sys/nightly"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -20,7 +20,6 @@ crate-type = ["cdylib"]
|
||||
wdk-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
wdk.workspace = true
|
||||
wdk-sys = { workspace = true, features = ["iddcx"] }
|
||||
wdk-iddcx.workspace = true
|
||||
pf-driver-proto.workspace = true
|
||||
|
||||
@@ -21,14 +21,13 @@ crate-type = ["cdylib"]
|
||||
wdk-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
wdk.workspace = true
|
||||
wdk-sys.workspace = true
|
||||
pf-driver-proto.workspace = true
|
||||
pf-umdf-util.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
nightly = ["wdk-sys/nightly", "wdk/nightly"]
|
||||
nightly = ["wdk-sys/nightly"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user