Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afed2206ab | |||
| 39a49da567 | |||
| e64aefa25c | |||
| 4d93eb24ff | |||
| 3c617f655e | |||
| 7f18b3dcd0 | |||
| 8970cfe188 | |||
| 263eab31e3 | |||
| 7ecf2d8dfd | |||
| 55dfb4800f | |||
| 47112f44b7 | |||
| dad5a08c1f | |||
| d8da12bbbd | |||
| 79508b2666 | |||
| 340cbcfe22 | |||
| 4098b252bc | |||
| f9b857aac2 | |||
| 92c6da9546 | |||
| 8531135bb7 | |||
| 2ebffe3457 | |||
| 9c86f667ca | |||
| 448986f41c | |||
| 4b1bbfdf0e | |||
| b5c30dff4f | |||
| aac48408fd | |||
| 4ff6f447a8 | |||
| 11fc3be726 | |||
| 67a32711b3 | |||
| 4be993df87 | |||
| 6b5ee9f47b | |||
| c56b1b455a | |||
| 71d6b64f81 | |||
| 0b1322d1c6 | |||
| 06346e5037 | |||
| 58cb416abb | |||
| e2257a6158 | |||
| dfed90bff2 | |||
| 184f94e867 | |||
| a95984bb4f | |||
| dea749186d | |||
| a8a6224fd8 | |||
| 5f088c6f56 | |||
| f09def4138 | |||
| 96a35ca84c | |||
| 99b4de32ee |
+17
-5
@@ -18,6 +18,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# keeps the job green across image-content changes; a no-op once the image has them.
|
||||||
|
- name: GTK4/libadwaita/SDL3 dev packages
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||||
|
|
||||||
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
|
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
|
||||||
# registry/git are download caches, target/ the incremental build. The target key
|
# registry/git are download caches, target/ the incremental build. The target key
|
||||||
# carries the rustc version — rust-toolchain.toml pins the floating "stable"
|
# carries the rustc version — rust-toolchain.toml pins the floating "stable"
|
||||||
@@ -69,10 +77,12 @@ jobs:
|
|||||||
working-directory: web
|
working-directory: web
|
||||||
steps:
|
steps:
|
||||||
# oven/bun ships neither git nor a real node (only a bun shim) — actions/checkout
|
# oven/bun ships neither git nor a real node (only a bun shim) — actions/checkout
|
||||||
# needs both.
|
# needs both. The slim Debian base also lacks ca-certificates, so without it git's
|
||||||
- name: Install git + node
|
# HTTPS fetch of the repo dies with "Problem with the SSL CA cert (path? access
|
||||||
|
# rights?)" — no CA bundle to validate git.unom.io's (public) Let's Encrypt cert.
|
||||||
|
- name: Install git + node + CA certs
|
||||||
working-directory: /
|
working-directory: /
|
||||||
run: apt-get update && apt-get install -y --no-install-recommends git nodejs
|
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile --ignore-scripts
|
run: bun install --frozen-lockfile --ignore-scripts
|
||||||
@@ -92,9 +102,11 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
working-directory: docs-site
|
working-directory: docs-site
|
||||||
steps:
|
steps:
|
||||||
- name: Install git
|
# ca-certificates: the slim Debian base lacks a CA bundle, so actions/checkout's
|
||||||
|
# HTTPS fetch otherwise fails with "Problem with the SSL CA cert" (see web job).
|
||||||
|
- name: Install git + CA certs
|
||||||
working-directory: /
|
working-directory: /
|
||||||
run: apt-get update && apt-get install -y --no-install-recommends git
|
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile --ignore-scripts
|
run: bun install --frozen-lockfile --ignore-scripts
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian
|
||||||
|
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs
|
||||||
|
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the
|
||||||
|
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the
|
||||||
|
# target boxes run.
|
||||||
|
#
|
||||||
|
# Registry (public, unom org): https://git.unom.io/unom/-/packages
|
||||||
|
# Box setup (once): see packaging/debian/README.md
|
||||||
|
#
|
||||||
|
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
|
||||||
|
name: deb
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.unom.io
|
||||||
|
OWNER: unom
|
||||||
|
DISTRIBUTION: stable
|
||||||
|
COMPONENT: main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-publish:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: git.unom.io/unom/punktfunk-rust-ci:latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# dpkg-shlibdeps (Depends resolution) + dpkg-deb live in dpkg-dev. The client's link
|
||||||
|
# deps are also baked into the rust-ci image, but this job runs against the image
|
||||||
|
# from the PREVIOUS push (docker.yml bootstrap note) — keep it green across image
|
||||||
|
# changes; a no-op once the image has them.
|
||||||
|
- name: dpkg-dev + client link deps
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends dpkg-dev \
|
||||||
|
libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||||
|
|
||||||
|
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
|
||||||
|
- name: Cache keys
|
||||||
|
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/usr/local/cargo/registry
|
||||||
|
/usr/local/cargo/git
|
||||||
|
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-home-
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: cargo-target-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-target-${{ env.rustc }}-
|
||||||
|
|
||||||
|
- name: Build release host + client
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$PWD"
|
||||||
|
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
|
||||||
|
|
||||||
|
- name: Version
|
||||||
|
# Tag v1.2.3 -> 1.2.3 (a real release); a main push -> 0.0.1~ciN.g<sha>, which sorts
|
||||||
|
# BEFORE 0.0.1 (the '~') yet monotonically increases by run number, so `apt upgrade`
|
||||||
|
# always moves the boxes to the newest main build.
|
||||||
|
run: |
|
||||||
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
|
case "$GITHUB_REF" in
|
||||||
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
||||||
|
*) V="0.0.1~ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
||||||
|
esac
|
||||||
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
|
echo "package version $V"
|
||||||
|
|
||||||
|
- name: Build .debs
|
||||||
|
run: |
|
||||||
|
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
||||||
|
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
||||||
|
|
||||||
|
- name: Publish to the Gitea apt registry
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
for DEB in dist/*.deb; do
|
||||||
|
echo "uploading $DEB"
|
||||||
|
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
|
||||||
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||||
|
done
|
||||||
|
echo "published to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
# punktfunk-web — management console (web/Dockerfile, repo-root context)
|
# punktfunk-web — management console (web/Dockerfile, repo-root context)
|
||||||
# punktfunk-docs — documentation site (docs-site/Dockerfile)
|
# punktfunk-docs — documentation site (docs-site/Dockerfile)
|
||||||
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml
|
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml
|
||||||
|
# punktfunk-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM)
|
||||||
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
||||||
#
|
#
|
||||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope.
|
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope.
|
||||||
@@ -38,6 +39,9 @@ jobs:
|
|||||||
- image: punktfunk-rust-ci
|
- image: punktfunk-rust-ci
|
||||||
dockerfile: ci/rust-ci.Dockerfile
|
dockerfile: ci/rust-ci.Dockerfile
|
||||||
context: ci
|
context: ci
|
||||||
|
- image: punktfunk-fedora-rpm
|
||||||
|
dockerfile: ci/fedora-rpm.Dockerfile
|
||||||
|
context: ci
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -80,10 +80,12 @@ jobs:
|
|||||||
- name: Build PunktfunkCore.xcframework (mac + iOS)
|
- name: Build PunktfunkCore.xcframework (mac + iOS)
|
||||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||||
|
|
||||||
- name: Import Developer ID certificate (throwaway keychain)
|
- name: Import signing certificates (throwaway keychain)
|
||||||
env:
|
env:
|
||||||
P12_B64: ${{ secrets.DEVID_CERT_P12_B64 }}
|
P12_B64: ${{ secrets.DEVID_CERT_P12_B64 }}
|
||||||
P12_PASSWORD: ${{ secrets.DEVID_CERT_PASSWORD }}
|
P12_PASSWORD: ${{ secrets.DEVID_CERT_PASSWORD }}
|
||||||
|
IOS_P12_B64: ${{ secrets.IOS_DIST_CERT_P12_B64 }}
|
||||||
|
IOS_P12_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
KEYCHAIN="$RUNNER_TEMP/punktfunk-ci.keychain-db"
|
KEYCHAIN="$RUNNER_TEMP/punktfunk-ci.keychain-db"
|
||||||
KEYCHAIN_PASS="$(uuidgen)"
|
KEYCHAIN_PASS="$(uuidgen)"
|
||||||
@@ -108,6 +110,15 @@ jobs:
|
|||||||
security import "$RUNNER_TEMP/devid.p12" -k "$KEYCHAIN" -P "$P12_PASSWORD" \
|
security import "$RUNNER_TEMP/devid.p12" -k "$KEYCHAIN" -P "$P12_PASSWORD" \
|
||||||
-T /usr/bin/codesign -T /usr/bin/security
|
-T /usr/bin/codesign -T /usr/bin/security
|
||||||
rm -f "$RUNNER_TEMP/devid.p12"
|
rm -f "$RUNNER_TEMP/devid.p12"
|
||||||
|
# iOS App Store distribution identity (optional — imported only when the secret is
|
||||||
|
# set; the iOS/TestFlight job stays best-effort until it is). The WWDR intermediates
|
||||||
|
# fetched above also chain this Apple Distribution cert.
|
||||||
|
if [ -n "$IOS_P12_B64" ]; then
|
||||||
|
printf '%s' "$IOS_P12_B64" | base64 -d > "$RUNNER_TEMP/ios-dist.p12"
|
||||||
|
security import "$RUNNER_TEMP/ios-dist.p12" -k "$KEYCHAIN" -P "$IOS_P12_PASSWORD" \
|
||||||
|
-T /usr/bin/codesign -T /usr/bin/security
|
||||||
|
rm -f "$RUNNER_TEMP/ios-dist.p12"
|
||||||
|
fi
|
||||||
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
||||||
-s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
|
-s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
|
||||||
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
|
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
|
||||||
@@ -120,41 +131,63 @@ jobs:
|
|||||||
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
|
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
|
||||||
chmod 600 "$RUNNER_TEMP/asc.p8"
|
chmod 600 "$RUNNER_TEMP/asc.p8"
|
||||||
|
|
||||||
- name: Archive macOS
|
- name: Archive macOS (unsigned — signed by codesign below)
|
||||||
run: |
|
run: |
|
||||||
|
# Archive WITHOUT signing, then codesign with Developer ID in the next step. We do
|
||||||
|
# NOT let xcodebuild sign during archive because the app's keychain-access-groups
|
||||||
|
# entitlement is the "Keychain Sharing" capability, and Xcode's archive gate demands
|
||||||
|
# a provisioning profile for it under BOTH automatic and manual signing — even
|
||||||
|
# though a Developer ID app honours that team-prefixed entitlement at RUNTIME with
|
||||||
|
# no profile (the gate is an Xcode build-phase check, not a real requirement). Raw
|
||||||
|
# codesign has no such gate. Safe because the bundle is a single statically-linked
|
||||||
|
# binary: static PunktfunkCore.xcframework, SPM static products, macOS 14 target (no
|
||||||
|
# embedded Swift dylibs), and no Embed-Frameworks phase — so nothing nested to sign.
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk \
|
-project "$PROJECT" -scheme Punktfunk \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
-allowProvisioningUpdates \
|
CODE_SIGNING_ALLOWED=NO
|
||||||
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
|
||||||
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
|
||||||
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
|
|
||||||
|
|
||||||
- name: Export macOS (Developer ID)
|
- name: Sign macOS app (Developer ID, hardened runtime)
|
||||||
run: |
|
run: |
|
||||||
cat > "$RUNNER_TEMP/export-devid.plist" <<EOF
|
APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
# codesign does NOT expand $(AppIdentifierPrefix) (an Xcode build-setting var), so
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
# resolve it to the real team prefix — otherwise keychain-access-groups would be the
|
||||||
<plist version="1.0">
|
# literal string instead of the team-scoped group.
|
||||||
<dict>
|
RESOLVED="$RUNNER_TEMP/Punktfunk.entitlements"
|
||||||
<key>method</key><string>developer-id</string>
|
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
||||||
<key>teamID</key><string>$TEAM_ID</string>
|
clients/apple/Config/Punktfunk.entitlements > "$RESOLVED"
|
||||||
<key>destination</key><string>export</string>
|
# codesign must be pointed at the throwaway keychain explicitly: on this runner the
|
||||||
<!-- Manual + explicit cert: with -allowProvisioningUpdates Xcode prefers
|
# default keychain search list does not reliably carry across steps, so a bare
|
||||||
CLOUD-managed Developer ID signing, which the App-Manager-role API key
|
# --sign "Developer ID Application" reports "no identity found" even though the
|
||||||
can't do ("Cloud signing permission error") and it never falls back to
|
# import step found it there. Re-assert the search list + default keychain in THIS
|
||||||
the perfectly valid local identity. -->
|
# step's context (no password needed — it stays unlocked with a codesign-allowed
|
||||||
<key>signingStyle</key><string>manual</string>
|
# partition list from the import step) AND scope codesign to it with --keychain.
|
||||||
<key>signingCertificate</key><string>Developer ID Application</string>
|
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
|
||||||
</dict>
|
security default-keychain -d user -s "$KEYCHAIN"
|
||||||
</plist>
|
echo "signing identity keychain: $KEYCHAIN"
|
||||||
EOF
|
security find-identity -v -p codesigning "$KEYCHAIN"
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
|
# Inside-out: sign any nested Mach-O first (defensive — the static build normally
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
|
# has none), then the app bundle with the resolved entitlements + hardened runtime +
|
||||||
-exportOptionsPlist "$RUNNER_TEMP/export-devid.plist" \
|
# secure timestamp, which is what notarization requires.
|
||||||
-exportPath "$RUNNER_TEMP/export-devid"
|
if [ -d "$APP/Contents/Frameworks" ]; then
|
||||||
|
find "$APP/Contents/Frameworks" -depth \( -name '*.framework' -o -name '*.dylib' \) \
|
||||||
|
-print0 | while IFS= read -r -d '' f; do
|
||||||
|
codesign --force --options runtime --timestamp \
|
||||||
|
--keychain "$KEYCHAIN" \
|
||||||
|
--sign "Developer ID Application" "$f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
codesign --force --options runtime --timestamp \
|
||||||
|
--keychain "$KEYCHAIN" \
|
||||||
|
--entitlements "$RESOLVED" \
|
||||||
|
--sign "Developer ID Application" "$APP"
|
||||||
|
codesign --verify --strict --verbose=2 "$APP"
|
||||||
|
# Stage where the DMG step expects it ($RUNNER_TEMP/export-devid/Punktfunk.app).
|
||||||
|
mkdir -p "$RUNNER_TEMP/export-devid"
|
||||||
|
rm -rf "$RUNNER_TEMP/export-devid/Punktfunk.app"
|
||||||
|
cp -R "$APP" "$RUNNER_TEMP/export-devid/Punktfunk.app"
|
||||||
|
|
||||||
- name: Notarized DMG
|
- name: Notarized DMG
|
||||||
run: |
|
run: |
|
||||||
@@ -196,6 +229,24 @@ jobs:
|
|||||||
# is done so real upload failures fail the run.
|
# is done so real upload failures fail the run.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
|
# The iOS platform SDK is a separate Xcode component and isn't installed on every
|
||||||
|
# runner; without it `archive` dies with "iOS 26.5 is not installed". Skip cleanly
|
||||||
|
# (this is best-effort anyway) instead of a red step — install it on the runner with
|
||||||
|
# `xcodebuild -downloadPlatform iOS` when iOS/TestFlight is ready to go live.
|
||||||
|
if ! DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -showsdks 2>/dev/null | grep -q iphoneos; then
|
||||||
|
echo "::warning::iOS platform SDK not installed on this runner — skipping iOS/TestFlight."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# App Store signing uses the Apple Distribution identity imported above from
|
||||||
|
# IOS_DIST_CERT_P12_B64. Skip cleanly until that secret exists; re-assert the
|
||||||
|
# throwaway keychain on the search list + as default so automatic signing finds it
|
||||||
|
# (the search list doesn't reliably carry across steps on this runner).
|
||||||
|
if ! security find-identity -v -p codesigning "$KEYCHAIN" | grep -q "Apple Distribution"; then
|
||||||
|
echo "::warning::no Apple Distribution identity present — set IOS_DIST_CERT_P12_B64. Skipping iOS/TestFlight."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
|
||||||
|
security default-keychain -d user -s "$KEYCHAIN"
|
||||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||||
-project "$PROJECT" -scheme Punktfunk-iOS \
|
-project "$PROJECT" -scheme Punktfunk-iOS \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Build the punktfunk-host RPM and publish it to Gitea's RPM package registry, so Bazzite /
|
||||||
|
# Fedora Atomic hosts layer + update it with rpm-ostree. Counterpart to deb.yml (apt). Runs in
|
||||||
|
# the Fedora 43 builder image (ci/fedora-rpm.Dockerfile) so the RPM's auto library Requires
|
||||||
|
# (libavcodec.so.NN, …) match the target's sonames.
|
||||||
|
#
|
||||||
|
# Registry (public, unom org), group "bazzite":
|
||||||
|
# repo file https://git.unom.io/api/packages/unom/rpm/bazzite.repo
|
||||||
|
# Box setup (once): see packaging/rpm/README.md
|
||||||
|
#
|
||||||
|
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
|
||||||
|
name: rpm
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.unom.io
|
||||||
|
OWNER: unom
|
||||||
|
RPM_GROUP: bazzite
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-publish:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: git.unom.io/unom/punktfunk-fedora-rpm:latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
CARGO_HOME: /usr/local/cargo
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# against the image from the PREVIOUS push (docker.yml bootstrap note) — keep it
|
||||||
|
# green across image changes; a no-op once the image has them.
|
||||||
|
- name: Prep
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$PWD"
|
||||||
|
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /usr/local/cargo/registry
|
||||||
|
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-home-
|
||||||
|
|
||||||
|
- name: Version
|
||||||
|
# Tag v1.2.3 -> 1.2.3-1 (release); main push -> 0.0.1-0.ciN.g<sha>, whose release "0."
|
||||||
|
# sorts BEFORE the eventual "1" yet increases by run number, so `rpm-ostree upgrade`
|
||||||
|
# always moves to the newest main build.
|
||||||
|
run: |
|
||||||
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
|
case "$GITHUB_REF" in
|
||||||
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1" ;;
|
||||||
|
*) V="0.0.1"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;;
|
||||||
|
esac
|
||||||
|
echo "PF_VERSION=$V" >> "$GITHUB_ENV"
|
||||||
|
echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
|
||||||
|
echo "rpm $V-$R"
|
||||||
|
|
||||||
|
- name: Build RPM
|
||||||
|
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" bash packaging/rpm/build-rpm.sh
|
||||||
|
|
||||||
|
- name: Publish to the Gitea RPM registry
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# Publish only the main package (skip -debuginfo/-debugsource subpackages).
|
||||||
|
for rpm in dist/*.rpm; do
|
||||||
|
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
|
||||||
|
echo "uploading $rpm"
|
||||||
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/rpm/$RPM_GROUP/upload"
|
||||||
|
done
|
||||||
|
echo "published to $OWNER/rpm/$RPM_GROUP"
|
||||||
@@ -13,3 +13,6 @@ clients/apple/PunktfunkCore.xcframework/
|
|||||||
clients/apple/.swiftpm/
|
clients/apple/.swiftpm/
|
||||||
# Xcode per-user state
|
# Xcode per-user state
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|
||||||
|
# Debian package build output
|
||||||
|
/dist/
|
||||||
|
|||||||
@@ -86,8 +86,28 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
`RemoteFirstLightTests` (full pipeline over the LAN). See
|
||||||
[`clients/apple/README.md`](clients/apple/README.md). Next: stage 2 presenter
|
[`clients/apple/README.md`](clients/apple/README.md). Next: stage 2 presenter
|
||||||
(`VTDecompressionSession` + `CAMetalLayer` frame pacing), glass-to-glass numbers via
|
(`VTDecompressionSession` + `CAMetalLayer` frame pacing), glass-to-glass numbers via
|
||||||
`tools/latency-probe` (scaffold), iOS variant. The Linux reference client
|
`tools/latency-probe` (scaffold), iOS variant.
|
||||||
(`punktfunk-client-rs`) gets VAAPI + wgpu on the same connector later.
|
**Linux stage 1 done, first light 2026-06-12** (`crates/punktfunk-client-linux`, binary
|
||||||
|
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
|
||||||
|
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
|
||||||
|
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
|
||||||
|
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
|
||||||
|
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
|
||||||
|
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
|
||||||
|
against `serve --native` on this box: 1080p60, steady 60 fps, capture→decoded p50
|
||||||
|
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
|
||||||
|
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
|
||||||
|
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
|
||||||
|
service (pad pin UI, auto type from the physical pad, DualSense touchpad/motion 0xCC +
|
||||||
|
raw-DS5-effects trigger/player-LED replay — needs a physical pad to live-verify), mic
|
||||||
|
uplink (validated live), per-host speed test, compositor pref, native-display mode
|
||||||
|
default, saved-hosts list, .deb + RPM-subpackage CI (deb.yml/rpm.yml). **VAAPI decode
|
||||||
|
→ DRM-PRIME dmabuf → `GdkDmabufTexture`** (BT.709 color state; Tier-1 zero-copy on
|
||||||
|
Intel/AMD, `PUNKTFUNK_DECODER=software|vaapi` override) with a proven fallback ladder —
|
||||||
|
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode; needs an
|
||||||
|
Intel/AMD client box to live-verify the hw path. Next: the stage-2 raw-Wayland
|
||||||
|
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
|
||||||
|
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
|
||||||
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
||||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||||
at high res).
|
at high res).
|
||||||
@@ -141,7 +161,8 @@ crates/punktfunk-host/
|
|||||||
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||||
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
inject/{libei,wlr,gamepad,dualsense}.rs input backends (uinput xpad + UHID DualSense)
|
||||||
capture.rs · encode.rs · audio.rs · m0.rs · m3.rs · mgmt.rs · native_pairing.rs
|
capture.rs · encode.rs · audio.rs · m0.rs · m3.rs · mgmt.rs · native_pairing.rs
|
||||||
crates/punktfunk-client-rs/ punktfunk/1 reference client (M3 headless; M4 adds decode+present)
|
crates/punktfunk-client-rs/ punktfunk/1 reference client (M3 headless test/measurement tool)
|
||||||
|
crates/punktfunk-client-linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
|
||||||
web/ TanStack web console over the mgmt API (status · devices · pairing)
|
web/ TanStack web console over the mgmt API (status · devices · pairing)
|
||||||
packaging/ Fedora/Bazzite RPM · bootc · COPR (packaging/bazzite/README.md)
|
packaging/ Fedora/Bazzite RPM · bootc · COPR (packaging/bazzite/README.md)
|
||||||
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
tools/{loss-harness,latency-probe}/ measurement (plan §10)
|
||||||
|
|||||||
Generated
+388
@@ -196,6 +196,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-channel"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-recursion"
|
name = "async-recursion"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -419,6 +431,29 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cairo-rs"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cairo-sys-rs"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbc"
|
name = "cbc"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -896,6 +931,16 @@ version = "0.2.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "field-offset"
|
||||||
|
version = "0.3.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
||||||
|
dependencies = [
|
||||||
|
"memoffset",
|
||||||
|
"rustc_version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -1057,6 +1102,63 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gdk-pixbuf"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646"
|
||||||
|
dependencies = [
|
||||||
|
"gdk-pixbuf-sys",
|
||||||
|
"gio",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gdk-pixbuf-sys"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb"
|
||||||
|
dependencies = [
|
||||||
|
"gio-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gdk4"
|
||||||
|
version = "0.11.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-rs",
|
||||||
|
"gdk-pixbuf",
|
||||||
|
"gdk4-sys",
|
||||||
|
"gio",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
"pango",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gdk4-sys"
|
||||||
|
version = "0.11.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"gdk-pixbuf-sys",
|
||||||
|
"gio-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
"pkg-config",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -1117,12 +1219,202 @@ dependencies = [
|
|||||||
"polyval",
|
"polyval",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gio"
|
||||||
|
version = "0.22.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"gio-sys",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gio-sys"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glib"
|
||||||
|
version = "0.22.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
"gio-sys",
|
||||||
|
"glib-macros",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glib-macros"
|
||||||
|
version = "0.22.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glib-sys"
|
||||||
|
version = "0.22.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glob"
|
name = "glob"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gobject-sys"
|
||||||
|
version = "0.22.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "graphene-rs"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1"
|
||||||
|
dependencies = [
|
||||||
|
"glib",
|
||||||
|
"graphene-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "graphene-sys"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gsk4"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-rs",
|
||||||
|
"gdk4",
|
||||||
|
"glib",
|
||||||
|
"graphene-rs",
|
||||||
|
"gsk4-sys",
|
||||||
|
"libc",
|
||||||
|
"pango",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gsk4-sys"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"gdk4-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"graphene-sys",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gtk4"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-rs",
|
||||||
|
"field-offset",
|
||||||
|
"futures-channel",
|
||||||
|
"gdk-pixbuf",
|
||||||
|
"gdk4",
|
||||||
|
"gio",
|
||||||
|
"glib",
|
||||||
|
"graphene-rs",
|
||||||
|
"gsk4",
|
||||||
|
"gtk4-macros",
|
||||||
|
"gtk4-sys",
|
||||||
|
"libc",
|
||||||
|
"pango",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gtk4-macros"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gtk4-sys"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"gdk-pixbuf-sys",
|
||||||
|
"gdk4-sys",
|
||||||
|
"gio-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"graphene-sys",
|
||||||
|
"gsk4-sys",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1427,6 +1719,37 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libadwaita"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4"
|
||||||
|
dependencies = [
|
||||||
|
"gdk4",
|
||||||
|
"gio",
|
||||||
|
"glib",
|
||||||
|
"gtk4",
|
||||||
|
"libadwaita-sys",
|
||||||
|
"libc",
|
||||||
|
"pango",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libadwaita-sys"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d"
|
||||||
|
dependencies = [
|
||||||
|
"gdk4-sys",
|
||||||
|
"gio-sys",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk4-sys",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.186"
|
||||||
@@ -1753,6 +2076,30 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pango"
|
||||||
|
version = "0.22.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438"
|
||||||
|
dependencies = [
|
||||||
|
"gio",
|
||||||
|
"glib",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pango-sys"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6"
|
||||||
|
dependencies = [
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"libc",
|
||||||
|
"system-deps",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -1948,6 +2295,26 @@ dependencies = [
|
|||||||
"unarray",
|
"unarray",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "punktfunk-client-linux"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-channel",
|
||||||
|
"ffmpeg-next",
|
||||||
|
"gtk4",
|
||||||
|
"libadwaita",
|
||||||
|
"mdns-sd",
|
||||||
|
"opus",
|
||||||
|
"pipewire",
|
||||||
|
"punktfunk-core",
|
||||||
|
"sdl3",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-rs"
|
name = "punktfunk-client-rs"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
@@ -2516,6 +2883,27 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sdl3"
|
||||||
|
version = "0.18.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25bd22eb1bbc9137e914022b4994ed35591eea0884e9e3e98e6d9895cad6e1d2"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"libc",
|
||||||
|
"sdl3-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sdl3-sys"
|
||||||
|
version = "0.6.6+SDL-3.4.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04e7f134def04ed72e6f55187c6c29c72f7dab5d359c4be0dd49c9b97fef59c7"
|
||||||
|
dependencies = [
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ members = [
|
|||||||
"crates/punktfunk-core",
|
"crates/punktfunk-core",
|
||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
"crates/punktfunk-client-rs",
|
"crates/punktfunk-client-rs",
|
||||||
|
"crates/punktfunk-client-linux",
|
||||||
"tools/latency-probe",
|
"tools/latency-probe",
|
||||||
"tools/loss-harness",
|
"tools/loss-harness",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# CI builder for the punktfunk RPM — Fedora 43 to match Bazzite's base (so the RPM's
|
||||||
|
# auto-generated library Requires, e.g. libavcodec.so.NN, pin to exactly what the target
|
||||||
|
# runs). Used by .gitea/workflows/rpm.yml; built+pushed by .gitea/workflows/docker.yml.
|
||||||
|
#
|
||||||
|
# docker build -f ci/fedora-rpm.Dockerfile -t punktfunk-fedora-rpm ci
|
||||||
|
#
|
||||||
|
# Mirrors ci/rust-ci.Dockerfile (the Ubuntu workspace builder) for the rpmbuild side.
|
||||||
|
FROM fedora:43
|
||||||
|
|
||||||
|
# RPM Fusion (free + nonfree) provides the NVENC-capable ffmpeg-devel the host links against.
|
||||||
|
RUN dnf -y install \
|
||||||
|
"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \
|
||||||
|
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
|
||||||
|
&& dnf -y install \
|
||||||
|
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache)
|
||||||
|
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs \
|
||||||
|
# build toolchain + bindgen
|
||||||
|
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
|
||||||
|
# ffmpeg (NVENC), capture/audio/display link deps
|
||||||
|
ffmpeg-devel pipewire-devel wayland-devel libxkbcommon-devel opus-devel \
|
||||||
|
mesa-libGL-devel mesa-libgbm-devel \
|
||||||
|
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
|
||||||
|
gtk4-devel libadwaita-devel SDL3-devel \
|
||||||
|
&& dnf clean all
|
||||||
|
|
||||||
|
# libcuda link stub — the zerocopy path links a fixed set of cuXxx driver symbols, but CI has
|
||||||
|
# no GPU and never RUNS CUDA. Rather than drag in the NVIDIA userspace stack, synthesize a stub
|
||||||
|
# libcuda.so.1 that just defines those symbols (the SAME approach the Ubuntu image takes with the
|
||||||
|
# real driver lib, minus the driver). On Bazzite the real driver provides libcuda.so.1 at runtime.
|
||||||
|
# The symbol list is `nm -D --undefined-only` of the built host binary; a new cu* call would fail
|
||||||
|
# the link with a clear "undefined reference", flagging this list to update.
|
||||||
|
RUN set -eux; : > /tmp/cuda_stub.c; \
|
||||||
|
for s in cuCtxCreate_v2 cuCtxSetCurrent cuCtxSynchronize cuDestroyExternalMemory \
|
||||||
|
cuDeviceGet cuExternalMemoryGetMappedBuffer cuGraphicsGLRegisterImage \
|
||||||
|
cuGraphicsMapResources cuGraphicsSubResourceGetMappedArray cuGraphicsUnmapResources \
|
||||||
|
cuGraphicsUnregisterResource cuImportExternalMemory cuInit cuMemAllocPitch_v2 \
|
||||||
|
cuMemcpy2D_v2 cuMemFree_v2; do \
|
||||||
|
echo "int $s(void){return 0;}" >> /tmp/cuda_stub.c; \
|
||||||
|
done; \
|
||||||
|
gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib64/libcuda.so.1 /tmp/cuda_stub.c; \
|
||||||
|
ln -sf libcuda.so.1 /usr/lib64/libcuda.so; \
|
||||||
|
rm -f /tmp/cuda_stub.c; ldconfig; test -e /usr/lib64/libcuda.so
|
||||||
|
|
||||||
|
# Rustup (not Fedora's packaged rust) so rust-toolchain.toml's pinned channel resolves, matching
|
||||||
|
# the Ubuntu builder. Shared location so jobs running as any uid can use it.
|
||||||
|
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||||
|
CARGO_HOME=/usr/local/cargo \
|
||||||
|
PATH=/usr/local/cargo/bin:$PATH
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --no-modify-path --profile minimal \
|
||||||
|
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||||
|
&& rustc --version && cargo --version
|
||||||
@@ -20,6 +20,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libpipewire-0.3-dev libopus-dev libwayland-dev libxkbcommon-dev \
|
libpipewire-0.3-dev libopus-dev libwayland-dev libxkbcommon-dev \
|
||||||
# zerocopy link deps (GL via libglvnd, EGL, GBM)
|
# zerocopy link deps (GL via libglvnd, EGL, GBM)
|
||||||
libgl-dev libegl-dev libgbm-dev \
|
libgl-dev libegl-dev libgbm-dev \
|
||||||
|
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
|
||||||
|
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# libcuda link stub: the NVIDIA userspace library (no kernel module needed) provides
|
# libcuda link stub: the NVIDIA userspace library (no kernel module needed) provides
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?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>
|
||||||
|
<!-- Keychain Sharing: a team-scoped access group so the punktfunk/1 client identity in
|
||||||
|
the data-protection keychain is gated by the app's entitlement (team + bundle id),
|
||||||
|
not a per-binary ACL. That is what makes Keychain access persist across rebuilds
|
||||||
|
with NO prompt — see ClientIdentityStore. $(AppIdentifierPrefix) expands to the
|
||||||
|
team prefix at signing time. -->
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastUpgradeCheck = 2650;
|
LastUpgradeCheck = 2700;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
AA0000000000000000000009 = {
|
AA0000000000000000000009 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
@@ -272,6 +272,7 @@
|
|||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -328,6 +329,7 @@
|
|||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = F4H37KF6WC;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -353,13 +355,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_IDENTITY = "-";
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||||
@@ -387,13 +389,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_IDENTITY = "-";
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||||
@@ -421,9 +423,9 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||||
@@ -459,9 +461,9 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||||
@@ -496,9 +498,9 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||||
@@ -525,9 +527,9 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F4H37KF6WC;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Config/Info.plist;
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2650"
|
LastUpgradeVersion = "2700"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2650"
|
LastUpgradeVersion = "2700"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2650"
|
LastUpgradeVersion = "2700"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM),
|
// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM),
|
||||||
// generated once and stored in the login Keychain. The certificate's fingerprint is how
|
// generated once and stored in the data-protection Keychain (with a legacy file-keychain
|
||||||
|
// fallback for unsigned builds — see `query(dataProtection:)`). The certificate's fingerprint is how
|
||||||
// hosts recognize this client after PIN pairing — losing the key un-pairs this Mac from
|
// hosts recognize this client after PIN pairing — losing the key un-pairs this Mac from
|
||||||
// every host, so the pair is presented on every connect but never regenerated once
|
// every host, so the pair is presented on every connect but never regenerated once
|
||||||
// stored. That invariant drives the error handling below: a Keychain that *refuses
|
// stored. That invariant drives the error handling below: a Keychain that *refuses
|
||||||
@@ -42,8 +43,9 @@ final class ClientIdentityStore: @unchecked Sendable {
|
|||||||
break // genuine first run — mint below
|
break // genuine first run — mint below
|
||||||
case .corrupt:
|
case .corrupt:
|
||||||
// Our own item, undecodable: the pairings it backed are unusable either
|
// Our own item, undecodable: the pairings it backed are unusable either
|
||||||
// way, so deliberately self-heal by replacing it.
|
// way, so deliberately self-heal by replacing it (both keychains, best-effort).
|
||||||
SecItemDelete(Self.query as CFDictionary)
|
SecItemDelete(Self.query(dataProtection: true) as CFDictionary)
|
||||||
|
SecItemDelete(Self.query(dataProtection: false) as CFDictionary)
|
||||||
case .denied(let status):
|
case .denied(let status):
|
||||||
throw IdentityError.keychain(status)
|
throw IdentityError.keychain(status)
|
||||||
}
|
}
|
||||||
@@ -89,14 +91,35 @@ final class ClientIdentityStore: @unchecked Sendable {
|
|||||||
case denied(OSStatus)
|
case denied(OSStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let query: [String: Any] = [
|
/// Item coordinates. We prefer the DATA-PROTECTION keychain: with the app's
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
/// `keychain-access-groups` entitlement, items there are gated by the app's identity
|
||||||
kSecAttrService as String: "io.unom.punktfunk",
|
/// (team + bundle id) instead of a per-binary ACL — so a SIGNED build reads them across
|
||||||
kSecAttrAccount as String: "client-identity",
|
/// rebuilds with NO Keychain prompt (a per-binary ACL re-prompts on every resign, which
|
||||||
]
|
/// is why an ad-hoc-signed app asked every launch). An ad-hoc / unsigned build (e.g.
|
||||||
|
/// `swift run`) has no such entitlement — `SecItem*` returns `errSecMissingEntitlement`
|
||||||
|
/// there, and we fall back to the legacy file keychain (still works, with the old prompt).
|
||||||
|
private static func query(dataProtection: Bool) -> [String: Any] {
|
||||||
|
var q: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: "io.unom.punktfunk",
|
||||||
|
kSecAttrAccount as String: "client-identity",
|
||||||
|
]
|
||||||
|
if dataProtection { q[kSecUseDataProtectionKeychain as String] = true }
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
private func copyStored() -> ReadResult {
|
private func copyStored() -> ReadResult {
|
||||||
var query = Self.query
|
let result = read(dataProtection: true)
|
||||||
|
// No entitlement (ad-hoc / unsigned build): the data-protection keychain is
|
||||||
|
// unavailable — read the legacy file keychain instead.
|
||||||
|
if case .denied(errSecMissingEntitlement) = result {
|
||||||
|
return read(dataProtection: false)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func read(dataProtection: Bool) -> ReadResult {
|
||||||
|
var query = Self.query(dataProtection: dataProtection)
|
||||||
query[kSecReturnData as String] = true
|
query[kSecReturnData as String] = true
|
||||||
var out: CFTypeRef?
|
var out: CFTypeRef?
|
||||||
switch SecItemCopyMatching(query as CFDictionary, &out) {
|
switch SecItemCopyMatching(query as CFDictionary, &out) {
|
||||||
@@ -116,8 +139,16 @@ final class ClientIdentityStore: @unchecked Sendable {
|
|||||||
guard let data = try? JSONEncoder().encode(
|
guard let data = try? JSONEncoder().encode(
|
||||||
Stored(certPEM: identity.certPEM, keyPEM: identity.keyPEM))
|
Stored(certPEM: identity.certPEM, keyPEM: identity.keyPEM))
|
||||||
else { return errSecParam }
|
else { return errSecParam }
|
||||||
var add = Self.query
|
var add = Self.query(dataProtection: true)
|
||||||
add[kSecValueData as String] = data
|
add[kSecValueData as String] = data
|
||||||
return SecItemAdd(add as CFDictionary, nil)
|
// After-first-unlock so a background reconnect can still read it; the access-group
|
||||||
|
// entitlement (not a per-binary ACL) gates it, so it survives rebuilds prompt-free.
|
||||||
|
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
let status = SecItemAdd(add as CFDictionary, nil)
|
||||||
|
guard status == errSecMissingEntitlement else { return status }
|
||||||
|
// Ad-hoc / unsigned build: persist to the legacy file keychain instead.
|
||||||
|
var legacy = Self.query(dataProtection: false)
|
||||||
|
legacy[kSecValueData as String] = data
|
||||||
|
return SecItemAdd(legacy as CFDictionary, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ struct HomeView: View {
|
|||||||
private func hostCard(_ host: StoredHost) -> some View {
|
private func hostCard(_ host: StoredHost) -> some View {
|
||||||
HostCardView(
|
HostCardView(
|
||||||
host: host,
|
host: host,
|
||||||
|
isOnline: isOnline(host),
|
||||||
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
isMostRecent: host.id == mostRecentHostID,
|
isMostRecent: host.id == mostRecentHostID,
|
||||||
isBusy: model.isBusy,
|
isBusy: model.isBusy,
|
||||||
@@ -176,12 +177,18 @@ struct HomeView: View {
|
|||||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discovered hosts not already saved (matched by address+port) — the saved grid shows the
|
/// A saved host is "online" iff a live mDNS advert currently matches it (see
|
||||||
/// rest, so this section only surfaces genuinely-new hosts on the network.
|
/// `StoredHost.matches`). Recomputed on every discovery change (the @Published set), so the
|
||||||
|
/// dot tracks hosts appearing/leaving the network live.
|
||||||
|
private func isOnline(_ host: StoredHost) -> Bool {
|
||||||
|
discovery.hosts.contains { host.matches($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discovered hosts not already saved — the saved grid shows the rest, so this section only
|
||||||
|
/// surfaces genuinely-new hosts on the network. Same match as the online dot, so a saved host
|
||||||
|
/// whose IP changed (still fingerprint-matched) doesn't also appear here as a stranger.
|
||||||
private var discoveredUnsaved: [DiscoveredHost] {
|
private var discoveredUnsaved: [DiscoveredHost] {
|
||||||
discovery.hosts.filter { d in
|
discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } }
|
||||||
!store.hosts.contains { $0.address == d.host && $0.port == d.port }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host of the most recent session — its card carries the accent ring.
|
/// The host of the most recent session — its card carries the accent ring.
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ private struct CardMetrics {
|
|||||||
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||||
struct HostCardView: View {
|
struct HostCardView: View {
|
||||||
let host: StoredHost
|
let host: StoredHost
|
||||||
|
/// Currently advertising on the LAN (matched against live mDNS discovery). False means
|
||||||
|
/// "not seen on this network" — off, or a remote/cross-subnet host we can't observe.
|
||||||
|
let isOnline: Bool
|
||||||
let isConnecting: Bool
|
let isConnecting: Bool
|
||||||
let isMostRecent: Bool
|
let isMostRecent: Bool
|
||||||
let isBusy: Bool
|
let isBusy: Bool
|
||||||
@@ -48,9 +51,16 @@ struct HostCardView: View {
|
|||||||
}
|
}
|
||||||
.frame(height: m.iconBox)
|
.frame(height: m.iconBox)
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Text(host.displayName)
|
HStack(spacing: 6) {
|
||||||
.font(m.nameFont)
|
// Presence dot: green = advertising on the LAN now; grey = not seen.
|
||||||
.lineLimit(1)
|
Circle()
|
||||||
|
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
.accessibilityLabel(isOnline ? "Online" : "Offline")
|
||||||
|
Text(host.displayName)
|
||||||
|
.font(m.nameFont)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
if host.pinnedSHA256 != nil {
|
if host.pinnedSHA256 != nil {
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
|
|||||||
@@ -25,6 +25,26 @@ struct StoredHost: Identifiable, Codable, Hashable {
|
|||||||
var displayName: String { name.isEmpty ? address : name }
|
var displayName: String { name.isEmpty ? address : name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension StoredHost {
|
||||||
|
/// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host — drives the
|
||||||
|
/// "online" indicator and de-dupes the discovered section. Matched by certificate
|
||||||
|
/// fingerprint when both sides carry it (so it survives a DHCP address change), otherwise
|
||||||
|
/// by address:port. Online detection is LAN-scoped: a host not advertising on this network
|
||||||
|
/// (off, or a remote/cross-subnet address) simply won't match — "not seen", not proven off.
|
||||||
|
func matches(_ discovered: DiscoveredHost) -> Bool {
|
||||||
|
if let pin = pinnedSHA256, let fp = discovered.fingerprintHex,
|
||||||
|
pin.hexLower == fp.lowercased() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return address == discovered.host && port == discovered.port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
/// Lowercase hex, no separators — to compare a pinned fingerprint against the mDNS `fp`.
|
||||||
|
var hexLower: String { map { String(format: "%02x", $0) }.joined() }
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class HostStore: ObservableObject {
|
final class HostStore: ObservableObject {
|
||||||
private static let key = DefaultsKey.hosts
|
private static let key = DefaultsKey.hosts
|
||||||
|
|||||||
@@ -80,6 +80,19 @@ public final class MetalVideoPresenter {
|
|||||||
layer.pixelFormat = .bgra8Unorm
|
layer.pixelFormat = .bgra8Unorm
|
||||||
layer.framebufferOnly = true
|
layer.framebufferOnly = true
|
||||||
layer.isOpaque = true
|
layer.isOpaque = true
|
||||||
|
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the
|
||||||
|
// display-link / MAIN thread) has to block waiting for one to free.
|
||||||
|
layer.maximumDrawableCount = 3
|
||||||
|
#if os(macOS)
|
||||||
|
// The display link already paces exactly one present per vsync. Leaving the layer's
|
||||||
|
// own vsync wait on means `commandBuffer.present` ALSO blocks for the hardware vsync,
|
||||||
|
// so `nextDrawable()` stalls the MAIN thread until a drawable frees — windowed, the
|
||||||
|
// WindowServer's looser compositing hides it; FULLSCREEN's tighter, more-direct path
|
||||||
|
// serializes the main thread to the display and the stall surfaces as bad judder.
|
||||||
|
// Disabling the layer-level sync lets present return promptly (the display link is the
|
||||||
|
// pacing source), which is what fixes the fullscreen stutter. macOS-only property.
|
||||||
|
layer.displaySyncEnabled = false
|
||||||
|
#endif
|
||||||
self.layer = layer
|
self.layer = layer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,21 @@ public final class PunktfunkConnection {
|
|||||||
_ = punktfunk_connection_request_mode(h, width, height, refreshHz)
|
_ = punktfunk_connection_request_mode(h, width, height, refreshHz)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the host's encoder to emit a fresh IDR keyframe now — recovery when the local
|
||||||
|
/// decoder has wedged. The host opens the infinite-GOP stream with one IDR and then sends
|
||||||
|
/// P-frames only, so a stalled decode (a lost/corrupt opening IDR, a bad early P-frame —
|
||||||
|
/// most likely on the cold first connect) would otherwise stay frozen until the next
|
||||||
|
/// loss-triggered recovery keyframe, which may be far off. Fire-and-forget; the recovered
|
||||||
|
/// keyframe is the only ack. THROTTLE at the call site — the decode stays wedged for
|
||||||
|
/// several frames until the IDR lands, so requesting every frame would flood the control
|
||||||
|
/// stream. Silently dropped after close.
|
||||||
|
public func requestKeyframe() {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
guard let h = handle, !closeRequested else { return }
|
||||||
|
_ = punktfunk_connection_request_keyframe(h)
|
||||||
|
}
|
||||||
|
|
||||||
/// The currently active session mode (updated by accepted `requestMode` switches).
|
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||||
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||||
abiLock.lock()
|
abiLock.lock()
|
||||||
|
|||||||
@@ -44,11 +44,36 @@ private final class PumpToken: @unchecked Sendable {
|
|||||||
func cancel() { lock.lock(); live = false; lock.unlock() }
|
func cancel() { lock.lock(); live = false; lock.unlock() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Throttled host keyframe requests for decode recovery. The decoder's async error callback
|
||||||
|
/// (a VT thread) and the pump thread (a submit failure) both signal a wedge; this coalesces
|
||||||
|
/// them so the control stream isn't flooded while the decode stays stalled for several frames
|
||||||
|
/// until the requested IDR lands. Bound to the live connection in `start`, unbound in `stop`.
|
||||||
|
private final class KeyframeRecovery: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var connection: PunktfunkConnection?
|
||||||
|
private var lastNs: UInt64 = 0
|
||||||
|
|
||||||
|
func bind(_ c: PunktfunkConnection?) {
|
||||||
|
lock.lock(); connection = c; lastNs = 0; lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func request() {
|
||||||
|
lock.lock()
|
||||||
|
let now = DispatchTime.now().uptimeNanoseconds
|
||||||
|
let due = lastNs == 0 || now &- lastNs > 250_000_000 // ≥ 250 ms since the last request
|
||||||
|
if due { lastNs = now }
|
||||||
|
let conn = due ? connection : nil
|
||||||
|
lock.unlock()
|
||||||
|
conn?.requestKeyframe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public final class Stage2Pipeline {
|
public final class Stage2Pipeline {
|
||||||
private let ring = ReadyRing()
|
private let ring = ReadyRing()
|
||||||
private let presenter: MetalVideoPresenter
|
private let presenter: MetalVideoPresenter
|
||||||
private let decoder: VideoDecoder
|
private let decoder: VideoDecoder
|
||||||
private let presentMeter: LatencyMeter
|
private let presentMeter: LatencyMeter
|
||||||
|
private let recovery = KeyframeRecovery()
|
||||||
private var token = PumpToken()
|
private var token = PumpToken()
|
||||||
private var offsetNs: Int64 = 0
|
private var offsetNs: Int64 = 0
|
||||||
|
|
||||||
@@ -63,9 +88,13 @@ public final class Stage2Pipeline {
|
|||||||
self.presenter = presenter
|
self.presenter = presenter
|
||||||
self.presentMeter = presentMeter
|
self.presentMeter = presentMeter
|
||||||
let ring = ring
|
let ring = ring
|
||||||
|
let recovery = recovery
|
||||||
self.decoder = VideoDecoder(
|
self.decoder = VideoDecoder(
|
||||||
onDecoded: { ring.submit($0) },
|
onDecoded: { ring.submit($0) },
|
||||||
onDecodeError: { _ in /* the pump resets the session via reset() on the next IDR */ })
|
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump
|
||||||
|
// resets to re-gate on the next IDR, and we ask the host to send one now (infinite
|
||||||
|
// GOP — it wouldn't otherwise come soon). Throttled in KeyframeRecovery.
|
||||||
|
onDecodeError: { _ in recovery.request() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start pulling AUs into the decoder. `onFrame` fires per AU at receipt (capture→client
|
/// Start pulling AUs into the decoder. `onFrame` fires per AU at receipt (capture→client
|
||||||
@@ -77,9 +106,11 @@ public final class Stage2Pipeline {
|
|||||||
onSessionEnd: (@Sendable () -> Void)?
|
onSessionEnd: (@Sendable () -> Void)?
|
||||||
) {
|
) {
|
||||||
offsetNs = connection.clockOffsetNs
|
offsetNs = connection.clockOffsetNs
|
||||||
|
recovery.bind(connection) // arm host-keyframe recovery for this session
|
||||||
token = PumpToken() // fresh token per start — cancel is permanent (like StreamPump)
|
token = PumpToken() // fresh token per start — cancel is permanent (like StreamPump)
|
||||||
let token = token
|
let token = token
|
||||||
let decoder = decoder
|
let decoder = decoder
|
||||||
|
let recovery = recovery
|
||||||
let thread = Thread {
|
let thread = Thread {
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
while token.isLive {
|
while token.isLive {
|
||||||
@@ -92,8 +123,10 @@ public final class Stage2Pipeline {
|
|||||||
guard let f = format, token.isLive else { continue }
|
guard let f = format, token.isLive else { continue }
|
||||||
if !decoder.decode(au: au, format: f) {
|
if !decoder.decode(au: au, format: f) {
|
||||||
// Submit/decoder error: drop the session and re-gate on the next IDR's
|
// Submit/decoder error: drop the session and re-gate on the next IDR's
|
||||||
// in-band parameter sets (a delta frame can't recover) — stage-1's policy.
|
// in-band parameter sets (a delta frame can't recover) — stage-1's policy
|
||||||
|
// — and ask the host for that IDR now (infinite GOP; throttled).
|
||||||
decoder.reset()
|
decoder.reset()
|
||||||
|
recovery.request()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if token.isLive { onSessionEnd?() }
|
if token.isLive { onSessionEnd?() }
|
||||||
@@ -125,6 +158,7 @@ public final class Stage2Pipeline {
|
|||||||
public func stop() {
|
public func stop() {
|
||||||
token.cancel()
|
token.cancel()
|
||||||
decoder.reset()
|
decoder.reset()
|
||||||
|
recovery.bind(nil) // stop requesting keyframes once the session is torn down
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit { token.cancel() }
|
deinit { token.cancel() }
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ final class StreamPump {
|
|||||||
|
|
||||||
let thread = Thread {
|
let thread = Thread {
|
||||||
var format: CMVideoFormatDescription?
|
var format: CMVideoFormatDescription?
|
||||||
|
var lastKeyframeRequest = Date.distantPast
|
||||||
while token.isLive {
|
while token.isLive {
|
||||||
do {
|
do {
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
||||||
@@ -49,13 +50,19 @@ final class StreamPump {
|
|||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
}
|
}
|
||||||
if layer.status == .failed {
|
if layer.status == .failed {
|
||||||
// Decode wedged: flush and re-gate on the next in-band parameter
|
// Decode wedged: flush and re-gate on the next in-band parameter sets
|
||||||
// sets — resuming with a delta frame can't recover. (A
|
// (resuming with a delta frame can't recover), AND ask the host for a
|
||||||
// request-IDR channel on punktfunk/1 is a host-side TODO; with the
|
// fresh IDR. With the host's infinite GOP the next keyframe could be
|
||||||
// host's infinite GOP this may otherwise stay black until the
|
// far off, so without the request the picture stays frozen — the
|
||||||
// next recovery keyframe.)
|
// intermittent first-connect freeze. Throttled: the layer stays .failed
|
||||||
|
// across several polls until the IDR lands, and one request suffices.
|
||||||
layer.flush()
|
layer.flush()
|
||||||
format = AnnexB.formatDescription(fromIDR: au.data)
|
format = AnnexB.formatDescription(fromIDR: au.data)
|
||||||
|
let now = Date()
|
||||||
|
if now.timeIntervalSince(lastKeyframeRequest) > 0.25 {
|
||||||
|
connection.requestKeyframe()
|
||||||
|
lastKeyframeRequest = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard let f = format,
|
guard let f = format,
|
||||||
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
let sample = AnnexB.sampleBuffer(au: au, format: f),
|
||||||
|
|||||||
@@ -197,6 +197,17 @@ public final class StreamLayerView: NSView {
|
|||||||
self?.releaseCapture()
|
self?.releaseCapture()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Becoming key RETRIES a still-pending session-start auto-capture — the case where a
|
||||||
|
// session began (reconnect) while this window wasn't key yet, so engageCapture(fromClick:
|
||||||
|
// false) was refused by its key-window guard and, with no retry, capture stayed off and
|
||||||
|
// input dead. This is a no-op once capture engaged (pendingAutoCapture is cleared) and
|
||||||
|
// after a manual ⌘⎋/focus-loss release (the flag is already false), so it does NOT
|
||||||
|
// resurrect the deliberately-rejected "auto-grab on every activation" behavior.
|
||||||
|
windowObservers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.attemptPendingCapture()
|
||||||
|
})
|
||||||
attemptPendingCapture()
|
attemptPendingCapture()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,8 +312,13 @@ public final class StreamLayerView: NSView {
|
|||||||
|
|
||||||
private func attemptPendingCapture() {
|
private func attemptPendingCapture() {
|
||||||
guard pendingAutoCapture, window != nil, bounds.width > 0 else { return }
|
guard pendingAutoCapture, window != nil, bounds.width > 0 else { return }
|
||||||
pendingAutoCapture = false // one shot, even if the engage below is refused
|
|
||||||
engageCapture(fromClick: false)
|
engageCapture(fromClick: false)
|
||||||
|
// Clear the one-shot only once it ACTUALLY engaged. If the engage was refused — the
|
||||||
|
// app/window isn't key yet (common right after a reconnect), or the cursor grab raced
|
||||||
|
// app activation — leave it armed so didBecomeKey (or the next layout pass) retries.
|
||||||
|
// This stays scoped to session start: a later manual release (⌘⎋, focus loss) doesn't
|
||||||
|
// re-arm it, so it never resurrects auto-grab-on-activation.
|
||||||
|
if captured { pendingAutoCapture = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func engageCapture(fromClick: Bool) {
|
private func engageCapture(fromClick: Bool) {
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ public final class StreamViewController: UIViewController {
|
|||||||
self.connection = connection
|
self.connection = connection
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
// Fresh session: drop any resign/foreground capture-restore state left over from a
|
||||||
|
// prior session (stop() doesn't clear it). Otherwise a stale `true` could later
|
||||||
|
// re-engage capture on a foreground that the new session never asked for.
|
||||||
|
wasCapturedOnResign = false
|
||||||
// Read the LIVE mode per touch batch — an accepted requestMode() mid-stream
|
// Read the LIVE mode per touch batch — an accepted requestMode() mid-stream
|
||||||
// changes the letterbox, and touches must follow it.
|
// changes the letterbox, and touches must follow it.
|
||||||
streamView.currentHostMode = { [weak connection] in
|
streamView.currentHostMode = { [weak connection] in
|
||||||
@@ -247,7 +251,10 @@ public final class StreamViewController: UIViewController {
|
|||||||
observers.append(NotificationCenter.default.addObserver(
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
guard let self, self.wasCapturedOnResign, self.captureEnabled, self.connection != nil
|
// inputCapture != nil: don't try to restore before this session's capture is wired
|
||||||
|
// up — setForwarding would silently no-op on the nil handlers and leave input dead.
|
||||||
|
guard let self, self.wasCapturedOnResign, self.captureEnabled,
|
||||||
|
self.connection != nil, self.inputCapture != nil
|
||||||
else { return }
|
else { return }
|
||||||
self.setCaptured(true)
|
self.setCaptured(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
[package]
|
||||||
|
name = "punktfunk-client-linux"
|
||||||
|
description = "Native Linux punktfunk/1 client — GTK4/libadwaita shell, FFmpeg decode, PipeWire audio, SDL3 gamepads"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "punktfunk-client"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# Everything is Linux-gated so `cargo build --workspace` stays green on macOS (the Mac
|
||||||
|
# client lives in clients/apple); on other platforms this builds as a stub binary.
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
|
||||||
|
|
||||||
|
# UI shell. GraphicsOffload needs GTK ≥ 4.14; black-background ≥ 4.16. AlertDialog/
|
||||||
|
# PreferencesDialog need libadwaita ≥ 1.5.
|
||||||
|
gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] }
|
||||||
|
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||||
|
async-channel = "2"
|
||||||
|
|
||||||
|
# Video decode (same FFmpeg pin as the host) and audio.
|
||||||
|
ffmpeg-next = "8"
|
||||||
|
opus = "0.3"
|
||||||
|
pipewire = "0.9"
|
||||||
|
|
||||||
|
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
|
||||||
|
# need the hidapi driver).
|
||||||
|
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||||
|
|
||||||
|
mdns-sd = "0.20"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
//! The application shell: window, navigation, trust dialogs, session lifecycle.
|
||||||
|
|
||||||
|
use crate::session::{SessionEvent, SessionParams};
|
||||||
|
use crate::trust::{KnownHost, KnownHosts, Settings};
|
||||||
|
use crate::ui_hosts::ConnectRequest;
|
||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::{gdk, glib};
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::config::{CompositorPref, GamepadPref};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
const APP_ID: &str = "io.unom.Punktfunk";
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
window: adw::ApplicationWindow,
|
||||||
|
nav: adw::NavigationView,
|
||||||
|
toasts: adw::ToastOverlay,
|
||||||
|
settings: Rc<RefCell<Settings>>,
|
||||||
|
identity: (String, String),
|
||||||
|
/// App-lifetime SDL gamepad service: Settings list + per-session capture/feedback.
|
||||||
|
gamepad: crate::gamepad::GamepadService,
|
||||||
|
/// One session at a time — ignore connects while one is starting/running.
|
||||||
|
busy: std::cell::Cell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn toast(&self, msg: &str) {
|
||||||
|
self.toasts.add_toast(adw::Toast::new(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run() -> glib::ExitCode {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||||
|
app.connect_activate(build_ui);
|
||||||
|
// GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also
|
||||||
|
// keeps GApplication from rejecting unknown options.
|
||||||
|
app.run_with_args(&[] as &[&str])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--connect host[:port]` — skip the hosts page and start a session immediately
|
||||||
|
/// (scripting + headless testing; trust follows the same known-hosts/TOFU rules).
|
||||||
|
fn cli_connect_request() -> Option<ConnectRequest> {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let target = args
|
||||||
|
.iter()
|
||||||
|
.skip_while(|a| *a != "--connect")
|
||||||
|
.nth(1)?
|
||||||
|
.clone();
|
||||||
|
let (addr, port) = match target.rsplit_once(':') {
|
||||||
|
Some((a, p)) => (a.to_string(), p.parse().ok()?),
|
||||||
|
None => (target.clone(), 9777),
|
||||||
|
};
|
||||||
|
Some(ConnectRequest {
|
||||||
|
name: addr.clone(),
|
||||||
|
addr,
|
||||||
|
port,
|
||||||
|
fp_hex: None,
|
||||||
|
pair_required: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_ui(gtk_app: &adw::Application) {
|
||||||
|
let identity = match crate::trust::load_or_create_identity() {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("client identity: {e:#}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let nav = adw::NavigationView::new();
|
||||||
|
let toasts = adw::ToastOverlay::new();
|
||||||
|
toasts.set_child(Some(&nav));
|
||||||
|
let window = adw::ApplicationWindow::builder()
|
||||||
|
.application(gtk_app)
|
||||||
|
.title("Punktfunk")
|
||||||
|
.default_width(1100)
|
||||||
|
.default_height(720)
|
||||||
|
.content(&toasts)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let app = Rc::new(App {
|
||||||
|
window: window.clone(),
|
||||||
|
nav: nav.clone(),
|
||||||
|
toasts,
|
||||||
|
settings: Rc::new(RefCell::new(Settings::load())),
|
||||||
|
identity,
|
||||||
|
gamepad: crate::gamepad::GamepadService::start(),
|
||||||
|
busy: std::cell::Cell::new(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
let hosts_page = crate::ui_hosts::new(
|
||||||
|
{
|
||||||
|
let app = app.clone();
|
||||||
|
Rc::new(move |req| initiate_connect(app.clone(), req))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let app = app.clone();
|
||||||
|
Rc::new(move || {
|
||||||
|
crate::ui_settings::show(&app.window, app.settings.clone(), &app.gamepad)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let app = app.clone();
|
||||||
|
Rc::new(move |req| speed_test(app.clone(), req))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
nav.add(&hosts_page);
|
||||||
|
window.present();
|
||||||
|
|
||||||
|
if let Some(req) = cli_connect_request() {
|
||||||
|
initiate_connect(app, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trust gate in front of every connect. Discovered hosts carry their fingerprint in
|
||||||
|
/// the mDNS advert, so trust is decided *before* any traffic: known → pinned connect;
|
||||||
|
/// unknown → TOFU prompt (or straight to pairing when the host requires it). Manual
|
||||||
|
/// entries have no advance fingerprint: trust on first use, pin from then on.
|
||||||
|
fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
if app.busy.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let known = KnownHosts::load();
|
||||||
|
match &req.fp_hex {
|
||||||
|
Some(fp_hex) => {
|
||||||
|
if known.find_by_fp(fp_hex).is_some() {
|
||||||
|
start_session(app, req.clone(), crate::trust::parse_hex32(fp_hex));
|
||||||
|
} else if req.pair_required {
|
||||||
|
// TOFU alone won't pass the host's gate — go straight to the ceremony.
|
||||||
|
pin_dialog(app, req);
|
||||||
|
} else {
|
||||||
|
tofu_dialog(app, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let pin = known
|
||||||
|
.find_by_addr(&req.addr, req.port)
|
||||||
|
.and_then(|k| crate::trust::parse_hex32(&k.fp_hex));
|
||||||
|
start_session(app, req, pin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First contact with a discovered host: show the advertised fingerprint and let the user
|
||||||
|
/// trust it (TOFU), run the PIN ceremony instead, or walk away.
|
||||||
|
fn tofu_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
let fp = req.fp_hex.clone().unwrap_or_default();
|
||||||
|
let dialog = adw::AlertDialog::new(
|
||||||
|
Some("New Host"),
|
||||||
|
Some(&format!(
|
||||||
|
"{} at {}:{}\n\nCertificate fingerprint:\n{}\n\nPairing with a PIN verifies it; \
|
||||||
|
trusting accepts it as-is.",
|
||||||
|
req.name, req.addr, req.port, fp
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
dialog.add_responses(&[
|
||||||
|
("cancel", "Cancel"),
|
||||||
|
("pair", "Pair with PIN…"),
|
||||||
|
("trust", "Trust & Connect"),
|
||||||
|
]);
|
||||||
|
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("trust"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let parent = app.window.clone();
|
||||||
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
|
"trust" => {
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
known.upsert(KnownHost {
|
||||||
|
name: req.name.clone(),
|
||||||
|
addr: req.addr.clone(),
|
||||||
|
port: req.port,
|
||||||
|
fp_hex: fp.clone(),
|
||||||
|
paired: false,
|
||||||
|
});
|
||||||
|
let _ = known.save();
|
||||||
|
start_session(app.clone(), req.clone(), crate::trust::parse_hex32(&fp));
|
||||||
|
}
|
||||||
|
"pair" => pin_dialog(app.clone(), req.clone()),
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
dialog.present(Some(&parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
|
||||||
|
/// of it pins the host's certificate (and registers ours) with no offline-guessable
|
||||||
|
/// transcript.
|
||||||
|
fn pin_dialog(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
let entry = gtk::Entry::builder()
|
||||||
|
.input_purpose(gtk::InputPurpose::Digits)
|
||||||
|
.placeholder_text("4-digit PIN shown by the host")
|
||||||
|
.activates_default(true)
|
||||||
|
.build();
|
||||||
|
let dialog = adw::AlertDialog::new(
|
||||||
|
Some("Pair with PIN"),
|
||||||
|
Some(&format!(
|
||||||
|
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
|
||||||
|
req.name
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
dialog.set_extra_child(Some(&entry));
|
||||||
|
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
|
||||||
|
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("pair"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let parent = app.window.clone();
|
||||||
|
dialog.connect_response(Some("pair"), move |_, _| {
|
||||||
|
let pin = entry.text().to_string();
|
||||||
|
let app = app.clone();
|
||||||
|
let req = req.clone();
|
||||||
|
let identity = app.identity.clone();
|
||||||
|
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
||||||
|
let (host, port, name) = (req.addr.clone(), req.port, glib::host_name().to_string());
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = NativeClient::pair(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
(&identity.0, &identity.1),
|
||||||
|
pin.trim(),
|
||||||
|
&name,
|
||||||
|
std::time::Duration::from_secs(90),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)"));
|
||||||
|
let _ = tx.send_blocking(result);
|
||||||
|
});
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(fp)) => {
|
||||||
|
let fp_hex = crate::trust::hex(&fp);
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
known.upsert(KnownHost {
|
||||||
|
name: req.name.clone(),
|
||||||
|
addr: req.addr.clone(),
|
||||||
|
port: req.port,
|
||||||
|
fp_hex,
|
||||||
|
paired: true,
|
||||||
|
});
|
||||||
|
let _ = known.save();
|
||||||
|
app.toast("Paired — connecting…");
|
||||||
|
start_session(app.clone(), req, Some(fp));
|
||||||
|
}
|
||||||
|
Ok(Err(msg)) => app.toast(&msg),
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dialog.present(Some(&parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure the path to a host over the real data plane (Swift's "Test Network Speed…"):
|
||||||
|
/// connect, have the host burst probe filler for 2 s up to its 3 Gbps ceiling, report
|
||||||
|
/// goodput · loss · a recommended bitrate (≈70 % of measured), and apply it in one tap.
|
||||||
|
fn speed_test(app: Rc<App>, req: ConnectRequest) {
|
||||||
|
if app.busy.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pin = req.fp_hex.as_deref().and_then(crate::trust::parse_hex32);
|
||||||
|
let status = gtk::Label::new(Some("Connecting…"));
|
||||||
|
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
|
||||||
|
dialog.set_extra_child(Some(&status));
|
||||||
|
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
|
||||||
|
dialog.set_response_enabled("apply", false);
|
||||||
|
dialog.set_close_response("close");
|
||||||
|
dialog.present(Some(&app.window));
|
||||||
|
|
||||||
|
let (tx, rx) =
|
||||||
|
async_channel::bounded::<Result<punktfunk_core::client::ProbeOutcome, String>>(1);
|
||||||
|
let identity = app.identity.clone();
|
||||||
|
let (host, port) = (req.addr.clone(), req.port);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = (|| {
|
||||||
|
let c = NativeClient::connect(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
punktfunk_core::config::Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
CompositorPref::Auto,
|
||||||
|
GamepadPref::Auto,
|
||||||
|
0,
|
||||||
|
pin,
|
||||||
|
Some(identity),
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("connect: {e:?}"))?;
|
||||||
|
c.request_probe(3_000_000, 2_000)
|
||||||
|
.map_err(|e| format!("probe: {e:?}"))?;
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
|
let r = c.probe_result();
|
||||||
|
if r.done {
|
||||||
|
// Let the last UDP shards land before tearing down.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||||
|
return Ok(c.probe_result());
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() > deadline {
|
||||||
|
return Err("probe timed out".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
let _ = tx.send_blocking(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let outcome = rx.recv().await;
|
||||||
|
app.busy.set(false);
|
||||||
|
match outcome {
|
||||||
|
Ok(Ok(r)) => {
|
||||||
|
let mbps = f64::from(r.throughput_kbps) / 1000.0;
|
||||||
|
let recommended_kbps = r.throughput_kbps / 10 * 7;
|
||||||
|
status.set_text(&format!(
|
||||||
|
"{mbps:.0} Mbit/s measured · {:.1} % loss\nRecommended bitrate: {:.0} Mbit/s",
|
||||||
|
r.loss_pct,
|
||||||
|
f64::from(recommended_kbps) / 1000.0,
|
||||||
|
));
|
||||||
|
dialog.set_response_enabled("apply", true);
|
||||||
|
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
|
||||||
|
let settings = app.settings.clone();
|
||||||
|
let toasts = app.toasts.clone();
|
||||||
|
dialog.connect_response(Some("apply"), move |_, _| {
|
||||||
|
let mut s = settings.borrow_mut();
|
||||||
|
s.bitrate_kbps = recommended_kbps;
|
||||||
|
s.save();
|
||||||
|
toasts.add_toast(adw::Toast::new(&format!(
|
||||||
|
"Bitrate set to {:.0} Mbit/s",
|
||||||
|
f64::from(recommended_kbps) / 1000.0
|
||||||
|
)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Err(msg)) => status.set_text(&msg),
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mode to request: explicit settings, with `0` fields resolved to the native
|
||||||
|
/// size/refresh of the monitor the window currently occupies (mirrors the Swift client's
|
||||||
|
/// native-display default).
|
||||||
|
fn resolve_mode(app: &App) -> punktfunk_core::config::Mode {
|
||||||
|
let s = app.settings.borrow();
|
||||||
|
let mut mode = punktfunk_core::config::Mode {
|
||||||
|
width: s.width,
|
||||||
|
height: s.height,
|
||||||
|
refresh_hz: s.refresh_hz,
|
||||||
|
};
|
||||||
|
if mode.width == 0 || mode.refresh_hz == 0 {
|
||||||
|
let monitor = app
|
||||||
|
.window
|
||||||
|
.surface()
|
||||||
|
.zip(gdk::Display::default())
|
||||||
|
.and_then(|(surf, d)| d.monitor_at_surface(&surf));
|
||||||
|
if let Some(m) = monitor {
|
||||||
|
let geo = m.geometry();
|
||||||
|
let scale = m.scale_factor().max(1);
|
||||||
|
if mode.width == 0 {
|
||||||
|
mode.width = (geo.width() * scale) as u32;
|
||||||
|
mode.height = (geo.height() * scale) as u32;
|
||||||
|
}
|
||||||
|
if mode.refresh_hz == 0 {
|
||||||
|
mode.refresh_hz = ((m.refresh_rate() + 500) / 1000).max(30) as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No monitor info (early call, odd compositor) — a sane floor.
|
||||||
|
if mode.width == 0 {
|
||||||
|
(mode.width, mode.height) = (1920, 1080);
|
||||||
|
}
|
||||||
|
if mode.refresh_hz == 0 {
|
||||||
|
mode.refresh_hz = 60;
|
||||||
|
}
|
||||||
|
mode
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_session(app: Rc<App>, req: ConnectRequest, pin: Option<[u8; 32]>) {
|
||||||
|
if app.busy.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mode = resolve_mode(&app);
|
||||||
|
let s = app.settings.borrow();
|
||||||
|
let params = SessionParams {
|
||||||
|
host: req.addr.clone(),
|
||||||
|
port: req.port,
|
||||||
|
mode,
|
||||||
|
compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
|
||||||
|
// "Automatic" matches the physical pad (Swift parity); an explicit choice wins.
|
||||||
|
gamepad: match GamepadPref::from_name(&s.gamepad) {
|
||||||
|
Some(GamepadPref::Auto) | None => app.gamepad.auto_pref(),
|
||||||
|
Some(explicit) => explicit,
|
||||||
|
},
|
||||||
|
bitrate_kbps: s.bitrate_kbps,
|
||||||
|
mic_enabled: s.mic_enabled,
|
||||||
|
pin,
|
||||||
|
identity: app.identity.clone(),
|
||||||
|
};
|
||||||
|
let inhibit = s.inhibit_shortcuts;
|
||||||
|
drop(s);
|
||||||
|
let tofu = pin.is_none();
|
||||||
|
|
||||||
|
let mut handle = crate::session::start(params);
|
||||||
|
let frames = std::mem::replace(&mut handle.frames, async_channel::bounded(1).1);
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let mut frames = Some(frames);
|
||||||
|
let mut page: Option<crate::ui_stream::StreamPage> = None;
|
||||||
|
while let Ok(event) = handle.events.recv().await {
|
||||||
|
match event {
|
||||||
|
SessionEvent::Connected {
|
||||||
|
connector,
|
||||||
|
mode,
|
||||||
|
fingerprint,
|
||||||
|
} => {
|
||||||
|
// A TOFU connect just observed the real fingerprint — pin it from now on.
|
||||||
|
if tofu {
|
||||||
|
let fp_hex = crate::trust::hex(&fingerprint);
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
known.upsert(KnownHost {
|
||||||
|
name: req.name.clone(),
|
||||||
|
addr: req.addr.clone(),
|
||||||
|
port: req.port,
|
||||||
|
fp_hex: fp_hex.clone(),
|
||||||
|
paired: false,
|
||||||
|
});
|
||||||
|
let _ = known.save();
|
||||||
|
app.toast(&format!(
|
||||||
|
"Trusted on first use — fingerprint {}…",
|
||||||
|
&fp_hex[..16]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tracing::debug!(?mode, "connected — pushing stream page");
|
||||||
|
let title = format!(
|
||||||
|
"{} · {}×{}@{}",
|
||||||
|
req.name, mode.width, mode.height, mode.refresh_hz
|
||||||
|
);
|
||||||
|
app.gamepad.attach(connector.clone());
|
||||||
|
let p = crate::ui_stream::new(
|
||||||
|
&app.window,
|
||||||
|
connector,
|
||||||
|
frames.take().expect("Connected delivered once"),
|
||||||
|
handle.stop.clone(),
|
||||||
|
inhibit,
|
||||||
|
&title,
|
||||||
|
);
|
||||||
|
app.nav.push(&p.page);
|
||||||
|
page = Some(p);
|
||||||
|
}
|
||||||
|
SessionEvent::Stats(s) => {
|
||||||
|
if let Some(p) = &page {
|
||||||
|
p.update_stats(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SessionEvent::Failed(msg) => {
|
||||||
|
tracing::warn!(%msg, "connect failed");
|
||||||
|
app.toast(&msg);
|
||||||
|
app.busy.set(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
SessionEvent::Ended(err) => {
|
||||||
|
app.gamepad.detach();
|
||||||
|
app.nav.pop_to_tag("hosts");
|
||||||
|
if let Some(e) = err {
|
||||||
|
app.toast(&e);
|
||||||
|
}
|
||||||
|
app.busy.set(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
//! Audio: playback (decoded PCM → a PipeWire playback stream) and the microphone uplink
|
||||||
|
//! (PipeWire capture → Opus → 0xCB datagrams, the inverse of the host's virtual mic).
|
||||||
|
//!
|
||||||
|
//! Playback mirrors the host's virtual-mic producer (`punktfunk-host::audio::linux`) with
|
||||||
|
//! the same adaptive jitter buffer: the session pump pushes 5 ms Opus-decoded chunks on
|
||||||
|
//! the network clock; PipeWire pulls whole quanta on the device clock. Prime to ~3
|
||||||
|
//! quanta before producing, cap the ring so latency stays bounded, re-prime after a real
|
||||||
|
//! drain.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::mpsc::{Receiver, SyncSender, TrySendError};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
|
const CHANNELS: usize = 2;
|
||||||
|
/// Mic frames are 20 ms (960 samples/channel) — any size ≤ 120 ms is fine host-side.
|
||||||
|
const MIC_FRAME: usize = 960;
|
||||||
|
|
||||||
|
struct Terminate;
|
||||||
|
|
||||||
|
pub struct AudioPlayer {
|
||||||
|
pcm_tx: SyncSender<Vec<f32>>,
|
||||||
|
quit_tx: pipewire::channel::Sender<Terminate>,
|
||||||
|
thread: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioPlayer {
|
||||||
|
/// Spawn the PipeWire playback thread. Failure (no PipeWire in the session) is
|
||||||
|
/// survivable — the caller streams video-only.
|
||||||
|
pub fn spawn() -> Result<AudioPlayer> {
|
||||||
|
// 64 × 5 ms = 320 ms of slack between the pump and the PipeWire loop.
|
||||||
|
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||||
|
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||||
|
let thread = std::thread::Builder::new()
|
||||||
|
.name("punktfunk-audio".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(e) = pw_thread(pcm_rx, quit_rx) {
|
||||||
|
tracing::warn!(error = %e, "audio playback thread ended");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.context("spawn audio thread")?;
|
||||||
|
Ok(AudioPlayer {
|
||||||
|
pcm_tx,
|
||||||
|
quit_tx,
|
||||||
|
thread: Some(thread),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue one interleaved-stereo f32 chunk. Drops the chunk if the PipeWire side is
|
||||||
|
/// wedged (the renderer conceals the gap; never block the session pump).
|
||||||
|
pub fn push(&self, pcm: Vec<f32>) {
|
||||||
|
if let Err(TrySendError::Disconnected(_)) = self.pcm_tx.try_send(pcm) {
|
||||||
|
// Thread already dead — Drop will reap it; nothing to do per-chunk.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AudioPlayer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.quit_tx.send(Terminate);
|
||||||
|
if let Some(t) = self.thread.take() {
|
||||||
|
let _ = t.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Producer-side state: incoming decoded PCM and the ring the process callback drains.
|
||||||
|
struct PlayerData {
|
||||||
|
rx: Receiver<Vec<f32>>,
|
||||||
|
ring: VecDeque<f32>,
|
||||||
|
primed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pw_thread(
|
||||||
|
pcm_rx: Receiver<Vec<f32>>,
|
||||||
|
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use pipewire as pw;
|
||||||
|
use pw::{properties::properties, spa};
|
||||||
|
use spa::param::audio::{AudioFormat, AudioInfoRaw};
|
||||||
|
use spa::pod::Pod;
|
||||||
|
|
||||||
|
static PW_INIT: std::sync::Once = std::sync::Once::new();
|
||||||
|
PW_INIT.call_once(pw::init);
|
||||||
|
|
||||||
|
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
|
||||||
|
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
|
||||||
|
let core = context
|
||||||
|
.connect_rc(None)
|
||||||
|
.context("pw connect (is PipeWire running in this session?)")?;
|
||||||
|
|
||||||
|
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
|
||||||
|
let mainloop = mainloop.clone();
|
||||||
|
move |_| mainloop.quit()
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = pw::stream::StreamBox::new(
|
||||||
|
&core,
|
||||||
|
"punktfunk-client",
|
||||||
|
properties! {
|
||||||
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
|
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||||
|
*pw::keys::MEDIA_ROLE => "Game",
|
||||||
|
*pw::keys::NODE_NAME => "punktfunk-client",
|
||||||
|
*pw::keys::NODE_DESCRIPTION => "Punktfunk Stream",
|
||||||
|
// ~5 ms quantum (one Opus frame) keeps the ring — and so the latency — small.
|
||||||
|
*pw::keys::NODE_LATENCY => "240/48000",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.context("pw Stream")?;
|
||||||
|
|
||||||
|
let ud = PlayerData {
|
||||||
|
rx: pcm_rx,
|
||||||
|
ring: VecDeque::new(),
|
||||||
|
primed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _listener = stream
|
||||||
|
.add_local_listener_with_user_data(ud)
|
||||||
|
.state_changed(|_s, _ud, old, new| {
|
||||||
|
tracing::debug!(?old, ?new, "pipewire playback stream state");
|
||||||
|
})
|
||||||
|
.process(|stream, ud| {
|
||||||
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
while let Ok(chunk) = ud.rx.try_recv() {
|
||||||
|
ud.ring.extend(chunk);
|
||||||
|
}
|
||||||
|
let stride = 4 * CHANNELS; // F32LE interleaved
|
||||||
|
let datas = buffer.datas_mut();
|
||||||
|
if datas.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = &mut datas[0];
|
||||||
|
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
|
||||||
|
let want = want_frames * CHANNELS;
|
||||||
|
|
||||||
|
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
|
||||||
|
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
|
||||||
|
// genuine drain.
|
||||||
|
let target = (3 * want).clamp(720 * CHANNELS, 9600 * CHANNELS);
|
||||||
|
while ud.ring.len() > target.max(want) + want {
|
||||||
|
ud.ring.pop_front();
|
||||||
|
}
|
||||||
|
if !ud.primed && ud.ring.len() >= target {
|
||||||
|
ud.primed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let n_frames = if let Some(slice) = data.data() {
|
||||||
|
for k in 0..want {
|
||||||
|
let s = if ud.primed {
|
||||||
|
ud.ring.pop_front().unwrap_or(0.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let off = k * 4;
|
||||||
|
slice[off..off + 4].copy_from_slice(&s.to_le_bytes());
|
||||||
|
}
|
||||||
|
want_frames
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if ud.ring.is_empty() {
|
||||||
|
ud.primed = false;
|
||||||
|
}
|
||||||
|
let chunk = data.chunk_mut();
|
||||||
|
*chunk.offset_mut() = 0;
|
||||||
|
*chunk.stride_mut() = stride as _;
|
||||||
|
*chunk.size_mut() = (stride * n_frames) as _;
|
||||||
|
}));
|
||||||
|
if outcome.is_err() {
|
||||||
|
tracing::error!("panic in pipewire playback callback");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register()
|
||||||
|
.context("register playback listener")?;
|
||||||
|
|
||||||
|
let mut info = AudioInfoRaw::new();
|
||||||
|
info.set_format(AudioFormat::F32LE);
|
||||||
|
info.set_rate(SAMPLE_RATE);
|
||||||
|
info.set_channels(CHANNELS as u32);
|
||||||
|
let obj = pw::spa::pod::Object {
|
||||||
|
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||||
|
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
||||||
|
properties: info.into(),
|
||||||
|
};
|
||||||
|
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
|
||||||
|
std::io::Cursor::new(Vec::new()),
|
||||||
|
&pw::spa::pod::Value::Object(obj),
|
||||||
|
)
|
||||||
|
.context("serialize format pod")?
|
||||||
|
.0
|
||||||
|
.into_inner();
|
||||||
|
let mut params = [Pod::from_bytes(&values).context("pod from bytes")?];
|
||||||
|
|
||||||
|
stream
|
||||||
|
.connect(
|
||||||
|
spa::utils::Direction::Output,
|
||||||
|
None,
|
||||||
|
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
|
||||||
|
&mut params,
|
||||||
|
)
|
||||||
|
.context("pw stream connect")?;
|
||||||
|
|
||||||
|
mainloop.run();
|
||||||
|
tracing::debug!("pipewire playback loop exited");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The microphone uplink: capture the default input device, Opus-encode 20 ms chunks,
|
||||||
|
/// ship them as 0xCB datagrams into the host's virtual PipeWire source.
|
||||||
|
pub struct MicStreamer {
|
||||||
|
quit_tx: pipewire::channel::Sender<Terminate>,
|
||||||
|
thread: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MicStreamer {
|
||||||
|
pub fn spawn(connector: Arc<NativeClient>) -> Result<MicStreamer> {
|
||||||
|
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||||
|
let thread = std::thread::Builder::new()
|
||||||
|
.name("punktfunk-mic".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(e) = mic_thread(&connector, quit_rx) {
|
||||||
|
tracing::warn!(error = %e, "mic uplink thread ended");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.context("spawn mic thread")?;
|
||||||
|
Ok(MicStreamer {
|
||||||
|
quit_tx,
|
||||||
|
thread: Some(thread),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MicStreamer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.quit_tx.send(Terminate);
|
||||||
|
if let Some(t) = self.thread.take() {
|
||||||
|
let _ = t.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture-side state: accumulated PCM and the Opus encoder (encoding a 20 ms frame is
|
||||||
|
/// ~100 µs — fine inside the process callback).
|
||||||
|
struct MicData {
|
||||||
|
connector: Arc<NativeClient>,
|
||||||
|
ring: VecDeque<f32>,
|
||||||
|
encoder: opus::Encoder,
|
||||||
|
seq: u32,
|
||||||
|
out: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mic_thread(
|
||||||
|
connector: &Arc<NativeClient>,
|
||||||
|
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use pipewire as pw;
|
||||||
|
use pw::{properties::properties, spa};
|
||||||
|
use spa::param::audio::{AudioFormat, AudioInfoRaw};
|
||||||
|
use spa::pod::Pod;
|
||||||
|
|
||||||
|
static PW_INIT: std::sync::Once = std::sync::Once::new();
|
||||||
|
PW_INIT.call_once(pw::init);
|
||||||
|
|
||||||
|
let mut encoder =
|
||||||
|
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Stereo, opus::Application::Voip)
|
||||||
|
.map_err(|e| anyhow::anyhow!("opus encoder: {e}"))?;
|
||||||
|
let _ = encoder.set_bitrate(opus::Bitrate::Bits(64_000));
|
||||||
|
|
||||||
|
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw mic MainLoop")?;
|
||||||
|
let context = pw::context::ContextRc::new(&mainloop, None).context("pw mic Context")?;
|
||||||
|
let core = context
|
||||||
|
.connect_rc(None)
|
||||||
|
.context("pw mic connect (is PipeWire running in this session?)")?;
|
||||||
|
|
||||||
|
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
|
||||||
|
let mainloop = mainloop.clone();
|
||||||
|
move |_| mainloop.quit()
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = pw::stream::StreamBox::new(
|
||||||
|
&core,
|
||||||
|
"punktfunk-mic-capture",
|
||||||
|
properties! {
|
||||||
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||||
|
*pw::keys::MEDIA_ROLE => "Communication",
|
||||||
|
*pw::keys::NODE_NAME => "punktfunk-mic-capture",
|
||||||
|
*pw::keys::NODE_DESCRIPTION => "Punktfunk Microphone",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.context("pw mic Stream")?;
|
||||||
|
|
||||||
|
let ud = MicData {
|
||||||
|
connector: connector.clone(),
|
||||||
|
ring: VecDeque::new(),
|
||||||
|
encoder,
|
||||||
|
seq: 0,
|
||||||
|
out: vec![0u8; 4000],
|
||||||
|
};
|
||||||
|
|
||||||
|
let _listener = stream
|
||||||
|
.add_local_listener_with_user_data(ud)
|
||||||
|
.state_changed(|_s, _ud, old, new| {
|
||||||
|
tracing::debug!(?old, ?new, "pipewire mic capture stream state");
|
||||||
|
})
|
||||||
|
.process(|stream, ud| {
|
||||||
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let datas = buffer.datas_mut();
|
||||||
|
if datas.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = &mut datas[0];
|
||||||
|
let n = data.chunk().size() as usize;
|
||||||
|
if let Some(slice) = data.data() {
|
||||||
|
for s in slice[..n.min(slice.len())].chunks_exact(4) {
|
||||||
|
ud.ring
|
||||||
|
.push_back(f32::from_le_bytes([s[0], s[1], s[2], s[3]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ship every complete 20 ms stereo frame.
|
||||||
|
while ud.ring.len() >= MIC_FRAME * CHANNELS {
|
||||||
|
let pcm: Vec<f32> = ud.ring.drain(..MIC_FRAME * CHANNELS).collect();
|
||||||
|
match ud.encoder.encode_float(&pcm, &mut ud.out) {
|
||||||
|
Ok(len) => {
|
||||||
|
let pts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let _ = ud.connector.send_mic(ud.seq, pts, ud.out[..len].to_vec());
|
||||||
|
ud.seq = ud.seq.wrapping_add(1);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::debug!(error = %e, "opus mic encode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if outcome.is_err() {
|
||||||
|
tracing::error!("panic in pipewire mic callback");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register()
|
||||||
|
.context("register mic listener")?;
|
||||||
|
|
||||||
|
let mut info = AudioInfoRaw::new();
|
||||||
|
info.set_format(AudioFormat::F32LE);
|
||||||
|
info.set_rate(SAMPLE_RATE);
|
||||||
|
info.set_channels(CHANNELS as u32);
|
||||||
|
let obj = pw::spa::pod::Object {
|
||||||
|
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||||
|
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
||||||
|
properties: info.into(),
|
||||||
|
};
|
||||||
|
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
|
||||||
|
std::io::Cursor::new(Vec::new()),
|
||||||
|
&pw::spa::pod::Value::Object(obj),
|
||||||
|
)
|
||||||
|
.context("serialize mic format pod")?
|
||||||
|
.0
|
||||||
|
.into_inner();
|
||||||
|
let mut params = [Pod::from_bytes(&values).context("mic pod from bytes")?];
|
||||||
|
|
||||||
|
stream
|
||||||
|
.connect(
|
||||||
|
spa::utils::Direction::Input,
|
||||||
|
None,
|
||||||
|
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
|
||||||
|
&mut params,
|
||||||
|
)
|
||||||
|
.context("pw mic stream connect")?;
|
||||||
|
|
||||||
|
mainloop.run();
|
||||||
|
tracing::debug!("pipewire mic capture loop exited");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//! LAN host discovery: browse the host's mDNS advert (`_punktfunk._udp`, TXT keys
|
||||||
|
//! `fp`/`pair`/`id` — see the host crate's `discovery.rs`) on a worker thread and stream
|
||||||
|
//! results to the UI.
|
||||||
|
|
||||||
|
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DiscoveredHost {
|
||||||
|
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
|
||||||
|
pub key: String,
|
||||||
|
pub name: String,
|
||||||
|
pub addr: String,
|
||||||
|
pub port: u16,
|
||||||
|
/// Host certificate fingerprint to pin (lowercase hex), empty if not advertised.
|
||||||
|
pub fp_hex: String,
|
||||||
|
/// Pairing requirement: `"required"` or `"optional"`.
|
||||||
|
pub pair: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||||
|
/// dropped (the send fails) or the daemon dies.
|
||||||
|
pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||||
|
let (tx, rx) = async_channel::unbounded();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("punktfunk-mdns".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let daemon = match ServiceDaemon::new() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "mDNS daemon failed — discovery disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let receiver = match daemon.browse("_punktfunk._udp.local.") {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
while let Ok(event) = receiver.recv() {
|
||||||
|
if let ServiceEvent::ServiceResolved(info) = event {
|
||||||
|
let props = info.get_properties();
|
||||||
|
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
|
||||||
|
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let id = val("id");
|
||||||
|
let host = DiscoveredHost {
|
||||||
|
key: if id.is_empty() {
|
||||||
|
info.get_fullname().to_string()
|
||||||
|
} else {
|
||||||
|
id
|
||||||
|
},
|
||||||
|
name: info
|
||||||
|
.get_fullname()
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("?")
|
||||||
|
.to_string(),
|
||||||
|
addr,
|
||||||
|
port: info.get_port(),
|
||||||
|
fp_hex: val("fp"),
|
||||||
|
pair: val("pair"),
|
||||||
|
};
|
||||||
|
if tx.send_blocking(host).is_err() {
|
||||||
|
break; // UI gone — stop browsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = daemon.shutdown();
|
||||||
|
})
|
||||||
|
.expect("spawn mdns thread");
|
||||||
|
rx
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
//! App-lifetime gamepad service over SDL3 (mirrors the Swift client's `GamepadManager` +
|
||||||
|
//! `GamepadCapture`/`GamepadFeedback`).
|
||||||
|
//!
|
||||||
|
//! One worker thread owns SDL for the process lifetime: it tracks connected pads for the
|
||||||
|
//! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most
|
||||||
|
//! recently connected), and — while a session is attached — forwards buttons/axes,
|
||||||
|
//! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on
|
||||||
|
//! every pad, lightbar via SDL, and on a real DualSense the raw effects packet
|
||||||
|
//! (adaptive-trigger blocks replayed verbatim, player LEDs). Held state is zeroed on the
|
||||||
|
//! wire when the active pad switches or the session detaches, so nothing sticks down.
|
||||||
|
//!
|
||||||
|
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
||||||
|
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::config::GamepadPref;
|
||||||
|
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
|
||||||
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Motion scale constants, shared convention with the Swift client (`GamepadWire`):
|
||||||
|
/// derived from hid-playstation's math over the host's fixed calibration blob. SDL hands
|
||||||
|
/// us gyro in rad/s and accel in m/s²; the DualSense report wants raw LSBs.
|
||||||
|
const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
|
||||||
|
const ACCEL_LSB_PER_G: f32 = 10_000.0;
|
||||||
|
const G: f32 = 9.80665;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PadInfo {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub is_dualsense: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Ctl {
|
||||||
|
Attach(Arc<NativeClient>),
|
||||||
|
Detach,
|
||||||
|
Pin(Option<u32>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GamepadService {
|
||||||
|
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||||||
|
active: Arc<Mutex<Option<PadInfo>>>,
|
||||||
|
pinned: Arc<Mutex<Option<u32>>>,
|
||||||
|
ctl: Sender<Ctl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GamepadService {
|
||||||
|
pub fn start() -> GamepadService {
|
||||||
|
let pads = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let active = Arc::new(Mutex::new(None));
|
||||||
|
let pinned = Arc::new(Mutex::new(None));
|
||||||
|
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||||||
|
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone());
|
||||||
|
if let Err(e) = std::thread::Builder::new()
|
||||||
|
.name("punktfunk-gamepad".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(e) = run(&p, &a, &pin, &ctl_rx) {
|
||||||
|
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "gamepad service failed to start");
|
||||||
|
}
|
||||||
|
GamepadService {
|
||||||
|
pads,
|
||||||
|
active,
|
||||||
|
pinned,
|
||||||
|
ctl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pads(&self) -> Vec<PadInfo> {
|
||||||
|
self.pads.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active(&self) -> Option<PadInfo> {
|
||||||
|
self.active.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pinned(&self) -> Option<u32> {
|
||||||
|
*self.pinned.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_pinned(&self, id: Option<u32>) {
|
||||||
|
let _ = self.ctl.send(Ctl::Pin(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||||
|
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detach(&self) {
|
||||||
|
let _ = self.ctl.send(Ctl::Detach);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What "Automatic" resolves to right now — the virtual pad matching the physical one
|
||||||
|
/// (Swift parity); no pad connected leaves the host's own default.
|
||||||
|
pub fn auto_pref(&self) -> GamepadPref {
|
||||||
|
match self.active() {
|
||||||
|
Some(p) if p.is_dualsense => GamepadPref::DualSense,
|
||||||
|
Some(_) => GamepadPref::Xbox360,
|
||||||
|
None => GamepadPref::Auto,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
|
||||||
|
let _ = connector.send_input(&InputEvent {
|
||||||
|
kind,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code,
|
||||||
|
x,
|
||||||
|
y: 0,
|
||||||
|
flags: 0, // pad index 0 — single-pad model
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
||||||
|
use sdl3::gamepad::Button;
|
||||||
|
Some(match b {
|
||||||
|
Button::South => wire::BTN_A,
|
||||||
|
Button::East => wire::BTN_B,
|
||||||
|
Button::West => wire::BTN_X,
|
||||||
|
Button::North => wire::BTN_Y,
|
||||||
|
Button::Back => wire::BTN_BACK,
|
||||||
|
Button::Start => wire::BTN_START,
|
||||||
|
Button::Guide => wire::BTN_GUIDE,
|
||||||
|
Button::LeftStick => wire::BTN_LS_CLICK,
|
||||||
|
Button::RightStick => wire::BTN_RS_CLICK,
|
||||||
|
Button::LeftShoulder => wire::BTN_LB,
|
||||||
|
Button::RightShoulder => wire::BTN_RB,
|
||||||
|
Button::DPadUp => wire::BTN_DPAD_UP,
|
||||||
|
Button::DPadDown => wire::BTN_DPAD_DOWN,
|
||||||
|
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||||
|
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||||
|
Button::Touchpad => wire::BTN_TOUCHPAD,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput
|
||||||
|
/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255.
|
||||||
|
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||||||
|
use sdl3::gamepad::Axis;
|
||||||
|
match axis {
|
||||||
|
Axis::LeftX => (wire::AXIS_LS_X, v as i32),
|
||||||
|
Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)),
|
||||||
|
Axis::RightX => (wire::AXIS_RS_X, v as i32),
|
||||||
|
Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
|
||||||
|
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
|
||||||
|
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the
|
||||||
|
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
|
||||||
|
/// Enable bits select only the fields each update touches, so rumble (driven separately
|
||||||
|
/// through SDL) and untouched fields keep their state.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Ds5Feedback;
|
||||||
|
|
||||||
|
impl Ds5Feedback {
|
||||||
|
const RIGHT_TRIGGER: usize = 10;
|
||||||
|
const LEFT_TRIGGER: usize = 21;
|
||||||
|
const PAD_LIGHTS: usize = 43;
|
||||||
|
const LED_RGB: usize = 44;
|
||||||
|
|
||||||
|
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
|
||||||
|
let mut p = [0u8; 47];
|
||||||
|
let (flag, off) = if which == 1 {
|
||||||
|
(0x04, Self::RIGHT_TRIGGER)
|
||||||
|
} else {
|
||||||
|
(0x08, Self::LEFT_TRIGGER)
|
||||||
|
};
|
||||||
|
p[0] = flag;
|
||||||
|
let n = effect.len().min(11);
|
||||||
|
p[off..off + n].copy_from_slice(&effect[..n]);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] {
|
||||||
|
let mut p = [0u8; 47];
|
||||||
|
p[1] = 0x04; // lightbar enable
|
||||||
|
p[Self::LED_RGB] = r;
|
||||||
|
p[Self::LED_RGB + 1] = g;
|
||||||
|
p[Self::LED_RGB + 2] = b;
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_packet(bits: u8) -> [u8; 47] {
|
||||||
|
let mut p = [0u8; 47];
|
||||||
|
p[1] = 0x10; // player-LED enable
|
||||||
|
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
||||||
|
p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Worker {
|
||||||
|
subsystem: sdl3::GamepadSubsystem,
|
||||||
|
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
||||||
|
/// Connection order; the most recently connected is the auto selection.
|
||||||
|
order: Vec<u32>,
|
||||||
|
pinned: Option<u32>,
|
||||||
|
attached: Option<Arc<NativeClient>>,
|
||||||
|
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||||
|
last_axis: [i32; 6],
|
||||||
|
held_buttons: Vec<u32>,
|
||||||
|
last_accel: [i16; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Worker {
|
||||||
|
fn active_id(&self) -> Option<u32> {
|
||||||
|
self.pinned
|
||||||
|
.filter(|id| self.opened.contains_key(id))
|
||||||
|
.or_else(|| self.order.last().copied())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||||
|
let pad = self.opened.get(&id)?;
|
||||||
|
Some(PadInfo {
|
||||||
|
id,
|
||||||
|
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||||
|
is_dualsense: matches!(
|
||||||
|
self.subsystem
|
||||||
|
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||||
|
sdl3::gamepad::GamepadType::PS5
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero everything the host believes is held — on pad switch and detach.
|
||||||
|
fn flush_held(&mut self) {
|
||||||
|
if let Some(c) = &self.attached {
|
||||||
|
for b in self.held_buttons.drain(..) {
|
||||||
|
send(c, InputKind::GamepadButton, b, 0);
|
||||||
|
}
|
||||||
|
for (id, v) in self.last_axis.iter_mut().enumerate() {
|
||||||
|
if *v != 0 && *v != i32::MIN {
|
||||||
|
send(c, InputKind::GamepadAxis, id as u32, 0);
|
||||||
|
}
|
||||||
|
*v = i32::MIN;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.held_buttons.clear();
|
||||||
|
self.last_axis = [i32::MIN; 6];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||||||
|
fn set_sensors(&mut self, enabled: bool) {
|
||||||
|
let Some(id) = self.active_id() else { return };
|
||||||
|
if let Some(pad) = self.opened.get_mut(&id) {
|
||||||
|
use sdl3::sensor::SensorType;
|
||||||
|
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
|
||||||
|
if unsafe { pad.has_sensor(s) } {
|
||||||
|
let _ = pad.sensor_set_enabled(s, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
fn run(
|
||||||
|
pads_out: &Mutex<Vec<PadInfo>>,
|
||||||
|
active_out: &Mutex<Option<PadInfo>>,
|
||||||
|
pinned_out: &Mutex<Option<u32>>,
|
||||||
|
ctl: &Receiver<Ctl>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its
|
||||||
|
// own thread.
|
||||||
|
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||||
|
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||||
|
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||||
|
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||||
|
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut w = Worker {
|
||||||
|
subsystem,
|
||||||
|
opened: HashMap::new(),
|
||||||
|
order: Vec::new(),
|
||||||
|
pinned: None,
|
||||||
|
attached: None,
|
||||||
|
last_axis: [i32::MIN; 6],
|
||||||
|
held_buttons: Vec::new(),
|
||||||
|
last_accel: [0; 3],
|
||||||
|
};
|
||||||
|
|
||||||
|
let publish = |w: &Worker| {
|
||||||
|
let mut list: Vec<PadInfo> = w.order.iter().filter_map(|&id| w.pad_info(id)).collect();
|
||||||
|
list.reverse(); // most recent first — the Settings list order
|
||||||
|
*pads_out.lock().unwrap() = list;
|
||||||
|
*active_out.lock().unwrap() = w.active_id().and_then(|id| w.pad_info(id));
|
||||||
|
*pinned_out.lock().unwrap() = w.pinned;
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Control plane from the UI thread.
|
||||||
|
loop {
|
||||||
|
match ctl.try_recv() {
|
||||||
|
Ok(Ctl::Attach(c)) => {
|
||||||
|
w.attached = Some(c);
|
||||||
|
w.last_axis = [i32::MIN; 6];
|
||||||
|
w.set_sensors(true);
|
||||||
|
}
|
||||||
|
Ok(Ctl::Detach) => {
|
||||||
|
w.flush_held();
|
||||||
|
w.set_sensors(false);
|
||||||
|
w.attached = None;
|
||||||
|
}
|
||||||
|
Ok(Ctl::Pin(id)) => {
|
||||||
|
let before = w.active_id();
|
||||||
|
w.pinned = id;
|
||||||
|
if w.active_id() != before {
|
||||||
|
w.flush_held();
|
||||||
|
if w.attached.is_some() {
|
||||||
|
w.set_sensors(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publish(&w);
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(event) = pump.poll_event() {
|
||||||
|
use sdl3::event::Event;
|
||||||
|
let active = w.active_id();
|
||||||
|
match event {
|
||||||
|
Event::ControllerDeviceAdded { which, .. } => {
|
||||||
|
if !w.opened.contains_key(&which) {
|
||||||
|
match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) {
|
||||||
|
Ok(pad) => {
|
||||||
|
tracing::info!(
|
||||||
|
name = pad.name().unwrap_or_default(),
|
||||||
|
"gamepad attached"
|
||||||
|
);
|
||||||
|
w.opened.insert(which, pad);
|
||||||
|
w.order.push(which);
|
||||||
|
if w.attached.is_some() && w.active_id() == Some(which) {
|
||||||
|
w.set_sensors(true);
|
||||||
|
}
|
||||||
|
publish(&w);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ControllerDeviceRemoved { which, .. } => {
|
||||||
|
if w.opened.remove(&which).is_some() {
|
||||||
|
w.order.retain(|&id| id != which);
|
||||||
|
if active == Some(which) {
|
||||||
|
w.flush_held();
|
||||||
|
}
|
||||||
|
tracing::info!("gamepad detached");
|
||||||
|
publish(&w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ControllerButtonDown { which, button, .. }
|
||||||
|
if active == Some(which) && w.attached.is_some() =>
|
||||||
|
{
|
||||||
|
if let Some(bit) = button_bit(button) {
|
||||||
|
w.held_buttons.push(bit);
|
||||||
|
send(
|
||||||
|
w.attached.as_ref().unwrap(),
|
||||||
|
InputKind::GamepadButton,
|
||||||
|
bit,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ControllerButtonUp { which, button, .. }
|
||||||
|
if active == Some(which) && w.attached.is_some() =>
|
||||||
|
{
|
||||||
|
if let Some(bit) = button_bit(button) {
|
||||||
|
w.held_buttons.retain(|&b| b != bit);
|
||||||
|
send(
|
||||||
|
w.attached.as_ref().unwrap(),
|
||||||
|
InputKind::GamepadButton,
|
||||||
|
bit,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ControllerAxisMotion {
|
||||||
|
which, axis, value, ..
|
||||||
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
|
let (id, v) = axis_value(axis, value);
|
||||||
|
if w.last_axis[id as usize] != v {
|
||||||
|
w.last_axis[id as usize] = v;
|
||||||
|
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
|
||||||
|
Event::ControllerTouchpadDown {
|
||||||
|
which,
|
||||||
|
finger,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| Event::ControllerTouchpadMotion {
|
||||||
|
which,
|
||||||
|
finger,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
..
|
||||||
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
|
let _ = w
|
||||||
|
.attached
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.send_rich_input(RichInput::Touchpad {
|
||||||
|
pad: 0,
|
||||||
|
finger: finger as u8,
|
||||||
|
active: true,
|
||||||
|
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||||
|
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Event::ControllerTouchpadUp {
|
||||||
|
which,
|
||||||
|
finger,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
..
|
||||||
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
|
let _ = w
|
||||||
|
.attached
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.send_rich_input(RichInput::Touchpad {
|
||||||
|
pad: 0,
|
||||||
|
finger: finger as u8,
|
||||||
|
active: false,
|
||||||
|
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||||
|
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Motion: accel events update the cache; each gyro event ships a sample
|
||||||
|
// (the DualSense reports both at ~250 Hz). Scale convention shared with
|
||||||
|
// the Swift client — sign/scale derived, not yet live-verified.
|
||||||
|
Event::ControllerSensorUpdated {
|
||||||
|
which,
|
||||||
|
sensor,
|
||||||
|
data,
|
||||||
|
..
|
||||||
|
} if active == Some(which) && w.attached.is_some() => {
|
||||||
|
use sdl3::sensor::SensorType;
|
||||||
|
match sensor {
|
||||||
|
SensorType::Accelerometer => {
|
||||||
|
for (i, v) in data.iter().enumerate() {
|
||||||
|
w.last_accel[i] =
|
||||||
|
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SensorType::Gyroscope => {
|
||||||
|
let mut gyro = [0i16; 3];
|
||||||
|
for (i, v) in data.iter().enumerate() {
|
||||||
|
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
|
||||||
|
}
|
||||||
|
let _ =
|
||||||
|
w.attached
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.send_rich_input(RichInput::Motion {
|
||||||
|
pad: 0,
|
||||||
|
gyro,
|
||||||
|
accel: w.last_accel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feedback planes (this thread is their single consumer). The host re-sends
|
||||||
|
// rumble state periodically, so a generous duration with refresh-on-update is
|
||||||
|
// safe — a dropped stop heals within ~500 ms.
|
||||||
|
if let Some(connector) = w.attached.clone() {
|
||||||
|
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
|
||||||
|
if pad == 0 {
|
||||||
|
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
||||||
|
let _ = p.set_rumble(low, high, 5_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||||
|
let Some(id) = w.active_id() else { continue };
|
||||||
|
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
|
||||||
|
let Some(pad) = w.opened.get_mut(&id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match hid {
|
||||||
|
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
|
||||||
|
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
|
||||||
|
}
|
||||||
|
HidOutput::Led { pad: 0, r, g, b } => {
|
||||||
|
let _ = pad.set_led(r, g, b);
|
||||||
|
}
|
||||||
|
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
|
||||||
|
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
|
||||||
|
}
|
||||||
|
HidOutput::Trigger {
|
||||||
|
pad: 0,
|
||||||
|
which,
|
||||||
|
ref effect,
|
||||||
|
} if is_ds => {
|
||||||
|
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(if w.attached.is_some() {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
30
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
//! Local key/button codes → the punktfunk input wire contract.
|
||||||
|
//!
|
||||||
|
//! The wire carries Windows Virtual-Key codes (the GameStream convention; the host maps
|
||||||
|
//! them back with `inject::vk_to_evdev`). GTK hands us the hardware keycode, which on
|
||||||
|
//! Wayland (and X11) is the evdev code + 8 — so this table is the exact inverse of the
|
||||||
|
//! host's, keyed on evdev codes. Layout-independent by construction: positional keys map
|
||||||
|
//! positionally, exactly what a game expects.
|
||||||
|
|
||||||
|
/// Map a Linux evdev key code to the Windows VK code the host expects. `None` = a key the
|
||||||
|
/// wire contract doesn't cover (media keys etc.) — drop it rather than guess.
|
||||||
|
pub fn evdev_to_vk(evdev: u16) -> Option<u8> {
|
||||||
|
Some(match evdev {
|
||||||
|
// --- Navigation / editing / whitespace ---
|
||||||
|
14 => 0x08, // KEY_BACKSPACE -> VK_BACK
|
||||||
|
15 => 0x09, // KEY_TAB -> VK_TAB
|
||||||
|
28 => 0x0D, // KEY_ENTER -> VK_RETURN
|
||||||
|
119 => 0x13, // KEY_PAUSE -> VK_PAUSE
|
||||||
|
58 => 0x14, // KEY_CAPSLOCK -> VK_CAPITAL
|
||||||
|
1 => 0x1B, // KEY_ESC -> VK_ESCAPE
|
||||||
|
57 => 0x20, // KEY_SPACE -> VK_SPACE
|
||||||
|
104 => 0x21, // KEY_PAGEUP -> VK_PRIOR
|
||||||
|
109 => 0x22, // KEY_PAGEDOWN -> VK_NEXT
|
||||||
|
107 => 0x23, // KEY_END -> VK_END
|
||||||
|
102 => 0x24, // KEY_HOME -> VK_HOME
|
||||||
|
105 => 0x25, // KEY_LEFT -> VK_LEFT
|
||||||
|
103 => 0x26, // KEY_UP -> VK_UP
|
||||||
|
106 => 0x27, // KEY_RIGHT -> VK_RIGHT
|
||||||
|
108 => 0x28, // KEY_DOWN -> VK_DOWN
|
||||||
|
99 => 0x2C, // KEY_SYSRQ -> VK_SNAPSHOT
|
||||||
|
110 => 0x2D, // KEY_INSERT -> VK_INSERT
|
||||||
|
111 => 0x2E, // KEY_DELETE -> VK_DELETE
|
||||||
|
|
||||||
|
// --- Digit row (KEY_1..KEY_9 are 2..10, KEY_0 is 11) ---
|
||||||
|
11 => 0x30,
|
||||||
|
2 => 0x31,
|
||||||
|
3 => 0x32,
|
||||||
|
4 => 0x33,
|
||||||
|
5 => 0x34,
|
||||||
|
6 => 0x35,
|
||||||
|
7 => 0x36,
|
||||||
|
8 => 0x37,
|
||||||
|
9 => 0x38,
|
||||||
|
10 => 0x39,
|
||||||
|
|
||||||
|
// --- Letters (evdev order is QWERTY rows, not alphabetical) ---
|
||||||
|
30 => 0x41, // A
|
||||||
|
48 => 0x42, // B
|
||||||
|
46 => 0x43, // C
|
||||||
|
32 => 0x44, // D
|
||||||
|
18 => 0x45, // E
|
||||||
|
33 => 0x46, // F
|
||||||
|
34 => 0x47, // G
|
||||||
|
35 => 0x48, // H
|
||||||
|
23 => 0x49, // I
|
||||||
|
36 => 0x4A, // J
|
||||||
|
37 => 0x4B, // K
|
||||||
|
38 => 0x4C, // L
|
||||||
|
50 => 0x4D, // M
|
||||||
|
49 => 0x4E, // N
|
||||||
|
24 => 0x4F, // O
|
||||||
|
25 => 0x50, // P
|
||||||
|
16 => 0x51, // Q
|
||||||
|
19 => 0x52, // R
|
||||||
|
31 => 0x53, // S
|
||||||
|
20 => 0x54, // T
|
||||||
|
22 => 0x55, // U
|
||||||
|
47 => 0x56, // V
|
||||||
|
17 => 0x57, // W
|
||||||
|
45 => 0x58, // X
|
||||||
|
21 => 0x59, // Y
|
||||||
|
44 => 0x5A, // Z
|
||||||
|
|
||||||
|
// --- Meta / context-menu ---
|
||||||
|
125 => 0x5B, // KEY_LEFTMETA -> VK_LWIN
|
||||||
|
126 => 0x5C, // KEY_RIGHTMETA -> VK_RWIN
|
||||||
|
127 => 0x5D, // KEY_COMPOSE -> VK_APPS
|
||||||
|
|
||||||
|
// --- Numpad ---
|
||||||
|
82 => 0x60, // KP0
|
||||||
|
79 => 0x61,
|
||||||
|
80 => 0x62,
|
||||||
|
81 => 0x63,
|
||||||
|
75 => 0x64,
|
||||||
|
76 => 0x65,
|
||||||
|
77 => 0x66,
|
||||||
|
71 => 0x67,
|
||||||
|
72 => 0x68,
|
||||||
|
73 => 0x69, // KP9
|
||||||
|
55 => 0x6A, // KEY_KPASTERISK -> VK_MULTIPLY
|
||||||
|
78 => 0x6B, // KEY_KPPLUS -> VK_ADD
|
||||||
|
96 => 0x6C, // KEY_KPENTER -> VK_SEPARATOR
|
||||||
|
74 => 0x6D, // KEY_KPMINUS -> VK_SUBTRACT
|
||||||
|
83 => 0x6E, // KEY_KPDOT -> VK_DECIMAL
|
||||||
|
98 => 0x6F, // KEY_KPSLASH -> VK_DIVIDE
|
||||||
|
|
||||||
|
// --- Function keys ---
|
||||||
|
59 => 0x70, // F1
|
||||||
|
60 => 0x71,
|
||||||
|
61 => 0x72,
|
||||||
|
62 => 0x73,
|
||||||
|
63 => 0x74,
|
||||||
|
64 => 0x75,
|
||||||
|
65 => 0x76,
|
||||||
|
66 => 0x77,
|
||||||
|
67 => 0x78,
|
||||||
|
68 => 0x79, // F10
|
||||||
|
87 => 0x7A, // F11
|
||||||
|
88 => 0x7B, // F12
|
||||||
|
|
||||||
|
// --- Locks ---
|
||||||
|
69 => 0x90, // KEY_NUMLOCK -> VK_NUMLOCK
|
||||||
|
70 => 0x91, // KEY_SCROLLLOCK -> VK_SCROLL
|
||||||
|
|
||||||
|
// --- Left/right modifiers (specific VKs; the host maps both generics here too) ---
|
||||||
|
42 => 0xA0, // KEY_LEFTSHIFT -> VK_LSHIFT
|
||||||
|
54 => 0xA1, // KEY_RIGHTSHIFT -> VK_RSHIFT
|
||||||
|
29 => 0xA2, // KEY_LEFTCTRL -> VK_LCONTROL
|
||||||
|
97 => 0xA3, // KEY_RIGHTCTRL -> VK_RCONTROL
|
||||||
|
56 => 0xA4, // KEY_LEFTALT -> VK_LMENU
|
||||||
|
100 => 0xA5, // KEY_RIGHTALT -> VK_RMENU
|
||||||
|
|
||||||
|
// --- OEM punctuation (US-layout positions) ---
|
||||||
|
39 => 0xBA, // KEY_SEMICOLON -> VK_OEM_1
|
||||||
|
13 => 0xBB, // KEY_EQUAL -> VK_OEM_PLUS
|
||||||
|
51 => 0xBC, // KEY_COMMA -> VK_OEM_COMMA
|
||||||
|
12 => 0xBD, // KEY_MINUS -> VK_OEM_MINUS
|
||||||
|
52 => 0xBE, // KEY_DOT -> VK_OEM_PERIOD
|
||||||
|
53 => 0xBF, // KEY_SLASH -> VK_OEM_2
|
||||||
|
41 => 0xC0, // KEY_GRAVE -> VK_OEM_3
|
||||||
|
26 => 0xDB, // KEY_LEFTBRACE -> VK_OEM_4
|
||||||
|
43 => 0xDC, // KEY_BACKSLASH -> VK_OEM_5
|
||||||
|
27 => 0xDD, // KEY_RIGHTBRACE -> VK_OEM_6
|
||||||
|
40 => 0xDE, // KEY_APOSTROPHE -> VK_OEM_7
|
||||||
|
86 => 0xE2, // KEY_102ND -> VK_OEM_102
|
||||||
|
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a GTK/GDK mouse button number to the GameStream button id the wire expects
|
||||||
|
/// (1=left, 2=middle, 3=right, 4=X1, 5=X2). GDK reports back/forward as 8/9.
|
||||||
|
pub fn gdk_button_to_gs(button: u32) -> Option<u32> {
|
||||||
|
Some(match button {
|
||||||
|
1 => 1,
|
||||||
|
2 => 2,
|
||||||
|
3 => 3,
|
||||||
|
8 => 4,
|
||||||
|
9 => 5,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The table must be the exact inverse of the host's `vk_to_evdev` for every key the
|
||||||
|
/// host knows (modulo the generic-modifier VKs, which collapse onto the same evdev
|
||||||
|
/// codes as the specific left-hand ones).
|
||||||
|
#[test]
|
||||||
|
fn roundtrips_through_the_host_table() {
|
||||||
|
// Mirror of the host's table (inject::vk_to_evdev), generic modifiers excluded.
|
||||||
|
let host_pairs: &[(u8, u16)] = &[
|
||||||
|
(0x08, 14),
|
||||||
|
(0x09, 15),
|
||||||
|
(0x0D, 28),
|
||||||
|
(0x13, 119),
|
||||||
|
(0x14, 58),
|
||||||
|
(0x1B, 1),
|
||||||
|
(0x20, 57),
|
||||||
|
(0x21, 104),
|
||||||
|
(0x22, 109),
|
||||||
|
(0x23, 107),
|
||||||
|
(0x24, 102),
|
||||||
|
(0x25, 105),
|
||||||
|
(0x26, 103),
|
||||||
|
(0x27, 106),
|
||||||
|
(0x28, 108),
|
||||||
|
(0x2C, 99),
|
||||||
|
(0x2D, 110),
|
||||||
|
(0x2E, 111),
|
||||||
|
(0x30, 11),
|
||||||
|
(0x31, 2),
|
||||||
|
(0x39, 10),
|
||||||
|
(0x41, 30),
|
||||||
|
(0x5A, 44),
|
||||||
|
(0x5B, 125),
|
||||||
|
(0x60, 82),
|
||||||
|
(0x69, 73),
|
||||||
|
(0x70, 59),
|
||||||
|
(0x7B, 88),
|
||||||
|
(0x90, 69),
|
||||||
|
(0xA0, 42),
|
||||||
|
(0xA5, 100),
|
||||||
|
(0xBA, 39),
|
||||||
|
(0xE2, 86),
|
||||||
|
];
|
||||||
|
for &(vk, evdev) in host_pairs {
|
||||||
|
assert_eq!(evdev_to_vk(evdev), Some(vk), "evdev {evdev}");
|
||||||
|
}
|
||||||
|
assert_eq!(evdev_to_vk(113), None); // KEY_MUTE — not in the wire contract
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//! `punktfunk-client` — the native Linux punktfunk/1 client (design: Option A, 2026-06-12).
|
||||||
|
//!
|
||||||
|
//! GTK4/libadwaita shell · `NativeClient` linked as a crate (no C ABI) · FFmpeg decode →
|
||||||
|
//! `GtkGraphicsOffload` present · PipeWire audio · SDL3 gamepads. The trust surface
|
||||||
|
//! mirrors the Apple client: persistent identity, TOFU prompt with the host fingerprint,
|
||||||
|
//! SPAKE2 PIN pairing.
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod app;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod audio;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod discovery;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod gamepad;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod keymap;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod session;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod trust;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod ui_hosts;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod ui_settings;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod ui_stream;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod video;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn main() -> gtk::glib::ExitCode {
|
||||||
|
app::run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GTK4/PipeWire/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on
|
||||||
|
/// macOS (the Mac client lives in clients/apple).
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn main() {
|
||||||
|
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! Session controller: one worker thread runs connect → pump (video pull + decode, audio
|
||||||
|
//! pull + Opus decode, stats), feeding the GTK main loop over channels. The UI keeps the
|
||||||
|
//! `Arc<NativeClient>` from the `Connected` event for direct input sends (no extra hop on
|
||||||
|
//! the input path) — `NativeClient` is `Sync`, planes stay one-consumer-per-thread:
|
||||||
|
//! video+audio here, rumble+hidout on the gamepad thread.
|
||||||
|
|
||||||
|
use crate::audio;
|
||||||
|
use crate::video::{DecodedFrame, Decoder};
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||||
|
use punktfunk_core::PunktfunkError;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
pub struct SessionParams {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub mode: Mode,
|
||||||
|
pub compositor: CompositorPref,
|
||||||
|
pub gamepad: GamepadPref,
|
||||||
|
pub bitrate_kbps: u32,
|
||||||
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
|
pub mic_enabled: bool,
|
||||||
|
/// Pinned host fingerprint; `None` = trust on first use (caller persists the observed one).
|
||||||
|
pub pin: Option<[u8; 32]>,
|
||||||
|
pub identity: (String, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
pub struct Stats {
|
||||||
|
pub fps: f32,
|
||||||
|
pub mbps: f32,
|
||||||
|
pub decode_ms: f32,
|
||||||
|
/// Median capture→decoded latency over the last window (host-clock corrected).
|
||||||
|
pub latency_ms: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum SessionEvent {
|
||||||
|
Connected {
|
||||||
|
connector: Arc<NativeClient>,
|
||||||
|
mode: Mode,
|
||||||
|
fingerprint: [u8; 32],
|
||||||
|
},
|
||||||
|
Failed(String),
|
||||||
|
Ended(Option<String>),
|
||||||
|
Stats(Stats),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SessionHandle {
|
||||||
|
pub events: async_channel::Receiver<SessionEvent>,
|
||||||
|
pub frames: async_channel::Receiver<DecodedFrame>,
|
||||||
|
pub stop: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(params: SessionParams) -> SessionHandle {
|
||||||
|
let (ev_tx, ev_rx) = async_channel::unbounded();
|
||||||
|
// Tiny frame queue, newest wins: force_send displaces the oldest when the UI lags.
|
||||||
|
let (frame_tx, frame_rx) = async_channel::bounded(2);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_w = stop.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("punktfunk-session".into())
|
||||||
|
.spawn(move || pump(params, ev_tx, frame_tx, stop_w))
|
||||||
|
.expect("spawn session thread");
|
||||||
|
SessionHandle {
|
||||||
|
events: ev_rx,
|
||||||
|
frames: frame_rx,
|
||||||
|
stop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ns() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pump(
|
||||||
|
params: SessionParams,
|
||||||
|
ev_tx: async_channel::Sender<SessionEvent>,
|
||||||
|
frame_tx: async_channel::Sender<DecodedFrame>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let connector = match NativeClient::connect(
|
||||||
|
¶ms.host,
|
||||||
|
params.port,
|
||||||
|
params.mode,
|
||||||
|
params.compositor,
|
||||||
|
params.gamepad,
|
||||||
|
params.bitrate_kbps,
|
||||||
|
params.pin,
|
||||||
|
Some(params.identity),
|
||||||
|
Duration::from_secs(15),
|
||||||
|
) {
|
||||||
|
Ok(c) => Arc::new(c),
|
||||||
|
Err(e) => {
|
||||||
|
let msg = match e {
|
||||||
|
PunktfunkError::Crypto => {
|
||||||
|
"Host identity rejected — wrong fingerprint, or the host requires pairing"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
PunktfunkError::Timeout => "Connection timed out".to_string(),
|
||||||
|
other => format!("Connect failed: {other:?}"),
|
||||||
|
};
|
||||||
|
let _ = ev_tx.send_blocking(SessionEvent::Failed(msg));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = ev_tx.send_blocking(SessionEvent::Connected {
|
||||||
|
connector: connector.clone(),
|
||||||
|
mode: connector.mode(),
|
||||||
|
fingerprint: connector.host_fingerprint,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut decoder = match Decoder::new() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = ev_tx.send_blocking(SessionEvent::Ended(Some(format!("video decoder: {e}"))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||||
|
// app-lifetime service's job (the UI attaches it on Connected).
|
||||||
|
let player = audio::AudioPlayer::spawn()
|
||||||
|
.map_err(|e| tracing::warn!(error = %e, "audio disabled"))
|
||||||
|
.ok();
|
||||||
|
let mut opus_dec = opus::Decoder::new(48_000, opus::Channels::Stereo)
|
||||||
|
.map_err(|e| tracing::warn!(error = %e, "opus decoder failed — audio disabled"))
|
||||||
|
.ok();
|
||||||
|
let _mic = params
|
||||||
|
.mic_enabled
|
||||||
|
.then(|| {
|
||||||
|
audio::MicStreamer::spawn(connector.clone())
|
||||||
|
.map_err(|e| tracing::warn!(error = %e, "mic uplink disabled"))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let clock_offset = connector.clock_offset_ns;
|
||||||
|
let mut total_frames = 0u64;
|
||||||
|
let mut window_start = Instant::now();
|
||||||
|
let mut frames_n = 0u32;
|
||||||
|
let mut bytes_n = 0u64;
|
||||||
|
let mut decode_us_sum = 0u64;
|
||||||
|
let mut lat_us: Vec<u64> = Vec::with_capacity(256);
|
||||||
|
let mut pcm = vec![0f32; 5760 * 2]; // decode scratch: max Opus frame (120 ms stereo)
|
||||||
|
|
||||||
|
let end: Option<String> = loop {
|
||||||
|
if stop.load(Ordering::SeqCst) {
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
match connector.next_frame(Duration::from_millis(4)) {
|
||||||
|
Ok(frame) => {
|
||||||
|
let t0 = Instant::now();
|
||||||
|
match decoder.decode(&frame.data) {
|
||||||
|
Ok(Some(decoded)) => {
|
||||||
|
total_frames += 1;
|
||||||
|
if total_frames == 1 {
|
||||||
|
let (w, h, path) = match &decoded {
|
||||||
|
DecodedFrame::Cpu(c) => (c.width, c.height, "software"),
|
||||||
|
DecodedFrame::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"),
|
||||||
|
};
|
||||||
|
tracing::info!(width = w, height = h, path, "first frame decoded");
|
||||||
|
}
|
||||||
|
// Latency: our wall clock expressed in the host's capture clock,
|
||||||
|
// minus the host-stamped capture pts (same math as client-rs).
|
||||||
|
let lat = (now_ns() as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
||||||
|
.max(0) as u64;
|
||||||
|
if lat > 0 && lat < 10_000_000_000 {
|
||||||
|
lat_us.push(lat / 1000);
|
||||||
|
}
|
||||||
|
decode_us_sum += t0.elapsed().as_micros() as u64;
|
||||||
|
frames_n += 1;
|
||||||
|
bytes_n += frame.data.len() as u64;
|
||||||
|
let _ = frame_tx.force_send(decoded);
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||||
|
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(PunktfunkError::NoFrame) => {}
|
||||||
|
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
|
||||||
|
Err(e) => break Some(format!("session: {e:?}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain audio between frames (packets land every 5 ms; the queue holds 320 ms).
|
||||||
|
while let Ok(pkt) = connector.next_audio(Duration::ZERO) {
|
||||||
|
if let (Some(player), Some(dec)) = (&player, opus_dec.as_mut()) {
|
||||||
|
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||||
|
Ok(samples) => player.push(pcm[..samples * 2].to_vec()),
|
||||||
|
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if window_start.elapsed() >= Duration::from_secs(1) {
|
||||||
|
let secs = window_start.elapsed().as_secs_f32();
|
||||||
|
lat_us.sort_unstable();
|
||||||
|
let p50 = lat_us.get(lat_us.len() / 2).copied().unwrap_or(0);
|
||||||
|
tracing::debug!(
|
||||||
|
fps = frames_n,
|
||||||
|
lat_p50_us = p50,
|
||||||
|
total_frames,
|
||||||
|
"stream window"
|
||||||
|
);
|
||||||
|
let _ = ev_tx.try_send(SessionEvent::Stats(Stats {
|
||||||
|
fps: frames_n as f32 / secs,
|
||||||
|
mbps: bytes_n as f32 * 8.0 / 1e6 / secs,
|
||||||
|
decode_ms: if frames_n > 0 {
|
||||||
|
decode_us_sum as f32 / frames_n as f32 / 1000.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
latency_ms: p50 as f32 / 1000.0,
|
||||||
|
}));
|
||||||
|
window_start = Instant::now();
|
||||||
|
frames_n = 0;
|
||||||
|
bytes_n = 0;
|
||||||
|
decode_us_sum = 0;
|
||||||
|
lat_us.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
total_frames,
|
||||||
|
reason = end.as_deref().unwrap_or("user"),
|
||||||
|
"session ended"
|
||||||
|
);
|
||||||
|
stop.store(true, Ordering::SeqCst);
|
||||||
|
let _ = ev_tx.send_blocking(SessionEvent::Ended(end));
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Client identity, the known-hosts (pinned fingerprint) store, and app settings.
|
||||||
|
//!
|
||||||
|
//! The identity shares `~/.config/punktfunk/client-{cert,key}.pem` with `punktfunk-client-rs`
|
||||||
|
//! so a box pairs once whichever client it uses.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use punktfunk_core::quic::endpoint;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn config_dir() -> Result<PathBuf> {
|
||||||
|
let home = std::env::var("HOME").context("HOME unset")?;
|
||||||
|
Ok(PathBuf::from(home).join(".config/punktfunk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This client's persistent identity, generated on first use — presented on every connect
|
||||||
|
/// so hosts can recognize it once paired.
|
||||||
|
pub fn load_or_create_identity() -> Result<(String, String)> {
|
||||||
|
let dir = config_dir()?;
|
||||||
|
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
|
||||||
|
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
|
||||||
|
return Ok((c, k));
|
||||||
|
}
|
||||||
|
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
std::fs::write(&cp, &c)?;
|
||||||
|
std::fs::write(&kp, &k)?;
|
||||||
|
tracing::info!(cert = %cp.display(), "generated client identity");
|
||||||
|
Ok((c, k))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hex(fp: &[u8; 32]) -> String {
|
||||||
|
fp.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_hex32(s: &str) -> Option<[u8; 32]> {
|
||||||
|
if s.len() != 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
for (i, b) in out.iter_mut().enumerate() {
|
||||||
|
*b = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One trusted host: its pinned certificate fingerprint plus how we got there (TOFU or a
|
||||||
|
/// PIN ceremony) and where we last reached it.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KnownHost {
|
||||||
|
pub name: String,
|
||||||
|
pub addr: String,
|
||||||
|
pub port: u16,
|
||||||
|
/// SHA-256 of the host certificate, lowercase hex — the pin for every later connect.
|
||||||
|
pub fp_hex: String,
|
||||||
|
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
||||||
|
pub paired: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
pub struct KnownHosts {
|
||||||
|
pub hosts: Vec<KnownHost>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KnownHosts {
|
||||||
|
fn path() -> Result<PathBuf> {
|
||||||
|
Ok(config_dir()?.join("client-known-hosts.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> KnownHosts {
|
||||||
|
Self::path()
|
||||||
|
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let p = Self::path()?;
|
||||||
|
std::fs::create_dir_all(p.parent().unwrap())?;
|
||||||
|
std::fs::write(&p, serde_json::to_string_pretty(self)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_fp(&self, fp_hex: &str) -> Option<&KnownHost> {
|
||||||
|
self.hosts.iter().find(|h| h.fp_hex == fp_hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_addr(&self, addr: &str, port: u16) -> Option<&KnownHost> {
|
||||||
|
self.hosts.iter().find(|h| h.addr == addr && h.port == port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or refresh an entry, keyed by fingerprint. `paired` only ever upgrades
|
||||||
|
/// (a later TOFU connect must not demote a PIN-paired host).
|
||||||
|
pub fn upsert(&mut self, entry: KnownHost) {
|
||||||
|
if let Some(h) = self.hosts.iter_mut().find(|h| h.fp_hex == entry.fp_hex) {
|
||||||
|
h.name = entry.name;
|
||||||
|
h.addr = entry.addr;
|
||||||
|
h.port = entry.port;
|
||||||
|
h.paired |= entry.paired;
|
||||||
|
} else {
|
||||||
|
self.hosts.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||||
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// Stream mode; `0` = the native size/refresh of the monitor the window is on,
|
||||||
|
/// resolved at connect time.
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub refresh_hz: u32,
|
||||||
|
/// Requested encoder bitrate (kbps); 0 = host default.
|
||||||
|
pub bitrate_kbps: u32,
|
||||||
|
pub gamepad: String,
|
||||||
|
/// Which host compositor backend to request (advisory; the host falls back to
|
||||||
|
/// auto-detect when unavailable).
|
||||||
|
pub compositor: String,
|
||||||
|
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
||||||
|
pub inhibit_shortcuts: bool,
|
||||||
|
/// Stream the default microphone to the host's virtual mic source.
|
||||||
|
pub mic_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Settings {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
refresh_hz: 0,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
gamepad: "auto".into(),
|
||||||
|
compositor: "auto".into(),
|
||||||
|
inhibit_shortcuts: true,
|
||||||
|
mic_enabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
fn path() -> Result<PathBuf> {
|
||||||
|
Ok(config_dir()?.join("client-gtk-settings.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Settings {
|
||||||
|
Self::path()
|
||||||
|
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) {
|
||||||
|
let Ok(p) = Self::path() else { return };
|
||||||
|
let _ = std::fs::create_dir_all(p.parent().unwrap());
|
||||||
|
if let Ok(s) = serde_json::to_string_pretty(self) {
|
||||||
|
let _ = std::fs::write(&p, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
//! The hosts page: saved (trusted) hosts, live mDNS discovery, manual connect entry.
|
||||||
|
|
||||||
|
use crate::discovery::{self, DiscoveredHost};
|
||||||
|
use crate::trust::KnownHosts;
|
||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::glib;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
/// What the user asked to connect to. `fp_hex` comes from the mDNS TXT record when the
|
||||||
|
/// host was discovered (drives the TOFU prompt *before* connecting); manual entries have
|
||||||
|
/// none and trust on first use.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ConnectRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub addr: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub fp_hex: Option<String>,
|
||||||
|
pub pair_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(
|
||||||
|
on_connect: Rc<dyn Fn(ConnectRequest)>,
|
||||||
|
on_settings: Rc<dyn Fn()>,
|
||||||
|
on_speed_test: Rc<dyn Fn(ConnectRequest)>,
|
||||||
|
) -> adw::NavigationPage {
|
||||||
|
let list = gtk::ListBox::new();
|
||||||
|
list.add_css_class("boxed-list");
|
||||||
|
list.set_selection_mode(gtk::SelectionMode::None);
|
||||||
|
let placeholder = gtk::Label::new(Some("Searching the LAN for hosts…"));
|
||||||
|
placeholder.add_css_class("dim-label");
|
||||||
|
placeholder.set_margin_top(24);
|
||||||
|
placeholder.set_margin_bottom(24);
|
||||||
|
list.set_placeholder(Some(&placeholder));
|
||||||
|
|
||||||
|
// key → (row, latest advert); the activation closure looks the advert up by key so
|
||||||
|
// re-adverts (new address, pairing flipped) take effect without rebuilding rows.
|
||||||
|
type Rows = Rc<RefCell<HashMap<String, (adw::ActionRow, DiscoveredHost)>>>;
|
||||||
|
let rows: Rows = Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
|
||||||
|
{
|
||||||
|
let rx = discovery::browse();
|
||||||
|
let rows = rows.clone();
|
||||||
|
let list = list.downgrade();
|
||||||
|
let on_connect = on_connect.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
while let Ok(host) = rx.recv().await {
|
||||||
|
let Some(list) = list.upgrade() else { break };
|
||||||
|
let mut map = rows.borrow_mut();
|
||||||
|
let subtitle = format!(
|
||||||
|
"{}:{} · pairing {}",
|
||||||
|
host.addr,
|
||||||
|
host.port,
|
||||||
|
if host.pair.is_empty() {
|
||||||
|
"optional"
|
||||||
|
} else {
|
||||||
|
&host.pair
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if let Some((row, stored)) = map.get_mut(&host.key) {
|
||||||
|
row.set_title(&host.name);
|
||||||
|
row.set_subtitle(&subtitle);
|
||||||
|
*stored = host;
|
||||||
|
} else {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(&host.name)
|
||||||
|
.subtitle(&subtitle)
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
{
|
||||||
|
let rows = rows.clone();
|
||||||
|
let key = host.key.clone();
|
||||||
|
let on_connect = on_connect.clone();
|
||||||
|
row.connect_activated(move |_| {
|
||||||
|
if let Some((_, h)) = rows.borrow().get(&key) {
|
||||||
|
on_connect(ConnectRequest {
|
||||||
|
name: h.name.clone(),
|
||||||
|
addr: h.addr.clone(),
|
||||||
|
port: h.port,
|
||||||
|
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||||
|
pair_required: h.pair == "required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
list.append(&row);
|
||||||
|
map.insert(host.key.clone(), (row, host));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual connect: host:port (punktfunk/1 default port 9777).
|
||||||
|
let manual = adw::EntryRow::builder().title("host:port").build();
|
||||||
|
let connect_btn = gtk::Button::with_label("Connect");
|
||||||
|
connect_btn.set_valign(gtk::Align::Center);
|
||||||
|
connect_btn.add_css_class("suggested-action");
|
||||||
|
manual.add_suffix(&connect_btn);
|
||||||
|
let submit = {
|
||||||
|
let manual = manual.clone();
|
||||||
|
let on_connect = on_connect.clone();
|
||||||
|
move || {
|
||||||
|
let text = manual.text().to_string();
|
||||||
|
let text = text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (addr, port) = match text.rsplit_once(':') {
|
||||||
|
Some((a, p)) => match p.parse::<u16>() {
|
||||||
|
Ok(port) => (a.to_string(), port),
|
||||||
|
Err(_) => return,
|
||||||
|
},
|
||||||
|
None => (text.to_string(), 9777),
|
||||||
|
};
|
||||||
|
on_connect(ConnectRequest {
|
||||||
|
name: addr.clone(),
|
||||||
|
addr,
|
||||||
|
port,
|
||||||
|
fp_hex: None,
|
||||||
|
pair_required: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let submit = submit.clone();
|
||||||
|
connect_btn.connect_clicked(move |_| submit());
|
||||||
|
}
|
||||||
|
manual.connect_entry_activated(move |_| submit());
|
||||||
|
|
||||||
|
let manual_list = gtk::ListBox::new();
|
||||||
|
manual_list.add_css_class("boxed-list");
|
||||||
|
manual_list.set_selection_mode(gtk::SelectionMode::None);
|
||||||
|
manual_list.append(&manual);
|
||||||
|
|
||||||
|
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. Rebuilt every time
|
||||||
|
// the page is shown, so fresh TOFU/pairing entries appear on return.
|
||||||
|
let saved_label = gtk::Label::new(Some("Saved hosts"));
|
||||||
|
saved_label.add_css_class("heading");
|
||||||
|
saved_label.set_halign(gtk::Align::Start);
|
||||||
|
let saved_list = gtk::ListBox::new();
|
||||||
|
saved_list.add_css_class("boxed-list");
|
||||||
|
saved_list.set_selection_mode(gtk::SelectionMode::None);
|
||||||
|
let rebuild_saved = {
|
||||||
|
let saved_list = saved_list.clone();
|
||||||
|
let saved_label = saved_label.clone();
|
||||||
|
let on_connect = on_connect.clone();
|
||||||
|
let on_speed_test = on_speed_test.clone();
|
||||||
|
move || {
|
||||||
|
saved_list.remove_all();
|
||||||
|
let known = KnownHosts::load();
|
||||||
|
saved_label.set_visible(!known.hosts.is_empty());
|
||||||
|
saved_list.set_visible(!known.hosts.is_empty());
|
||||||
|
for k in &known.hosts {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(&k.name)
|
||||||
|
.subtitle(format!(
|
||||||
|
"{}:{}{}",
|
||||||
|
k.addr,
|
||||||
|
k.port,
|
||||||
|
if k.paired {
|
||||||
|
" · paired"
|
||||||
|
} else {
|
||||||
|
" · trusted"
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
let req = ConnectRequest {
|
||||||
|
name: k.name.clone(),
|
||||||
|
addr: k.addr.clone(),
|
||||||
|
port: k.port,
|
||||||
|
fp_hex: Some(k.fp_hex.clone()),
|
||||||
|
pair_required: false,
|
||||||
|
};
|
||||||
|
let speed_btn = gtk::Button::from_icon_name("network-transmit-receive-symbolic");
|
||||||
|
speed_btn.set_tooltip_text(Some("Test network speed"));
|
||||||
|
speed_btn.set_valign(gtk::Align::Center);
|
||||||
|
speed_btn.add_css_class("flat");
|
||||||
|
{
|
||||||
|
let on_speed_test = on_speed_test.clone();
|
||||||
|
let req = req.clone();
|
||||||
|
speed_btn.connect_clicked(move |_| on_speed_test(req.clone()));
|
||||||
|
}
|
||||||
|
row.add_suffix(&speed_btn);
|
||||||
|
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
let on_connect = on_connect.clone();
|
||||||
|
row.connect_activated(move |_| on_connect(req.clone()));
|
||||||
|
saved_list.append(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rebuild_saved();
|
||||||
|
|
||||||
|
let content = gtk::Box::new(gtk::Orientation::Vertical, 18);
|
||||||
|
content.set_margin_top(24);
|
||||||
|
content.set_margin_bottom(24);
|
||||||
|
content.set_margin_start(12);
|
||||||
|
content.set_margin_end(12);
|
||||||
|
content.append(&saved_label);
|
||||||
|
content.append(&saved_list);
|
||||||
|
let discovered_label = gtk::Label::new(Some("Hosts on this network"));
|
||||||
|
discovered_label.add_css_class("heading");
|
||||||
|
discovered_label.set_halign(gtk::Align::Start);
|
||||||
|
content.append(&discovered_label);
|
||||||
|
content.append(&list);
|
||||||
|
let manual_label = gtk::Label::new(Some("Manual connection"));
|
||||||
|
manual_label.add_css_class("heading");
|
||||||
|
manual_label.set_halign(gtk::Align::Start);
|
||||||
|
content.append(&manual_label);
|
||||||
|
content.append(&manual_list);
|
||||||
|
|
||||||
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(560)
|
||||||
|
.child(&content)
|
||||||
|
.build();
|
||||||
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.child(&clamp)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
let settings_btn = gtk::Button::from_icon_name("preferences-system-symbolic");
|
||||||
|
settings_btn.set_tooltip_text(Some("Preferences"));
|
||||||
|
settings_btn.connect_clicked(move |_| on_settings());
|
||||||
|
header.pack_end(&settings_btn);
|
||||||
|
|
||||||
|
let toolbar = adw::ToolbarView::new();
|
||||||
|
toolbar.add_top_bar(&header);
|
||||||
|
toolbar.set_content(Some(&scrolled));
|
||||||
|
|
||||||
|
let page = adw::NavigationPage::builder()
|
||||||
|
.title("Punktfunk")
|
||||||
|
.tag("hosts")
|
||||||
|
.child(&toolbar)
|
||||||
|
.build();
|
||||||
|
page.connect_shown(move |_| rebuild_saved());
|
||||||
|
page
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
//! Preferences dialog: stream mode, bitrate, host compositor, gamepad type, microphone,
|
||||||
|
//! capture behavior. Written back to disk when the dialog closes.
|
||||||
|
|
||||||
|
use crate::trust::Settings;
|
||||||
|
use adw::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
|
||||||
|
const RESOLUTIONS: &[(u32, u32)] = &[
|
||||||
|
(0, 0),
|
||||||
|
(1280, 720),
|
||||||
|
(1920, 1080),
|
||||||
|
(2560, 1440),
|
||||||
|
(3840, 2160),
|
||||||
|
];
|
||||||
|
/// `0` = the monitor's native refresh, resolved at connect.
|
||||||
|
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||||
|
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"];
|
||||||
|
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||||
|
|
||||||
|
pub fn show(
|
||||||
|
parent: &impl IsA<gtk::Widget>,
|
||||||
|
settings: Rc<RefCell<Settings>>,
|
||||||
|
gamepads: &crate::gamepad::GamepadService,
|
||||||
|
) {
|
||||||
|
let page = adw::PreferencesPage::new();
|
||||||
|
|
||||||
|
let stream = adw::PreferencesGroup::builder().title("Stream").build();
|
||||||
|
let res_names: Vec<String> = RESOLUTIONS
|
||||||
|
.iter()
|
||||||
|
.map(|&(w, h)| {
|
||||||
|
if w == 0 {
|
||||||
|
"Native display".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{w} × {h}")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let res_row = adw::ComboRow::builder()
|
||||||
|
.title("Resolution")
|
||||||
|
.subtitle("The host creates a virtual output at exactly this size")
|
||||||
|
.model(>k::StringList::new(
|
||||||
|
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
let hz_names: Vec<String> = REFRESH
|
||||||
|
.iter()
|
||||||
|
.map(|&r| {
|
||||||
|
if r == 0 {
|
||||||
|
"Native".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{r} Hz")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let hz_row = adw::ComboRow::builder()
|
||||||
|
.title("Refresh rate")
|
||||||
|
.model(>k::StringList::new(
|
||||||
|
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
|
||||||
|
bitrate_row.set_title("Bitrate");
|
||||||
|
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
|
||||||
|
let compositor_row = adw::ComboRow::builder()
|
||||||
|
.title("Host compositor")
|
||||||
|
.subtitle("Advisory — the host falls back to auto-detect when unavailable")
|
||||||
|
.model(>k::StringList::new(&[
|
||||||
|
"Automatic",
|
||||||
|
"KWin",
|
||||||
|
"wlroots (Sway/Hyprland)",
|
||||||
|
"Mutter (GNOME)",
|
||||||
|
"gamescope",
|
||||||
|
]))
|
||||||
|
.build();
|
||||||
|
stream.add(&res_row);
|
||||||
|
stream.add(&hz_row);
|
||||||
|
stream.add(&bitrate_row);
|
||||||
|
stream.add(&compositor_row);
|
||||||
|
|
||||||
|
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||||
|
// Which physical controller forwards as pad 0: automatic = the most recently
|
||||||
|
// connected; pinning survives until the app exits (Swift parity).
|
||||||
|
let pads = gamepads.pads();
|
||||||
|
let mut pad_names = vec!["Automatic (most recent)".to_string()];
|
||||||
|
pad_names.extend(pads.iter().map(|p| {
|
||||||
|
if p.is_dualsense {
|
||||||
|
format!("{} · DualSense", p.name)
|
||||||
|
} else {
|
||||||
|
p.name.clone()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let forward_row = adw::ComboRow::builder()
|
||||||
|
.title("Forwarded controller")
|
||||||
|
.subtitle(if pads.is_empty() {
|
||||||
|
"No controllers detected"
|
||||||
|
} else {
|
||||||
|
"Exactly one controller is forwarded to the host"
|
||||||
|
})
|
||||||
|
.model(>k::StringList::new(
|
||||||
|
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
let pinned_i = gamepads
|
||||||
|
.pinned()
|
||||||
|
.and_then(|id| pads.iter().position(|p| p.id == id))
|
||||||
|
.map_or(0, |i| i + 1);
|
||||||
|
forward_row.set_selected(pinned_i as u32);
|
||||||
|
{
|
||||||
|
let svc = gamepads.clone();
|
||||||
|
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect();
|
||||||
|
forward_row.connect_selected_notify(move |row| {
|
||||||
|
let sel = row.selected() as usize;
|
||||||
|
svc.set_pinned(if sel == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
ids.get(sel - 1).copied()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let pad_row = adw::ComboRow::builder()
|
||||||
|
.title("Gamepad type")
|
||||||
|
.subtitle("The virtual pad the host creates — Automatic matches the physical pad")
|
||||||
|
.model(>k::StringList::new(&[
|
||||||
|
"Automatic",
|
||||||
|
"Xbox 360",
|
||||||
|
"DualSense",
|
||||||
|
]))
|
||||||
|
.build();
|
||||||
|
let inhibit_row = adw::SwitchRow::builder()
|
||||||
|
.title("Capture system shortcuts")
|
||||||
|
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||||||
|
.build();
|
||||||
|
input.add(&forward_row);
|
||||||
|
input.add(&pad_row);
|
||||||
|
input.add(&inhibit_row);
|
||||||
|
|
||||||
|
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
||||||
|
let mic_row = adw::SwitchRow::builder()
|
||||||
|
.title("Stream microphone")
|
||||||
|
.subtitle("Send the default input device to the host's virtual microphone")
|
||||||
|
.build();
|
||||||
|
audio.add(&mic_row);
|
||||||
|
|
||||||
|
page.add(&stream);
|
||||||
|
page.add(&input);
|
||||||
|
page.add(&audio);
|
||||||
|
|
||||||
|
// Seed from the current settings.
|
||||||
|
{
|
||||||
|
let s = settings.borrow();
|
||||||
|
let res_i = RESOLUTIONS
|
||||||
|
.iter()
|
||||||
|
.position(|&(w, h)| w == s.width && h == s.height)
|
||||||
|
.unwrap_or(0);
|
||||||
|
res_row.set_selected(res_i as u32);
|
||||||
|
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||||
|
hz_row.set_selected(hz_i as u32);
|
||||||
|
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||||
|
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
||||||
|
pad_row.set_selected(pad_i as u32);
|
||||||
|
let comp_i = COMPOSITORS
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == s.compositor)
|
||||||
|
.unwrap_or(0);
|
||||||
|
compositor_row.set_selected(comp_i as u32);
|
||||||
|
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||||
|
mic_row.set_active(s.mic_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dialog = adw::PreferencesDialog::new();
|
||||||
|
dialog.set_title("Preferences");
|
||||||
|
dialog.add(&page);
|
||||||
|
dialog.connect_closed(move |_| {
|
||||||
|
let mut s = settings.borrow_mut();
|
||||||
|
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
|
||||||
|
(s.width, s.height) = (w, h);
|
||||||
|
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||||
|
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||||
|
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||||
|
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||||
|
.to_string();
|
||||||
|
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||||
|
s.mic_enabled = mic_row.is_active();
|
||||||
|
s.save();
|
||||||
|
});
|
||||||
|
dialog.present(Some(parent));
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
//! The stream page: decoded frames into a `GtkGraphicsOffload`-wrapped picture, local
|
||||||
|
//! input captured and forwarded on the wire contract.
|
||||||
|
//!
|
||||||
|
//! Input capture is a deliberate, reversible STATE (Moonlight-style, mirroring the Swift
|
||||||
|
//! client): engaged when the stream starts and when the user clicks into the video (that
|
||||||
|
//! click is suppressed toward the host); released by Ctrl+Alt+Shift+Q (toggles) or focus
|
||||||
|
//! loss — held keys/buttons are flushed host-side on release so nothing sticks down.
|
||||||
|
//! While captured the local cursor is hidden (the host renders its own) and compositor
|
||||||
|
//! shortcuts are inhibited (configurable); while released nothing is forwarded and the
|
||||||
|
//! HUD says how to recapture.
|
||||||
|
//!
|
||||||
|
//! Keys are hardware keycodes (evdev + 8 on Wayland) → VK via `keymap`, layout-
|
||||||
|
//! independent. Mouse is absolute (`MouseMoveAbs` scaled into the negotiated mode through
|
||||||
|
//! the letterbox transform, surface size packed in `flags`) — pointer-lock relative
|
||||||
|
//! capture is the stage-2 presenter's job. F11 toggles fullscreen locally.
|
||||||
|
|
||||||
|
use crate::keymap;
|
||||||
|
use crate::session::Stats;
|
||||||
|
use crate::video::DecodedFrame;
|
||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::{gdk, glib};
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct StreamPage {
|
||||||
|
pub page: adw::NavigationPage,
|
||||||
|
stats_label: gtk::Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamPage {
|
||||||
|
pub fn update_stats(&self, s: Stats) {
|
||||||
|
self.stats_label.set_text(&format!(
|
||||||
|
"{:.0} fps · {:.1} Mbit/s · dec {:.1} ms · lat {:.1} ms",
|
||||||
|
s.fps, s.mbps, s.decode_ms, s.latency_ms
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||||
|
let _ = connector.send_input(&InputEvent {
|
||||||
|
kind,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
flags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward an absolute pointer position: widget coordinates → video pixels through the
|
||||||
|
/// Contain-fit letterbox. `flags` packs the coordinate-space size (`(w << 16) | h`, the
|
||||||
|
/// same contract as touch) — the host normalizes against it before mapping into the EIS
|
||||||
|
/// region; without it the event is dropped.
|
||||||
|
fn send_abs(widget: &impl IsA<gtk::Widget>, connector: &NativeClient, x: f64, y: f64) {
|
||||||
|
let w = widget.as_ref();
|
||||||
|
let mode = connector.mode();
|
||||||
|
let (ww, wh) = (w.width().max(1) as f64, w.height().max(1) as f64);
|
||||||
|
let (vw, vh) = (mode.width.max(1) as f64, mode.height.max(1) as f64);
|
||||||
|
let scale = (ww / vw).min(wh / vh);
|
||||||
|
let (ox, oy) = ((ww - vw * scale) / 2.0, (wh - vh * scale) / 2.0);
|
||||||
|
let px = (((x - ox) / scale).round()).clamp(0.0, vw - 1.0) as i32;
|
||||||
|
let py = (((y - oy) / scale).round()).clamp(0.0, vh - 1.0) as i32;
|
||||||
|
let flags = (mode.width << 16) | (mode.height & 0xffff);
|
||||||
|
send(connector, InputKind::MouseMoveAbs, 0, px, py, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The capture state shared by every input controller on the page.
|
||||||
|
struct Capture {
|
||||||
|
connector: Arc<NativeClient>,
|
||||||
|
window: adw::ApplicationWindow,
|
||||||
|
overlay: gtk::Overlay,
|
||||||
|
hint: gtk::Label,
|
||||||
|
inhibit_shortcuts: bool,
|
||||||
|
captured: Cell<bool>,
|
||||||
|
/// VKs / GameStream button ids currently held — flushed up on release.
|
||||||
|
held_keys: RefCell<HashSet<u8>>,
|
||||||
|
held_buttons: RefCell<HashSet<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capture {
|
||||||
|
fn engage(&self) {
|
||||||
|
if self.captured.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.overlay
|
||||||
|
.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
|
||||||
|
self.hint.set_visible(false);
|
||||||
|
if self.inhibit_shortcuts {
|
||||||
|
if let Some(tl) = self
|
||||||
|
.window
|
||||||
|
.surface()
|
||||||
|
.and_then(|s| s.downcast::<gdk::Toplevel>().ok())
|
||||||
|
{
|
||||||
|
tl.inhibit_system_shortcuts(None::<&gdk::Event>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(&self) {
|
||||||
|
if !self.captured.replace(false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.overlay.set_cursor(None);
|
||||||
|
self.hint.set_visible(true);
|
||||||
|
if let Some(tl) = self
|
||||||
|
.window
|
||||||
|
.surface()
|
||||||
|
.and_then(|s| s.downcast::<gdk::Toplevel>().ok())
|
||||||
|
{
|
||||||
|
tl.restore_system_shortcuts();
|
||||||
|
}
|
||||||
|
// Flush everything held so nothing sticks down on the host.
|
||||||
|
for vk in self.held_keys.borrow_mut().drain() {
|
||||||
|
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||||
|
}
|
||||||
|
for b in self.held_buttons.borrow_mut().drain() {
|
||||||
|
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
pub fn new(
|
||||||
|
window: &adw::ApplicationWindow,
|
||||||
|
connector: Arc<NativeClient>,
|
||||||
|
frames: async_channel::Receiver<DecodedFrame>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
inhibit_shortcuts: bool,
|
||||||
|
title: &str,
|
||||||
|
) -> StreamPage {
|
||||||
|
let picture = gtk::Picture::new();
|
||||||
|
picture.set_content_fit(gtk::ContentFit::Contain);
|
||||||
|
|
||||||
|
// The offload path: with a dmabuf-backed texture (stage 1.5) this becomes a
|
||||||
|
// subsurface the compositor can scan out directly; with memory textures it is a
|
||||||
|
// no-op wrapper. Black letterboxing keeps fullscreen scanout-eligible.
|
||||||
|
let offload = gtk::GraphicsOffload::new(Some(&picture));
|
||||||
|
offload.set_black_background(true);
|
||||||
|
|
||||||
|
let stats_label = gtk::Label::new(None);
|
||||||
|
stats_label.add_css_class("osd");
|
||||||
|
stats_label.add_css_class("numeric");
|
||||||
|
stats_label.set_halign(gtk::Align::Start);
|
||||||
|
stats_label.set_valign(gtk::Align::Start);
|
||||||
|
stats_label.set_margin_start(12);
|
||||||
|
stats_label.set_margin_top(12);
|
||||||
|
|
||||||
|
let hint = gtk::Label::new(Some(
|
||||||
|
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases",
|
||||||
|
));
|
||||||
|
hint.add_css_class("osd");
|
||||||
|
hint.set_halign(gtk::Align::Center);
|
||||||
|
hint.set_valign(gtk::Align::End);
|
||||||
|
hint.set_margin_bottom(24);
|
||||||
|
hint.set_visible(false);
|
||||||
|
|
||||||
|
let overlay = gtk::Overlay::new();
|
||||||
|
overlay.set_child(Some(&offload));
|
||||||
|
overlay.add_overlay(&stats_label);
|
||||||
|
overlay.add_overlay(&hint);
|
||||||
|
overlay.set_focusable(true);
|
||||||
|
|
||||||
|
let capture = Rc::new(Capture {
|
||||||
|
connector: connector.clone(),
|
||||||
|
window: window.clone(),
|
||||||
|
overlay: overlay.clone(),
|
||||||
|
hint: hint.clone(),
|
||||||
|
inhibit_shortcuts,
|
||||||
|
captured: Cell::new(false),
|
||||||
|
held_keys: RefCell::new(HashSet::new()),
|
||||||
|
held_buttons: RefCell::new(HashSet::new()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
|
||||||
|
fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)"));
|
||||||
|
{
|
||||||
|
let window = window.clone();
|
||||||
|
fullscreen_btn.connect_clicked(move |_| {
|
||||||
|
if window.is_fullscreen() {
|
||||||
|
window.unfullscreen();
|
||||||
|
} else {
|
||||||
|
window.fullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
header.pack_end(&fullscreen_btn);
|
||||||
|
|
||||||
|
let toolbar = adw::ToolbarView::new();
|
||||||
|
toolbar.add_top_bar(&header);
|
||||||
|
toolbar.set_content(Some(&overlay));
|
||||||
|
// Fullscreen = the stream and nothing else. (Window handlers are disconnected when
|
||||||
|
// the page dies — the window outlives every session.)
|
||||||
|
let fs_handler = {
|
||||||
|
let toolbar = toolbar.clone();
|
||||||
|
window.connect_fullscreened_notify(move |w| {
|
||||||
|
toolbar.set_reveal_top_bars(!w.is_fullscreen());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = adw::NavigationPage::builder()
|
||||||
|
.title(title)
|
||||||
|
.tag("stream")
|
||||||
|
.child(&toolbar)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// --- Frame consumer: newest texture wins, set on the GTK frame clock's cadence. ---
|
||||||
|
{
|
||||||
|
let picture = picture.downgrade();
|
||||||
|
// The host encodes BT.709 limited-range; without an explicit color state GDK
|
||||||
|
// would convert NV12 dmabufs with the (BT.601) dmabuf default.
|
||||||
|
let rec709 = {
|
||||||
|
let cicp = gdk::CicpParams::new();
|
||||||
|
cicp.set_color_primaries(1);
|
||||||
|
cicp.set_transfer_function(1);
|
||||||
|
cicp.set_matrix_coefficients(1);
|
||||||
|
cicp.set_range(gdk::CicpRange::Narrow);
|
||||||
|
cicp.build_color_state().ok()
|
||||||
|
};
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
while let Ok(f) = frames.recv().await {
|
||||||
|
let Some(picture) = picture.upgrade() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
match f {
|
||||||
|
DecodedFrame::Cpu(c) => {
|
||||||
|
let bytes = glib::Bytes::from_owned(c.rgba);
|
||||||
|
let tex = gdk::MemoryTexture::new(
|
||||||
|
c.width as i32,
|
||||||
|
c.height as i32,
|
||||||
|
gdk::MemoryFormat::R8g8b8a8,
|
||||||
|
&bytes,
|
||||||
|
c.stride,
|
||||||
|
);
|
||||||
|
picture.set_paintable(Some(&tex));
|
||||||
|
}
|
||||||
|
DecodedFrame::Dmabuf(d) => {
|
||||||
|
let mut b = gdk::DmabufTextureBuilder::new()
|
||||||
|
.set_display(&picture.display())
|
||||||
|
.set_width(d.width)
|
||||||
|
.set_height(d.height)
|
||||||
|
.set_fourcc(d.fourcc)
|
||||||
|
.set_modifier(d.modifier)
|
||||||
|
.set_n_planes(d.planes.len() as u32)
|
||||||
|
.set_color_state(rec709.as_ref());
|
||||||
|
for (i, p) in d.planes.iter().enumerate() {
|
||||||
|
b = unsafe { b.set_fd(i as u32, p.fd) }
|
||||||
|
.set_offset(i as u32, p.offset)
|
||||||
|
.set_stride(i as u32, p.stride);
|
||||||
|
}
|
||||||
|
let guard = d.guard;
|
||||||
|
// GDK runs the release func whether the import succeeds or not.
|
||||||
|
match unsafe { b.build_with_release_func(move || drop(guard)) } {
|
||||||
|
Ok(tex) => picture.set_paintable(Some(&tex)),
|
||||||
|
Err(e) => {
|
||||||
|
// Import rejected (format/modifier) — surfaces once per
|
||||||
|
// session in practice; the stream continues on the next
|
||||||
|
// frame, and PUNKTFUNK_DECODER=software is the escape.
|
||||||
|
tracing::warn!(error = %e, "dmabuf texture import failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Keyboard ---
|
||||||
|
{
|
||||||
|
let key = gtk::EventControllerKey::new();
|
||||||
|
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||||
|
let cap = capture.clone();
|
||||||
|
let window_k = window.clone();
|
||||||
|
key.connect_key_pressed(move |_, keyval, keycode, state| {
|
||||||
|
let chord = gdk::ModifierType::CONTROL_MASK
|
||||||
|
| gdk::ModifierType::ALT_MASK
|
||||||
|
| gdk::ModifierType::SHIFT_MASK;
|
||||||
|
if state.contains(chord) && keyval.to_lower() == gdk::Key::q {
|
||||||
|
if cap.captured.get() {
|
||||||
|
cap.release();
|
||||||
|
} else {
|
||||||
|
cap.engage();
|
||||||
|
}
|
||||||
|
return glib::Propagation::Stop;
|
||||||
|
}
|
||||||
|
if keyval == gdk::Key::F11 {
|
||||||
|
if window_k.is_fullscreen() {
|
||||||
|
window_k.unfullscreen();
|
||||||
|
} else {
|
||||||
|
window_k.fullscreen();
|
||||||
|
}
|
||||||
|
return glib::Propagation::Stop;
|
||||||
|
}
|
||||||
|
if !cap.captured.get() {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
if let Some(vk) = keycode
|
||||||
|
.checked_sub(8)
|
||||||
|
.and_then(|c| keymap::evdev_to_vk(c as u16))
|
||||||
|
{
|
||||||
|
cap.held_keys.borrow_mut().insert(vk);
|
||||||
|
send(&cap.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
|
||||||
|
}
|
||||||
|
glib::Propagation::Stop
|
||||||
|
});
|
||||||
|
let cap = capture.clone();
|
||||||
|
key.connect_key_released(move |_, _keyval, keycode, _state| {
|
||||||
|
if let Some(vk) = keycode
|
||||||
|
.checked_sub(8)
|
||||||
|
.and_then(|c| keymap::evdev_to_vk(c as u16))
|
||||||
|
{
|
||||||
|
// Flush-on-release may have beaten us to it — only forward if still held.
|
||||||
|
if cap.held_keys.borrow_mut().remove(&vk) {
|
||||||
|
send(&cap.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
overlay.add_controller(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mouse: absolute motion, buttons, wheel — forwarded only while captured ---
|
||||||
|
{
|
||||||
|
let motion = gtk::EventControllerMotion::new();
|
||||||
|
let cap = capture.clone();
|
||||||
|
motion.connect_motion(move |_, x, y| {
|
||||||
|
if cap.captured.get() {
|
||||||
|
send_abs(&cap.overlay, &cap.connector, x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
overlay.add_controller(motion);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let click = gtk::GestureClick::builder().button(0).build();
|
||||||
|
let cap = capture.clone();
|
||||||
|
click.connect_pressed(move |g, _n, x, y| {
|
||||||
|
cap.overlay.grab_focus();
|
||||||
|
if !cap.captured.get() {
|
||||||
|
cap.engage(); // the engaging click is suppressed toward the host
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send_abs(&cap.overlay, &cap.connector, x, y);
|
||||||
|
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
|
||||||
|
cap.held_buttons.borrow_mut().insert(gs);
|
||||||
|
send(&cap.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let cap = capture.clone();
|
||||||
|
click.connect_released(move |g, _n, _x, _y| {
|
||||||
|
if let Some(gs) = keymap::gdk_button_to_gs(g.current_button()) {
|
||||||
|
if cap.held_buttons.borrow_mut().remove(&gs) {
|
||||||
|
send(&cap.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
overlay.add_controller(click);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES);
|
||||||
|
let cap = capture.clone();
|
||||||
|
scroll.connect_scroll(move |_, dx, dy| {
|
||||||
|
if !cap.captured.get() {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
}
|
||||||
|
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
|
||||||
|
// positive = down. Smooth fractions survive — libei's discrete scroll is
|
||||||
|
// 120-based too.
|
||||||
|
let vy = (-dy * 120.0) as i32;
|
||||||
|
if vy != 0 {
|
||||||
|
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
|
||||||
|
}
|
||||||
|
let vx = (dx * 120.0) as i32;
|
||||||
|
if vx != 0 {
|
||||||
|
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
|
||||||
|
}
|
||||||
|
glib::Propagation::Stop
|
||||||
|
});
|
||||||
|
overlay.add_controller(scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Capture lifecycle ---
|
||||||
|
{
|
||||||
|
// Engaged when the stream starts (trust is already confirmed by then).
|
||||||
|
let cap = capture.clone();
|
||||||
|
overlay.connect_map(move |w| {
|
||||||
|
w.grab_focus();
|
||||||
|
cap.engage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Focus loss releases (Alt-Tab away, another window) — Swift does the same.
|
||||||
|
let active_handler = {
|
||||||
|
let cap = capture.clone();
|
||||||
|
window.connect_is_active_notify(move |w| {
|
||||||
|
if !w.is_active() {
|
||||||
|
cap.release();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let cap = capture.clone();
|
||||||
|
overlay.connect_unmap(move |_| cap.release());
|
||||||
|
}
|
||||||
|
// The page's `hidden` fires once navigation away completes (back button, pop on
|
||||||
|
// session end) — NOT on the transient unmap/map cycle a NavigationView push performs.
|
||||||
|
{
|
||||||
|
let window = window.clone();
|
||||||
|
let stop_h = stop.clone();
|
||||||
|
let handlers = RefCell::new(Some((fs_handler, active_handler)));
|
||||||
|
page.connect_hidden(move |_| {
|
||||||
|
tracing::debug!("stream page hidden — ending session");
|
||||||
|
if let Some((fs, active)) = handlers.borrow_mut().take() {
|
||||||
|
window.disconnect(fs);
|
||||||
|
window.disconnect(active);
|
||||||
|
}
|
||||||
|
if window.is_fullscreen() {
|
||||||
|
window.unfullscreen();
|
||||||
|
}
|
||||||
|
stop_h.store(true, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamPage { page, stats_label }
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
//! Video decode: reassembled HEVC access units → frames for the GTK presenter.
|
||||||
|
//!
|
||||||
|
//! Two backends, picked at session start (override: `PUNKTFUNK_DECODER=software|vaapi`):
|
||||||
|
//!
|
||||||
|
//! * **VAAPI** (Intel/AMD): libavcodec hwaccel decodes on the GPU; each frame is mapped
|
||||||
|
//! to a DRM-PRIME dmabuf (`av_hwframe_map`, zero copy) and handed to the UI as fds +
|
||||||
|
//! plane layout for `GdkDmabufTextureBuilder` — inside `GtkGraphicsOffload` that is the
|
||||||
|
//! decoder-to-subsurface path, direct-scanout eligible when fullscreen. NVIDIA boxes
|
||||||
|
//! have no usable VAAPI (nvidia-vaapi-driver is broken for this — Moonlight blacklists
|
||||||
|
//! it); device creation fails there and the software path takes over. A mid-session
|
||||||
|
//! VAAPI error also falls back — the host's IDR/RFI recovery resynchronizes.
|
||||||
|
//! * **Software**: libavcodec on the CPU + swscale to RGBA (`GdkMemoryTexture` upload).
|
||||||
|
//! Slice threading only — frame threading would add a frame of latency per thread.
|
||||||
|
//!
|
||||||
|
//! Both run `AV_CODEC_FLAG_LOW_DELAY`; the host encodes zero-reorder streams (no
|
||||||
|
//! B-frames, in-band parameter sets on every IDR), so decode is strictly one-in/one-out.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context as _, Result};
|
||||||
|
use ffmpeg::format::Pixel;
|
||||||
|
use ffmpeg::software::scaling;
|
||||||
|
use ffmpeg::util::frame::Video as AvFrame;
|
||||||
|
use ffmpeg_next as ffmpeg;
|
||||||
|
use std::os::fd::RawFd;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
pub enum DecodedFrame {
|
||||||
|
Cpu(CpuFrame),
|
||||||
|
Dmabuf(DmabufFrame),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
|
||||||
|
pub struct CpuFrame {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// RGBA row stride in bytes (≥ width*4 — swscale pads rows for SIMD).
|
||||||
|
pub stride: usize,
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decoded frame still on the GPU: dmabuf fds + plane layout for
|
||||||
|
/// `GdkDmabufTextureBuilder`. The fds belong to `guard`'s mapped DRM frame — they stay
|
||||||
|
/// valid until the guard drops (the texture's release func).
|
||||||
|
pub struct DmabufFrame {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// DRM fourcc of the layer (NV12 for 8-bit VAAPI output).
|
||||||
|
pub fourcc: u32,
|
||||||
|
pub modifier: u64,
|
||||||
|
pub planes: Vec<DmabufPlane>,
|
||||||
|
pub guard: DrmFrameGuard,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DmabufPlane {
|
||||||
|
pub fd: RawFd,
|
||||||
|
pub offset: u32,
|
||||||
|
pub stride: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the mapped DRM-PRIME `AVFrame` (which in turn references the VAAPI surface).
|
||||||
|
/// Dropping it releases the surface back to the decoder pool and closes the fds.
|
||||||
|
pub struct DrmFrameGuard(*mut ffmpeg::ffi::AVFrame);
|
||||||
|
// An AVFrame is plain refcounted data; freeing it from the GTK main thread is fine.
|
||||||
|
unsafe impl Send for DrmFrameGuard {}
|
||||||
|
|
||||||
|
impl Drop for DrmFrameGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { ffmpeg::ffi::av_frame_free(&mut self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Backend {
|
||||||
|
Vaapi(VaapiDecoder),
|
||||||
|
Software(SoftwareDecoder),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Decoder {
|
||||||
|
backend: Backend,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Decoder {
|
||||||
|
pub fn new() -> Result<Decoder> {
|
||||||
|
ffmpeg::init().context("ffmpeg init")?;
|
||||||
|
let choice = std::env::var("PUNKTFUNK_DECODER").unwrap_or_default();
|
||||||
|
if choice != "software" {
|
||||||
|
match VaapiDecoder::new() {
|
||||||
|
Ok(v) => {
|
||||||
|
tracing::info!("VAAPI hardware decode active (zero-copy dmabuf)");
|
||||||
|
return Ok(Decoder {
|
||||||
|
backend: Backend::Vaapi(v),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if choice == "vaapi" {
|
||||||
|
return Err(e.context("PUNKTFUNK_DECODER=vaapi but VAAPI failed"));
|
||||||
|
}
|
||||||
|
tracing::info!(reason = %e, "VAAPI unavailable — software decode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Decoder {
|
||||||
|
backend: Backend::Software(SoftwareDecoder::new()?),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one access unit; returns the decoded frame (the host's streams are
|
||||||
|
/// one-in/one-out). A software decode error after packet loss is survivable — log
|
||||||
|
/// upstream and keep feeding. A VAAPI error demotes to software for the rest of the
|
||||||
|
/// session (broken driver, e.g. nvidia-vaapi-driver) — the next IDR resynchronizes.
|
||||||
|
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedFrame>> {
|
||||||
|
match &mut self.backend {
|
||||||
|
Backend::Vaapi(v) => match v.decode(au) {
|
||||||
|
Ok(f) => Ok(f.map(DecodedFrame::Dmabuf)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
|
||||||
|
self.backend = Backend::Software(SoftwareDecoder::new()?);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Backend::Software(s) => Ok(s.decode(au)?.map(DecodedFrame::Cpu)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- software backend ---------------------------------------------------------------
|
||||||
|
|
||||||
|
struct SoftwareDecoder {
|
||||||
|
decoder: ffmpeg::decoder::Video,
|
||||||
|
/// Rebuilt whenever the decoded format/size changes (mid-stream `Reconfigure`).
|
||||||
|
sws: Option<(scaling::Context, Pixel, u32, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoftwareDecoder {
|
||||||
|
fn new() -> Result<SoftwareDecoder> {
|
||||||
|
let codec =
|
||||||
|
ffmpeg::decoder::find(ffmpeg::codec::Id::HEVC).ok_or(anyhow!("no HEVC decoder"))?;
|
||||||
|
let mut ctx = ffmpeg::codec::Context::new_with_codec(codec);
|
||||||
|
unsafe {
|
||||||
|
let raw = ctx.as_mut_ptr();
|
||||||
|
(*raw).flags |= ffmpeg::ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||||
|
// Slice threading adds no frame delay (frame threading adds thread_count-1).
|
||||||
|
(*raw).thread_type = ffmpeg::ffi::FF_THREAD_SLICE;
|
||||||
|
(*raw).thread_count = 0; // auto
|
||||||
|
}
|
||||||
|
let decoder = ctx.decoder().video().context("open HEVC decoder")?;
|
||||||
|
Ok(SoftwareDecoder { decoder, sws: None })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(&mut self, au: &[u8]) -> Result<Option<CpuFrame>> {
|
||||||
|
let packet = ffmpeg::Packet::copy(au);
|
||||||
|
self.decoder
|
||||||
|
.send_packet(&packet)
|
||||||
|
.map_err(|e| anyhow!("send_packet: {e}"))?;
|
||||||
|
let mut frame = AvFrame::empty();
|
||||||
|
let mut out = None;
|
||||||
|
while self.decoder.receive_frame(&mut frame).is_ok() {
|
||||||
|
out = Some(self.convert_rgba(&frame)?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_rgba(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
|
||||||
|
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
|
||||||
|
let rebuild =
|
||||||
|
!matches!(&self.sws, Some((_, f, sw, sh)) if *f == fmt && *sw == w && *sh == h);
|
||||||
|
if rebuild {
|
||||||
|
let ctx = scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT)
|
||||||
|
.context("swscale context")?;
|
||||||
|
self.sws = Some((ctx, fmt, w, h));
|
||||||
|
}
|
||||||
|
let (sws, ..) = self.sws.as_mut().unwrap();
|
||||||
|
let mut rgba = AvFrame::empty();
|
||||||
|
sws.run(frame, &mut rgba).map_err(|e| anyhow!("sws: {e}"))?;
|
||||||
|
Ok(CpuFrame {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
stride: rgba.stride(0),
|
||||||
|
rgba: rgba.data(0).to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- VAAPI backend --------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Raw FFI: ffmpeg-next has no hwaccel wrappers. All pointers are owned here and freed in
|
||||||
|
// Drop; decoded surfaces transfer out through DrmFrameGuard.
|
||||||
|
|
||||||
|
const AVERROR_EAGAIN: i32 = -11; // -EAGAIN; Linux-only crate
|
||||||
|
|
||||||
|
fn averr(what: &str, code: i32) -> anyhow::Error {
|
||||||
|
anyhow!("{what}: {}", ffmpeg::Error::from(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// libavcodec offers the formats it can decode into; pick the VAAPI hw surface. Falling
|
||||||
|
/// back to the first (software) entry would silently decode on the CPU *and* break our
|
||||||
|
/// dmabuf mapping — return NONE instead so the error surfaces and the session demotes
|
||||||
|
/// to the software backend explicitly.
|
||||||
|
unsafe extern "C" fn pick_vaapi(
|
||||||
|
_ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||||
|
mut list: *const ffmpeg::ffi::AVPixelFormat,
|
||||||
|
) -> ffmpeg::ffi::AVPixelFormat {
|
||||||
|
unsafe {
|
||||||
|
while *list != ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE {
|
||||||
|
if *list == ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VAAPI {
|
||||||
|
return ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||||
|
}
|
||||||
|
list = list.add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VaapiDecoder {
|
||||||
|
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||||
|
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||||
|
packet: *mut ffmpeg::ffi::AVPacket,
|
||||||
|
frame: *mut ffmpeg::ffi::AVFrame,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-owner pointers, only touched from the session pump thread.
|
||||||
|
unsafe impl Send for VaapiDecoder {}
|
||||||
|
|
||||||
|
impl VaapiDecoder {
|
||||||
|
fn new() -> Result<VaapiDecoder> {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
let mut hw_device: *mut ffi::AVBufferRef = ptr::null_mut();
|
||||||
|
let r = ffi::av_hwdevice_ctx_create(
|
||||||
|
&mut hw_device,
|
||||||
|
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||||
|
ptr::null(),
|
||||||
|
ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if r < 0 {
|
||||||
|
bail!("no VAAPI device ({})", ffmpeg::Error::from(r));
|
||||||
|
}
|
||||||
|
let codec = ffi::avcodec_find_decoder(ffi::AVCodecID::AV_CODEC_ID_HEVC);
|
||||||
|
if codec.is_null() {
|
||||||
|
ffi::av_buffer_unref(&mut hw_device);
|
||||||
|
bail!("no HEVC decoder");
|
||||||
|
}
|
||||||
|
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||||
|
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||||
|
(*ctx).get_format = Some(pick_vaapi);
|
||||||
|
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||||
|
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||||
|
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
|
||||||
|
if r < 0 {
|
||||||
|
let mut ctx = ctx;
|
||||||
|
ffi::avcodec_free_context(&mut ctx);
|
||||||
|
let mut hw_device = hw_device;
|
||||||
|
ffi::av_buffer_unref(&mut hw_device);
|
||||||
|
bail!("avcodec_open2: {}", ffmpeg::Error::from(r));
|
||||||
|
}
|
||||||
|
Ok(VaapiDecoder {
|
||||||
|
ctx,
|
||||||
|
hw_device,
|
||||||
|
packet: ffi::av_packet_alloc(),
|
||||||
|
frame: ffi::av_frame_alloc(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(&mut self, au: &[u8]) -> Result<Option<DmabufFrame>> {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
let r = ffi::av_new_packet(self.packet, au.len() as i32);
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("av_new_packet", r));
|
||||||
|
}
|
||||||
|
ptr::copy_nonoverlapping(au.as_ptr(), (*self.packet).data, au.len());
|
||||||
|
let r = ffi::avcodec_send_packet(self.ctx, self.packet);
|
||||||
|
ffi::av_packet_unref(self.packet);
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("send_packet", r));
|
||||||
|
}
|
||||||
|
let mut out = None;
|
||||||
|
loop {
|
||||||
|
let r = ffi::avcodec_receive_frame(self.ctx, self.frame);
|
||||||
|
if r == AVERROR_EAGAIN {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if r < 0 {
|
||||||
|
return Err(averr("receive_frame", r));
|
||||||
|
}
|
||||||
|
out = Some(self.map_dmabuf()?); // newest wins; older guards drop here
|
||||||
|
ffi::av_frame_unref(self.frame);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the VAAPI surface to DRM PRIME (zero copy) and lift the descriptor into a
|
||||||
|
/// `DmabufFrame`. The mapped frame keeps the surface alive via its buffer refs.
|
||||||
|
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
|
||||||
|
bail!("decoder returned a software frame (no VAAPI surface)");
|
||||||
|
}
|
||||||
|
let drm = ffi::av_frame_alloc();
|
||||||
|
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
|
||||||
|
let r = ffi::av_hwframe_map(drm, self.frame, ffi::AV_HWFRAME_MAP_READ as i32);
|
||||||
|
if r < 0 {
|
||||||
|
let mut drm = drm;
|
||||||
|
ffi::av_frame_free(&mut drm);
|
||||||
|
return Err(averr("av_hwframe_map", r));
|
||||||
|
}
|
||||||
|
let desc = (*drm).data[0] as *const ffi::AVDRMFrameDescriptor;
|
||||||
|
let guard = DrmFrameGuard(drm);
|
||||||
|
let d = &*desc;
|
||||||
|
if d.nb_layers < 1 {
|
||||||
|
bail!("DRM descriptor without layers");
|
||||||
|
}
|
||||||
|
let layer = &d.layers[0];
|
||||||
|
let mut planes = Vec::with_capacity(layer.nb_planes as usize);
|
||||||
|
for p in &layer.planes[..layer.nb_planes as usize] {
|
||||||
|
let obj = &d.objects[p.object_index as usize];
|
||||||
|
planes.push(DmabufPlane {
|
||||||
|
fd: obj.fd,
|
||||||
|
offset: p.offset as u32,
|
||||||
|
stride: p.pitch as u32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(DmabufFrame {
|
||||||
|
width: (*self.frame).width as u32,
|
||||||
|
height: (*self.frame).height as u32,
|
||||||
|
fourcc: layer.format,
|
||||||
|
modifier: d.objects[0].format_modifier,
|
||||||
|
planes,
|
||||||
|
guard,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for VaapiDecoder {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
use ffmpeg::ffi;
|
||||||
|
unsafe {
|
||||||
|
ffi::av_packet_free(&mut self.packet);
|
||||||
|
ffi::av_frame_free(&mut self.frame);
|
||||||
|
ffi::avcodec_free_context(&mut self.ctx);
|
||||||
|
ffi::av_buffer_unref(&mut self.hw_device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -371,6 +371,9 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
compositor: args.compositor,
|
compositor: args.compositor,
|
||||||
gamepad: args.gamepad,
|
gamepad: args.gamepad,
|
||||||
bitrate_kbps: args.bitrate_kbps,
|
bitrate_kbps: args.bitrate_kbps,
|
||||||
|
// `--name` (also the pairing label) — shown in the host's pending-approval list when
|
||||||
|
// this client knocks on a pairing-required host.
|
||||||
|
name: Some(args.name.clone()),
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
@@ -525,6 +528,7 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
// low-latency input path without a real input device.
|
// low-latency input path without a real input device.
|
||||||
if args.input_test {
|
if args.input_test {
|
||||||
let conn2 = conn.clone();
|
let conn2 = conn.clone();
|
||||||
|
let (mw, mh) = (args.mode.width, args.mode.height);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
tracing::info!("input-test: sending scripted datagrams for ~6s");
|
tracing::info!("input-test: sending scripted datagrams for ~6s");
|
||||||
@@ -544,6 +548,17 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
flags: 0,
|
flags: 0,
|
||||||
};
|
};
|
||||||
let _ = conn2.send_datagram(mv.encode().to_vec().into());
|
let _ = conn2.send_datagram(mv.encode().to_vec().into());
|
||||||
|
// Absolute motion too (the GTK client's path): a diagonal sweep, with the
|
||||||
|
// coordinate-space size packed in `flags` — the contract injectors require.
|
||||||
|
let abs = InputEvent {
|
||||||
|
kind: InputKind::MouseMoveAbs,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code: 0,
|
||||||
|
x: ((i * mw) / 160) as i32,
|
||||||
|
y: ((i * mh) / 160) as i32,
|
||||||
|
flags: (mw << 16) | (mh & 0xffff),
|
||||||
|
};
|
||||||
|
let _ = conn2.send_datagram(abs.encode().to_vec().into());
|
||||||
if i % 20 == 0 {
|
if i % 20 == 0 {
|
||||||
for kind in [InputKind::KeyDown, InputKind::KeyUp] {
|
for kind in [InputKind::KeyDown, InputKind::KeyUp] {
|
||||||
let key = InputEvent {
|
let key = InputEvent {
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ hmac = { version = "0.12", optional = true }
|
|||||||
spake2 = { version = "0.4", optional = true }
|
spake2 = { version = "0.4", optional = true }
|
||||||
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
|
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
|
||||||
|
|
||||||
# `sendmmsg` batched UDP send (the 1 Gbps+ syscall lever) — Linux only; other targets use the
|
# `libc` for batched UDP syscalls: `sendmmsg`/`recvmmsg` on Linux (the 1 Gbps+ lever) and the
|
||||||
# scalar `send` loop fallback.
|
# `recv(MSG_DONTWAIT)` drain on the other unix (Apple/BSD) targets, which have no `recvmmsg`
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
# (see transport/udp.rs `recv_batch`). Needed on every unix target — non-unix (Windows) uses
|
||||||
|
# the scalar fallbacks. Cross-compiles (iOS/tvOS) don't pull libc transitively the way the
|
||||||
|
# macOS host build does, so it must be a direct dep here or those slices fail to link `libc::`.
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ documentation_style = "c99"
|
|||||||
[parse]
|
[parse]
|
||||||
parse_deps = false
|
parse_deps = false
|
||||||
|
|
||||||
|
[export]
|
||||||
|
# Internal Apple-only FFI (transport/udp.rs `recvmsg_x` batched recv + its `MsghdrX`) — NOT part of
|
||||||
|
# the C ABI. cbindgen otherwise sweeps the foreign import and its #[repr(C)] struct into the header,
|
||||||
|
# where socklen_t/ssize_t/iovec are undefined and the C harness fails to compile.
|
||||||
|
exclude = ["MsghdrX", "recvmsg_x"]
|
||||||
|
|
||||||
[export.rename]
|
[export.rename]
|
||||||
"InputEvent" = "PunktfunkInputEvent"
|
"InputEvent" = "PunktfunkInputEvent"
|
||||||
"InputKind" = "PunktfunkInputKind"
|
"InputKind" = "PunktfunkInputKind"
|
||||||
|
|||||||
@@ -1383,6 +1383,32 @@ pub unsafe extern "C" fn punktfunk_connection_request_mode(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the host's encoder to emit a fresh IDR keyframe now — client recovery when the
|
||||||
|
/// decoder has stalled (the infinite-GOP stream sends one opening IDR then P-frames only, so
|
||||||
|
/// a wedged decoder would otherwise freeze until the next loss-triggered recovery keyframe).
|
||||||
|
/// Non-blocking, fire-and-forget; the recovered keyframe is the only ack. The caller should
|
||||||
|
/// THROTTLE — the decode stays wedged for several frames until the IDR lands, so requesting
|
||||||
|
/// every frame would flood the control stream.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
|
||||||
|
c: *const PunktfunkConnection,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
match c.inner.request_keyframe() {
|
||||||
|
Ok(()) => PunktfunkStatus::Ok,
|
||||||
|
Err(e) => e.status(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
/// A speed-test measurement, filled by [`punktfunk_connection_probe_result`]. `done` is 0 until
|
||||||
/// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
/// the host's end-of-burst report lands, then 1 (the numbers are final). `throughput_kbps` is the
|
||||||
/// measured goodput to drive a bitrate choice from; `loss_pct` is the delivery loss at that rate.
|
/// measured goodput to drive a bitrate choice from; `loss_pct` is the delivery loss at that rate.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use crate::input::InputEvent;
|
|||||||
use crate::packet::FLAG_PROBE;
|
use crate::packet::FLAG_PROBE;
|
||||||
use crate::quic::{
|
use crate::quic::{
|
||||||
endpoint, io, Hello, HidOutput, ProbeRequest, ProbeResult, Reconfigure, Reconfigured,
|
endpoint, io, Hello, HidOutput, ProbeRequest, ProbeResult, Reconfigure, Reconfigured,
|
||||||
RichInput, Start, Welcome,
|
RequestKeyframe, RichInput, Start, Welcome,
|
||||||
};
|
};
|
||||||
use crate::session::{Frame, Session};
|
use crate::session::{Frame, Session};
|
||||||
use crate::transport::UdpTransport;
|
use crate::transport::UdpTransport;
|
||||||
@@ -32,6 +32,7 @@ use std::time::{Duration, Instant};
|
|||||||
enum CtrlRequest {
|
enum CtrlRequest {
|
||||||
Mode(Mode),
|
Mode(Mode),
|
||||||
Probe(ProbeRequest),
|
Probe(ProbeRequest),
|
||||||
|
Keyframe,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the negotiated
|
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the negotiated
|
||||||
@@ -108,11 +109,15 @@ pub struct AudioPacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct NativeClient {
|
pub struct NativeClient {
|
||||||
frames: Receiver<Frame>,
|
// Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust
|
||||||
audio: Receiver<AudioPacket>,
|
// embedders can share one `Arc<NativeClient>` across their plane threads (the same
|
||||||
rumble: Receiver<(u16, u16, u16)>,
|
// one-thread-per-plane contract the C ABI documents — the lock is uncontended there,
|
||||||
|
// and two threads racing one plane now serialize instead of being undefined).
|
||||||
|
frames: Mutex<Receiver<Frame>>,
|
||||||
|
audio: Mutex<Receiver<AudioPacket>>,
|
||||||
|
rumble: Mutex<Receiver<(u16, u16, u16)>>,
|
||||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||||
hidout: Receiver<HidOutput>,
|
hidout: Mutex<Receiver<HidOutput>>,
|
||||||
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||||
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
||||||
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
|
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
|
||||||
@@ -234,10 +239,10 @@ impl NativeClient {
|
|||||||
};
|
};
|
||||||
*mode_slot.lock().unwrap() = negotiated;
|
*mode_slot.lock().unwrap() = negotiated;
|
||||||
Ok(NativeClient {
|
Ok(NativeClient {
|
||||||
frames: frame_rx,
|
frames: Mutex::new(frame_rx),
|
||||||
audio: audio_rx,
|
audio: Mutex::new(audio_rx),
|
||||||
rumble: rumble_rx,
|
rumble: Mutex::new(rumble_rx),
|
||||||
hidout: hidout_rx,
|
hidout: Mutex::new(hidout_rx),
|
||||||
input_tx,
|
input_tx,
|
||||||
mic_tx,
|
mic_tx,
|
||||||
rich_input_tx,
|
rich_input_tx,
|
||||||
@@ -361,6 +366,16 @@ impl NativeClient {
|
|||||||
.map_err(|_| PunktfunkError::Closed)
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the host's encoder to emit a fresh IDR keyframe now (client recovery on a stalled
|
||||||
|
/// decode). Non-blocking, fire-and-forget — the recovered keyframe is the only ack. The
|
||||||
|
/// caller should throttle (the decode stays wedged across several frames until the IDR
|
||||||
|
/// lands, so requesting on every frame would flood the control stream).
|
||||||
|
pub fn request_keyframe(&self) -> Result<()> {
|
||||||
|
self.ctrl_tx
|
||||||
|
.send(CtrlRequest::Keyframe)
|
||||||
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||||
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
|
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
|
||||||
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
|
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
|
||||||
@@ -419,7 +434,7 @@ impl NativeClient {
|
|||||||
/// (`&self` here supports the cross-plane sharing; a plane's queue is still
|
/// (`&self` here supports the cross-plane sharing; a plane's queue is still
|
||||||
/// single-consumer by contract).
|
/// single-consumer by contract).
|
||||||
pub fn next_frame(&self, timeout: Duration) -> Result<Frame> {
|
pub fn next_frame(&self, timeout: Duration) -> Result<Frame> {
|
||||||
match self.frames.recv_timeout(timeout) {
|
match self.frames.lock().unwrap().recv_timeout(timeout) {
|
||||||
Ok(f) => Ok(f),
|
Ok(f) => Ok(f),
|
||||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
||||||
@@ -430,7 +445,7 @@ impl NativeClient {
|
|||||||
/// [`PunktfunkError::Closed`] once the session ended. Drain on a dedicated audio thread —
|
/// [`PunktfunkError::Closed`] once the session ended. Drain on a dedicated audio thread —
|
||||||
/// packets arrive every 5 ms.
|
/// packets arrive every 5 ms.
|
||||||
pub fn next_audio(&self, timeout: Duration) -> Result<AudioPacket> {
|
pub fn next_audio(&self, timeout: Duration) -> Result<AudioPacket> {
|
||||||
match self.audio.recv_timeout(timeout) {
|
match self.audio.lock().unwrap().recv_timeout(timeout) {
|
||||||
Ok(p) => Ok(p),
|
Ok(p) => Ok(p),
|
||||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
||||||
@@ -440,7 +455,7 @@ impl NativeClient {
|
|||||||
/// Pull the next rumble update `(pad, low, high)`; same semantics as
|
/// Pull the next rumble update `(pad, low, high)`; same semantics as
|
||||||
/// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop.
|
/// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop.
|
||||||
pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> {
|
pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> {
|
||||||
match self.rumble.recv_timeout(timeout) {
|
match self.rumble.lock().unwrap().recv_timeout(timeout) {
|
||||||
Ok(r) => Ok(r),
|
Ok(r) => Ok(r),
|
||||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
||||||
@@ -452,7 +467,7 @@ impl NativeClient {
|
|||||||
/// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's
|
/// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's
|
||||||
/// `GCDualSenseAdaptiveTrigger` API). Only the DualSense host backend emits these.
|
/// `GCDualSenseAdaptiveTrigger` API). Only the DualSense host backend emits these.
|
||||||
pub fn next_hidout(&self, timeout: Duration) -> Result<HidOutput> {
|
pub fn next_hidout(&self, timeout: Duration) -> Result<HidOutput> {
|
||||||
match self.hidout.recv_timeout(timeout) {
|
match self.hidout.lock().unwrap().recv_timeout(timeout) {
|
||||||
Ok(h) => Ok(h),
|
Ok(h) => Ok(h),
|
||||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
||||||
@@ -579,6 +594,9 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
compositor,
|
compositor,
|
||||||
gamepad,
|
gamepad,
|
||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
|
// No device name yet: the connect ABI has no name parameter (pairing does). The
|
||||||
|
// host falls back to a fingerprint-derived label in its pending-approval list.
|
||||||
|
name: None,
|
||||||
}
|
}
|
||||||
.encode(),
|
.encode(),
|
||||||
)
|
)
|
||||||
@@ -709,6 +727,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
let bytes = match req {
|
let bytes = match req {
|
||||||
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
|
CtrlRequest::Mode(m) => Reconfigure { mode: m }.encode(),
|
||||||
CtrlRequest::Probe(p) => p.encode(),
|
CtrlRequest::Probe(p) => p.encode(),
|
||||||
|
CtrlRequest::Keyframe => RequestKeyframe.encode(),
|
||||||
};
|
};
|
||||||
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
use crate::config::Role;
|
use crate::config::Role;
|
||||||
use crate::error::{PunktfunkError, Result};
|
use crate::error::{PunktfunkError, Result};
|
||||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload};
|
||||||
use aes_gcm::{Aes128Gcm, Key, Nonce};
|
use aes_gcm::{Aes128Gcm, Key, Nonce};
|
||||||
|
|
||||||
/// 16-byte AEAD authentication tag appended by GCM.
|
/// 16-byte AEAD authentication tag appended by GCM.
|
||||||
@@ -60,6 +60,23 @@ impl SessionCrypto {
|
|||||||
.map_err(|_| PunktfunkError::Crypto)
|
.map_err(|_| PunktfunkError::Crypto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seal in place, no per-packet allocation: `buf` is laid out as `[plaintext .. ][TAG_LEN]` (the
|
||||||
|
/// last `TAG_LEN` bytes are scratch); on return it holds `[ciphertext .. ][tag]` — byte-identical
|
||||||
|
/// to `seal`'s `ciphertext || tag`, just written in place. The hot-path sealer (`Session`) uses
|
||||||
|
/// this to avoid the `Vec` that `seal`'s convenience API allocates for every packet.
|
||||||
|
pub fn seal_in_place(&self, seq: u64, buf: &mut [u8]) -> Result<()> {
|
||||||
|
debug_assert!(buf.len() >= TAG_LEN);
|
||||||
|
let nonce = nonce(self.send_salt, seq);
|
||||||
|
let split = buf.len() - TAG_LEN;
|
||||||
|
let (plaintext, tag_slot) = buf.split_at_mut(split);
|
||||||
|
let tag = self
|
||||||
|
.cipher
|
||||||
|
.encrypt_in_place_detached(Nonce::from_slice(&nonce), &seq.to_be_bytes(), plaintext)
|
||||||
|
.map_err(|_| PunktfunkError::Crypto)?;
|
||||||
|
tag_slot.copy_from_slice(&tag);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Open `ciphertext || tag` for sequence `seq` (also bound as associated data).
|
/// Open `ciphertext || tag` for sequence `seq` (also bound as associated data).
|
||||||
pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result<Vec<u8>> {
|
pub fn open(&self, seq: u64, ciphertext: &[u8]) -> Result<Vec<u8>> {
|
||||||
let nonce = nonce(self.recv_salt, seq);
|
let nonce = nonce(self.recv_salt, seq);
|
||||||
@@ -146,4 +163,28 @@ mod tests {
|
|||||||
client.seal(0, b"abc").unwrap()
|
client.seal(0, b"abc").unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seal_in_place_matches_seal_and_opens() {
|
||||||
|
let key = random_key();
|
||||||
|
let salt = random_salt();
|
||||||
|
let host = SessionCrypto::new(&key, salt, Role::Host);
|
||||||
|
let client = SessionCrypto::new(&key, salt, Role::Client);
|
||||||
|
for msg in [
|
||||||
|
&b""[..],
|
||||||
|
b"x",
|
||||||
|
b"the quick brown fox jumps over 13 lazy dogs!!",
|
||||||
|
] {
|
||||||
|
let reference = host.seal(7, msg).unwrap(); // ciphertext || tag
|
||||||
|
// In-place: [plaintext .. ][TAG_LEN scratch].
|
||||||
|
let mut buf = msg.to_vec();
|
||||||
|
buf.resize(msg.len() + TAG_LEN, 0);
|
||||||
|
host.seal_in_place(7, &mut buf).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
buf, reference,
|
||||||
|
"in-place seal must be byte-identical to seal"
|
||||||
|
);
|
||||||
|
assert_eq!(client.open(7, &buf).unwrap(), msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ pub enum InputKind {
|
|||||||
KeyUp = 1,
|
KeyUp = 1,
|
||||||
/// Relative motion: `x`/`y` carry `dx`/`dy`.
|
/// Relative motion: `x`/`y` carry `dx`/`dy`.
|
||||||
MouseMove = 2,
|
MouseMove = 2,
|
||||||
/// Absolute motion: `x`/`y` carry pixel coordinates.
|
/// Absolute motion: `x`/`y` carry pixel coordinates and `flags` packs the client's
|
||||||
|
/// coordinate-space size as `(width << 16) | height` (the same contract as
|
||||||
|
/// [`TouchDown`](Self::TouchDown)) — injectors normalize against it before mapping
|
||||||
|
/// into the output region and **drop the event when it is zero**.
|
||||||
MouseMoveAbs = 3,
|
MouseMoveAbs = 3,
|
||||||
MouseButtonDown = 4,
|
MouseButtonDown = 4,
|
||||||
MouseButtonUp = 5,
|
MouseButtonUp = 5,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
|
|||||||
|
|
||||||
/// `client → host`: open the session, requesting a display mode (the host creates its
|
/// `client → host`: open the session, requesting a display mode (the host creates its
|
||||||
/// virtual output at exactly this size/refresh — native resolution end to end).
|
/// virtual output at exactly this size/refresh — native resolution end to end).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct Hello {
|
pub struct Hello {
|
||||||
pub abi_version: u32,
|
pub abi_version: u32,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
@@ -57,8 +57,17 @@ pub struct Hello {
|
|||||||
/// the value it actually configured in [`Welcome::bitrate_kbps`]. Appended to the wire form —
|
/// the value it actually configured in [`Welcome::bitrate_kbps`]. Appended to the wire form —
|
||||||
/// omitted by older clients (decodes to `0`, i.e. host default).
|
/// omitted by older clients (decodes to `0`, i.e. host default).
|
||||||
pub bitrate_kbps: u32,
|
pub bitrate_kbps: u32,
|
||||||
|
/// Human-readable device name ("Enrico's MacBook"), shown by the host when this device knocks
|
||||||
|
/// on a pairing-required host (the delegated-approval pending list) and stored on approval.
|
||||||
|
/// Appended to the wire form as `len u8 || UTF-8` (≤ [`HELLO_NAME_MAX`] bytes) — omitted by
|
||||||
|
/// older clients (decodes to `None`; the host falls back to a fingerprint-derived label).
|
||||||
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
|
||||||
|
/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
|
||||||
|
pub const HELLO_NAME_MAX: usize = 64;
|
||||||
|
|
||||||
/// `host → client`: the complete session offer.
|
/// `host → client`: the complete session offer.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct Welcome {
|
pub struct Welcome {
|
||||||
@@ -117,6 +126,16 @@ pub struct Reconfigured {
|
|||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `client → host`, any time after [`Start`]: ask the host's encoder to emit a fresh IDR
|
||||||
|
/// keyframe NOW. The infinite-GOP stream opens with one IDR then sends P-frames only, so a
|
||||||
|
/// decoder that wedges (a lost/corrupt opening IDR, a bad early P-frame — most likely on the
|
||||||
|
/// cold first session) would otherwise stay frozen until the next loss-triggered recovery
|
||||||
|
/// keyframe, which may be far off. The client sends this when it detects a stalled decode;
|
||||||
|
/// the host forces the next frame to be an IDR with in-band parameter sets, recovering the
|
||||||
|
/// picture in ~one frame. Fire-and-forget — no reply (the recovered IDR is the ack).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RequestKeyframe;
|
||||||
|
|
||||||
/// `client → host`, any time after [`Start`]: run a bandwidth speed test. The host bursts
|
/// `client → host`, any time after [`Start`]: run a bandwidth speed test. The host bursts
|
||||||
/// filler access units (flagged [`crate::packet::FLAG_PROBE`]) over the data plane at
|
/// filler access units (flagged [`crate::packet::FLAG_PROBE`]) over the data plane at
|
||||||
/// `target_kbps` of application goodput for `duration_ms`, *pausing video for the duration*, then
|
/// `target_kbps` of application goodput for `duration_ms`, *pausing video for the duration*, then
|
||||||
@@ -186,6 +205,8 @@ pub fn clock_offset_ns(samples: &[(u64, u64, u64, u64)]) -> Option<(i64, u64)> {
|
|||||||
pub const MSG_RECONFIGURE: u8 = 0x01;
|
pub const MSG_RECONFIGURE: u8 = 0x01;
|
||||||
/// Type byte of [`Reconfigured`].
|
/// Type byte of [`Reconfigured`].
|
||||||
pub const MSG_RECONFIGURED: u8 = 0x02;
|
pub const MSG_RECONFIGURED: u8 = 0x02;
|
||||||
|
/// Type byte of [`RequestKeyframe`].
|
||||||
|
pub const MSG_REQUEST_KEYFRAME: u8 = 0x03;
|
||||||
/// Type byte of [`ProbeRequest`].
|
/// Type byte of [`ProbeRequest`].
|
||||||
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||||
/// Type byte of [`ProbeResult`].
|
/// Type byte of [`ProbeResult`].
|
||||||
@@ -463,6 +484,22 @@ impl Hello {
|
|||||||
b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it
|
b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it
|
||||||
b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline
|
b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline
|
||||||
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26
|
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
// Appended at offset 26: len u8 || UTF-8. This is the LAST trailing field — `None`
|
||||||
|
// emits nothing (so a no-name Hello is byte-identical to the bitrate-era form), which
|
||||||
|
// means a *future* field can't simply follow `name` at a fixed offset; it would need
|
||||||
|
// its own presence flag. Truncate to a char boundary within HELLO_NAME_MAX.
|
||||||
|
let mut n = name.as_str();
|
||||||
|
while n.len() > HELLO_NAME_MAX {
|
||||||
|
let mut cut = HELLO_NAME_MAX;
|
||||||
|
while !n.is_char_boundary(cut) {
|
||||||
|
cut -= 1;
|
||||||
|
}
|
||||||
|
n = &n[..cut];
|
||||||
|
}
|
||||||
|
b.push(n.len() as u8);
|
||||||
|
b.extend_from_slice(n.as_bytes());
|
||||||
|
}
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +529,17 @@ impl Hello {
|
|||||||
.get(22..26)
|
.get(22..26)
|
||||||
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
|
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
|
// Optional trailing device name: len u8 || UTF-8. Absent / oversized / non-UTF-8 →
|
||||||
|
// `None` (never fail the handshake over a label).
|
||||||
|
name: b.get(26).and_then(|&len| {
|
||||||
|
let len = len as usize;
|
||||||
|
if len == 0 || len > HELLO_NAME_MAX {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
b.get(27..27 + len)
|
||||||
|
.and_then(|s| std::str::from_utf8(s).ok())
|
||||||
|
.map(String::from)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -663,6 +711,23 @@ impl Reconfigured {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RequestKeyframe {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
// magic[0..4] type[4] — no payload
|
||||||
|
let mut b = Vec::with_capacity(5);
|
||||||
|
b.extend_from_slice(CTL_MAGIC);
|
||||||
|
b.push(MSG_REQUEST_KEYFRAME);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<RequestKeyframe> {
|
||||||
|
if b.len() != 5 || &b[0..4] != CTL_MAGIC || b[4] != MSG_REQUEST_KEYFRAME {
|
||||||
|
return Err(PunktfunkError::InvalidArg("bad RequestKeyframe"));
|
||||||
|
}
|
||||||
|
Ok(RequestKeyframe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ProbeRequest {
|
impl ProbeRequest {
|
||||||
pub fn encode(&self) -> Vec<u8> {
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
// magic[0..4] type[4] target_kbps[5..9] duration_ms[9..13]
|
// magic[0..4] type[4] target_kbps[5..9] duration_ms[9..13]
|
||||||
@@ -1064,6 +1129,26 @@ pub async fn clock_sync(
|
|||||||
pub mod endpoint {
|
pub mod endpoint {
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
|
||||||
|
/// load-bearing setting: with quinn's defaults it is OFF, so any quiet stretch on the
|
||||||
|
/// connection (no input, audio muted or stalled, a capture hiccup, a mode change) lets the
|
||||||
|
/// idle timer run out and quinn closes the session — surfacing to the embedder as
|
||||||
|
/// `next_au` → Closed. The native equivalent of Moonlight's ENet keepalive: a small PING
|
||||||
|
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so
|
||||||
|
/// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false
|
||||||
|
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
|
||||||
|
fn stream_transport() -> Arc<quinn::TransportConfig> {
|
||||||
|
use std::time::Duration;
|
||||||
|
const MAX_IDLE: Duration = Duration::from_secs(20);
|
||||||
|
const KEEP_ALIVE: Duration = Duration::from_secs(4);
|
||||||
|
let mut t = quinn::TransportConfig::default();
|
||||||
|
t.max_idle_timeout(Some(
|
||||||
|
quinn::IdleTimeout::try_from(MAX_IDLE).expect("20s is a valid QUIC idle timeout"),
|
||||||
|
));
|
||||||
|
t.keep_alive_interval(Some(KEEP_ALIVE));
|
||||||
|
Arc::new(t)
|
||||||
|
}
|
||||||
|
|
||||||
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
|
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
|
||||||
/// persist an identity and use [`server_with_identity`] so clients can pin it).
|
/// persist an identity and use [`server_with_identity`] so clients can pin it).
|
||||||
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
|
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
@@ -1106,7 +1191,8 @@ pub mod endpoint {
|
|||||||
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
||||||
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
||||||
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
||||||
let server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
||||||
|
server_config.transport_config(stream_transport()); // keep-alive — see stream_transport
|
||||||
Ok(quinn::Endpoint::server(server_config, addr)?)
|
Ok(quinn::Endpoint::server(server_config, addr)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1197,8 +1283,10 @@ pub mod endpoint {
|
|||||||
};
|
};
|
||||||
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
||||||
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
||||||
|
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
|
||||||
|
client_cfg.transport_config(stream_transport()); // keep-alive — see stream_transport
|
||||||
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||||
ep.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic_cfg)));
|
ep.set_default_client_config(client_cfg);
|
||||||
Ok(ep)
|
Ok(ep)
|
||||||
})();
|
})();
|
||||||
(ep, observed)
|
(ep, observed)
|
||||||
@@ -1406,6 +1494,7 @@ mod tests {
|
|||||||
compositor: CompositorPref::Kwin,
|
compositor: CompositorPref::Kwin,
|
||||||
gamepad: GamepadPref::DualSense,
|
gamepad: GamepadPref::DualSense,
|
||||||
bitrate_kbps: 25_000,
|
bitrate_kbps: 25_000,
|
||||||
|
name: Some("Test Device".into()),
|
||||||
};
|
};
|
||||||
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||||
let s = Start {
|
let s = Start {
|
||||||
@@ -1470,6 +1559,7 @@ mod tests {
|
|||||||
compositor: CompositorPref::Mutter,
|
compositor: CompositorPref::Mutter,
|
||||||
gamepad: GamepadPref::DualSense,
|
gamepad: GamepadPref::DualSense,
|
||||||
bitrate_kbps: 80_000,
|
bitrate_kbps: 80_000,
|
||||||
|
name: None,
|
||||||
};
|
};
|
||||||
let enc = h.encode();
|
let enc = h.encode();
|
||||||
assert_eq!(enc.len(), 26);
|
assert_eq!(enc.len(), 26);
|
||||||
@@ -1526,6 +1616,51 @@ mod tests {
|
|||||||
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
|
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_name_roundtrip_and_back_compat() {
|
||||||
|
let base = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: Some("Enrico's MacBook".into()),
|
||||||
|
};
|
||||||
|
let enc = base.encode();
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(&enc).unwrap().name.as_deref(),
|
||||||
|
Some("Enrico's MacBook")
|
||||||
|
);
|
||||||
|
// A bitrate-era (26-byte) peer reading a named Hello ignores the trailing name; a named
|
||||||
|
// host reading a bitrate-era Hello decodes name = None.
|
||||||
|
assert_eq!(Hello::decode(&enc[..26]).unwrap().name, None);
|
||||||
|
// No name → wire form is byte-identical to the bitrate-era message (26 bytes).
|
||||||
|
let unnamed = Hello {
|
||||||
|
name: None,
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(unnamed.encode().len(), 26);
|
||||||
|
// Over-long names truncate to a char boundary within HELLO_NAME_MAX on encode.
|
||||||
|
let long = Hello {
|
||||||
|
name: Some(format!("{}ü", "x".repeat(HELLO_NAME_MAX - 1))), // ü straddles the cap
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
let dec = Hello::decode(&long.encode()).unwrap();
|
||||||
|
let n = dec.name.expect("truncated name still present");
|
||||||
|
assert!(n.len() <= HELLO_NAME_MAX && n.starts_with('x'));
|
||||||
|
// A corrupt length byte (longer than the buffer) or bad UTF-8 degrades to None, never Err.
|
||||||
|
let mut bad_len = unnamed.encode();
|
||||||
|
bad_len.push(40); // claims 40 name bytes, none follow
|
||||||
|
assert_eq!(Hello::decode(&bad_len).unwrap().name, None);
|
||||||
|
let mut bad_utf8 = unnamed.encode();
|
||||||
|
bad_utf8.extend_from_slice(&[2, 0xFF, 0xFE]);
|
||||||
|
assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reconfigure_roundtrip() {
|
fn reconfigure_roundtrip() {
|
||||||
let rq = Reconfigure {
|
let rq = Reconfigure {
|
||||||
@@ -1554,6 +1689,22 @@ mod tests {
|
|||||||
.is_err());
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_keyframe_roundtrip() {
|
||||||
|
let bytes = RequestKeyframe.encode();
|
||||||
|
assert!(RequestKeyframe::decode(&bytes).is_ok());
|
||||||
|
// Distinct from the other control messages — its type byte must not collide.
|
||||||
|
let mode = Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
};
|
||||||
|
assert!(RequestKeyframe::decode(&Reconfigure { mode }.encode()).is_err());
|
||||||
|
assert!(Reconfigure::decode(&bytes).is_err());
|
||||||
|
// Length is exact (no trailing bytes accepted).
|
||||||
|
assert!(RequestKeyframe::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn probe_messages_roundtrip() {
|
fn probe_messages_roundtrip() {
|
||||||
let req = ProbeRequest {
|
let req = ProbeRequest {
|
||||||
@@ -1632,6 +1783,7 @@ mod tests {
|
|||||||
compositor: CompositorPref::Auto,
|
compositor: CompositorPref::Auto,
|
||||||
gamepad: GamepadPref::Auto,
|
gamepad: GamepadPref::Auto,
|
||||||
bitrate_kbps: 0,
|
bitrate_kbps: 0,
|
||||||
|
name: None,
|
||||||
}
|
}
|
||||||
.encode();
|
.encode();
|
||||||
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ pub struct Session {
|
|||||||
recv_lens: Vec<usize>,
|
recv_lens: Vec<usize>,
|
||||||
recv_count: usize,
|
recv_count: usize,
|
||||||
recv_idx: usize,
|
recv_idx: usize,
|
||||||
|
/// Host send pool: reused wire buffers (`seal_frame` seals in place into these, the caller sends
|
||||||
|
/// then returns them via [`reclaim_wires`](Self::reclaim_wires)). After warmup each buffer keeps
|
||||||
|
/// its capacity, so the per-packet ciphertext + wire `Vec` allocations vanish from the hot path.
|
||||||
|
wire_pool: Vec<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). At ~125k
|
/// Datagrams drained per `recvmmsg` syscall on the client (the reused ring's size). At ~125k
|
||||||
@@ -78,6 +82,7 @@ impl Session {
|
|||||||
recv_lens: Vec::new(),
|
recv_lens: Vec::new(),
|
||||||
recv_count: 0,
|
recv_count: 0,
|
||||||
recv_idx: 0,
|
recv_idx: 0,
|
||||||
|
wire_pool: Vec::new(),
|
||||||
config,
|
config,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -92,19 +97,23 @@ impl Session {
|
|||||||
|
|
||||||
/// Wrap a packet for the wire: when encrypting, prepend the 8-byte big-endian
|
/// Wrap a packet for the wire: when encrypting, prepend the 8-byte big-endian
|
||||||
/// sequence (the receiver derives the GCM nonce from it) then the ciphertext.
|
/// sequence (the receiver derives the GCM nonce from it) then the ciphertext.
|
||||||
fn seal_for_wire(&mut self, packet: &[u8]) -> Result<Vec<u8>> {
|
/// Seal one plaintext packet into the reused `wire` buffer in place (no allocation): the wire is
|
||||||
|
/// `seq(8) || ciphertext || tag` with crypto on, or just the packet with crypto off (probe).
|
||||||
|
/// Byte-identical to the previous `seal` + concat path; `clear()` keeps the buffer's capacity.
|
||||||
|
fn seal_into(&mut self, packet: &[u8], wire: &mut Vec<u8>) -> Result<()> {
|
||||||
let seq = self.next_seq;
|
let seq = self.next_seq;
|
||||||
self.next_seq = self.next_seq.wrapping_add(1);
|
self.next_seq = self.next_seq.wrapping_add(1);
|
||||||
|
wire.clear();
|
||||||
match &self.crypto {
|
match &self.crypto {
|
||||||
Some(c) => {
|
Some(c) => {
|
||||||
let ct = c.seal(seq, packet)?;
|
wire.extend_from_slice(&seq.to_be_bytes()); // [0..8] plaintext seq prefix
|
||||||
let mut wire = Vec::with_capacity(8 + ct.len());
|
wire.extend_from_slice(packet); // [8..8+n] plaintext to encrypt
|
||||||
wire.extend_from_slice(&seq.to_be_bytes());
|
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0); // tag scratch
|
||||||
wire.extend_from_slice(&ct);
|
c.seal_in_place(seq, &mut wire[8..])?; // encrypt [8..] in place, tag written at the end
|
||||||
Ok(wire)
|
|
||||||
}
|
}
|
||||||
None => Ok(packet.to_vec()),
|
None => wire.extend_from_slice(packet),
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unwrap a wire datagram back into a plaintext packet.
|
/// Unwrap a wire datagram back into a plaintext packet.
|
||||||
@@ -144,9 +153,13 @@ impl Session {
|
|||||||
.packetizer
|
.packetizer
|
||||||
.packetize(data, pts_ns, user_flags, self.coder.as_ref())?;
|
.packetize(data, pts_ns, user_flags, self.coder.as_ref())?;
|
||||||
StatsCounters::add(&self.stats.frames_submitted, 1);
|
StatsCounters::add(&self.stats.frames_submitted, 1);
|
||||||
let mut wires: Vec<Vec<u8>> = Vec::with_capacity(packets.len());
|
// Reuse the wire-buffer pool the caller returns via `reclaim_wires`: one buffer per packet,
|
||||||
for pkt in &packets {
|
// sealed in place — after warmup there is no per-packet ciphertext/wire allocation. (`wires`
|
||||||
wires.push(self.seal_for_wire(pkt)?);
|
// is a local, so `seal_into`'s `&mut self` doesn't alias the `&mut` iteration over it.)
|
||||||
|
let mut wires = std::mem::take(&mut self.wire_pool);
|
||||||
|
wires.resize_with(packets.len(), Vec::new);
|
||||||
|
for (wire, pkt) in wires.iter_mut().zip(packets.iter()) {
|
||||||
|
self.seal_into(pkt, wire)?;
|
||||||
}
|
}
|
||||||
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
|
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
|
||||||
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
|
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
|
||||||
@@ -154,11 +167,19 @@ impl Session {
|
|||||||
Ok(wires)
|
Ok(wires)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the wire buffers from [`seal_frame`](Self::seal_frame) to the reuse pool once the caller
|
||||||
|
/// has finished sending them, so the next frame reseals in place with no allocation. Optional —
|
||||||
|
/// dropping the buffers instead just forfeits the reuse (correctness is unaffected).
|
||||||
|
pub fn reclaim_wires(&mut self, wires: Vec<Vec<u8>>) {
|
||||||
|
self.wire_pool = wires;
|
||||||
|
}
|
||||||
|
|
||||||
/// Host: transmit one chunk of already-[`seal_frame`](Self::seal_frame)ed packets in a single
|
/// Host: transmit one chunk of already-[`seal_frame`](Self::seal_frame)ed packets in a single
|
||||||
/// batched `sendmmsg`, returning how many the kernel accepted. The rest (`packets.len() - n`)
|
/// batched `sendmmsg`, returning how many the kernel accepted. The rest (`packets.len() - n`)
|
||||||
/// are counted as send-buffer drops. Call once for the whole frame, or per paced chunk.
|
/// are counted as send-buffer drops. Call once for the whole frame, or per paced chunk.
|
||||||
pub fn send_sealed(&self, packets: &[&[u8]]) -> Result<usize> {
|
pub fn send_sealed(&self, packets: &[&[u8]]) -> Result<usize> {
|
||||||
let sent = self.transport.send_batch(packets)?;
|
// GSO when enabled (UdpTransport/Linux), else sendmmsg — same short-count drop contract.
|
||||||
|
let sent = self.transport.send_gso(packets)?;
|
||||||
if sent < packets.len() {
|
if sent < packets.len() {
|
||||||
StatsCounters::add(
|
StatsCounters::add(
|
||||||
&self.stats.packets_send_dropped,
|
&self.stats.packets_send_dropped,
|
||||||
@@ -174,8 +195,10 @@ impl Session {
|
|||||||
pub fn submit_frame(&mut self, data: &[u8], pts_ns: u64, user_flags: u32) -> Result<()> {
|
pub fn submit_frame(&mut self, data: &[u8], pts_ns: u64, user_flags: u32) -> Result<()> {
|
||||||
let wires = self.seal_frame(data, pts_ns, user_flags)?;
|
let wires = self.seal_frame(data, pts_ns, user_flags)?;
|
||||||
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||||
self.send_sealed(&refs)?;
|
let r = self.send_sealed(&refs);
|
||||||
Ok(())
|
drop(refs); // release the borrow of `wires` before returning the buffers to the pool
|
||||||
|
self.reclaim_wires(wires);
|
||||||
|
r.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host: drain one pending input event from the client, if any.
|
/// Host: drain one pending input event from the client, if any.
|
||||||
@@ -263,7 +286,8 @@ impl Session {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let pkt = event.encode();
|
let pkt = event.encode();
|
||||||
let wire = self.seal_for_wire(&pkt)?;
|
let mut wire = Vec::new(); // input is rare + per-event; no pool needed
|
||||||
|
self.seal_into(&pkt, &mut wire)?;
|
||||||
StatsCounters::add(&self.stats.packets_sent, 1);
|
StatsCounters::add(&self.stats.packets_sent, 1);
|
||||||
StatsCounters::add(&self.stats.bytes_sent, wire.len() as u64);
|
StatsCounters::add(&self.stats.bytes_sent, wire.len() as u64);
|
||||||
if !self.transport.send(&wire)? {
|
if !self.transport.send(&wire)? {
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ pub trait Transport: Send + Sync {
|
|||||||
Ok(sent)
|
Ok(sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a frame's equal-size packets using UDP Generic Segmentation Offload where available:
|
||||||
|
/// one `sendmsg` hands the kernel a big buffer it splits into `gso_size` UDP datagrams, building
|
||||||
|
/// ~1 GSO skb per ≤64 segments instead of one skb per packet. This is the multi-Gbps lever —
|
||||||
|
/// research shows ~2.4× throughput at equal CPU and ~40× fewer syscalls, and that `sendmmsg`
|
||||||
|
/// batching alone is insufficient (it still builds one skb per datagram). The
|
||||||
|
/// [`UdpTransport`](super::UdpTransport) Linux override implements it (opt-in via `PUNKTFUNK_GSO`,
|
||||||
|
/// auto-fallback on any GSO error); the default just delegates to [`send_batch`](Self::send_batch),
|
||||||
|
/// correct for loopback and non-Linux. Same lossy, FEC-protected short-count contract as `send_batch`.
|
||||||
|
fn send_gso(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
|
||||||
|
self.send_batch(packets)
|
||||||
|
}
|
||||||
|
|
||||||
fn recv(&self) -> std::io::Result<Option<Vec<u8>>>;
|
fn recv(&self) -> std::io::Result<Option<Vec<u8>>>;
|
||||||
|
|
||||||
/// Receive up to `out.len()` datagrams in as few syscalls as possible, writing each into its
|
/// Receive up to `out.len()` datagrams in as few syscalls as possible, writing each into its
|
||||||
|
|||||||
@@ -33,6 +33,132 @@ fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<libc::mmsghdr> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// UDP GSO enable state (process-wide). Opt-in via `PUNKTFUNK_GSO` — it's new unsafe hot-path code,
|
||||||
|
/// and the auto-fallback (latch off on any GSO syscall error) covers kernels/paths without support.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod gso {
|
||||||
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
|
static STATE: AtomicU8 = AtomicU8::new(0); // 0 = uninit, 1 = on, 2 = off
|
||||||
|
|
||||||
|
pub fn active() -> bool {
|
||||||
|
match STATE.load(Ordering::Relaxed) {
|
||||||
|
1 => true,
|
||||||
|
2 => false,
|
||||||
|
_ => {
|
||||||
|
let on = std::env::var_os("PUNKTFUNK_GSO").is_some();
|
||||||
|
STATE.store(if on { 1 } else { 2 }, Ordering::Relaxed);
|
||||||
|
on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Latch GSO off for the process after a GSO syscall error (unsupported kernel/path).
|
||||||
|
pub fn disable() {
|
||||||
|
STATE.store(2, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the send error means UDP GSO isn't supported here (vs a transient/real failure) — so we
|
||||||
|
/// latch GSO off and fall back to `sendmmsg` rather than tear the stream down.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn gso_unsupported(e: &std::io::Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
e.raw_os_error(),
|
||||||
|
Some(libc::ENOPROTOOPT) | Some(libc::EOPNOTSUPP) | Some(libc::EINVAL) | Some(libc::EIO)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `sendmsg` carrying a `UDP_SEGMENT` control message: the kernel splits `buf` (a back-to-back
|
||||||
|
/// concatenation of equal-size datagrams, only the final one allowed shorter) into `gso_size`-byte
|
||||||
|
/// UDP datagrams to the connected peer — one large GSO skb instead of N. `EAGAIN` (full send buffer)
|
||||||
|
/// surfaces as a `WouldBlock` error; the caller treats it as a lossy drop.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn send_one_gso(fd: libc::c_int, buf: &[u8], gso_size: u16) -> std::io::Result<()> {
|
||||||
|
let mut iov = libc::iovec {
|
||||||
|
iov_base: buf.as_ptr() as *mut libc::c_void,
|
||||||
|
iov_len: buf.len(),
|
||||||
|
};
|
||||||
|
// Aligned control buffer for one cmsg(UDP_SEGMENT = u16). 64 B > CMSG_SPACE(2); the union forces
|
||||||
|
// cmsghdr alignment (CMSG_FIRSTHDR requires it).
|
||||||
|
#[repr(C)]
|
||||||
|
union CmsgBuf {
|
||||||
|
_align: libc::cmsghdr,
|
||||||
|
bytes: [u8; 64],
|
||||||
|
}
|
||||||
|
let mut control = CmsgBuf { bytes: [0u8; 64] };
|
||||||
|
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||||
|
msg.msg_iov = &mut iov;
|
||||||
|
msg.msg_iovlen = 1;
|
||||||
|
let rc = unsafe {
|
||||||
|
msg.msg_control = control.bytes.as_mut_ptr() as *mut libc::c_void;
|
||||||
|
msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::<u16>() as u32) as _;
|
||||||
|
let cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||||
|
(*cmsg).cmsg_level = libc::SOL_UDP;
|
||||||
|
(*cmsg).cmsg_type = libc::UDP_SEGMENT;
|
||||||
|
(*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<u16>() as u32) as _;
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
(&gso_size as *const u16) as *const u8,
|
||||||
|
libc::CMSG_DATA(cmsg),
|
||||||
|
std::mem::size_of::<u16>(),
|
||||||
|
);
|
||||||
|
libc::sendmsg(fd, &msg, 0)
|
||||||
|
};
|
||||||
|
if rc < 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apple (macOS/iOS) batched-receive enable state. Darwin has no `recvmmsg(2)`, so our macOS client
|
||||||
|
/// does one `recv` per packet (non-allocating, but a syscall each); `recvmsg_x(2)` is the batched
|
||||||
|
/// equivalent. Opt-in via `PUNKTFUNK_RECVMSG_X` (it's FFI we can't exercise off-Apple — the scalar
|
||||||
|
/// recv-loop is the tested default), with auto-fallback if the syscall ever errors unexpectedly.
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
mod recvx {
|
||||||
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
|
static STATE: AtomicU8 = AtomicU8::new(0); // 0 = uninit, 1 = on, 2 = off
|
||||||
|
|
||||||
|
pub fn active() -> bool {
|
||||||
|
match STATE.load(Ordering::Relaxed) {
|
||||||
|
1 => true,
|
||||||
|
2 => false,
|
||||||
|
_ => {
|
||||||
|
let on = std::env::var_os("PUNKTFUNK_RECVMSG_X").is_some();
|
||||||
|
STATE.store(if on { 1 } else { 2 }, Ordering::Relaxed);
|
||||||
|
on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn disable() {
|
||||||
|
STATE.store(2, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `struct msghdr_x` from Darwin `<sys/socket.h>` (the batched-I/O variant — not in the `libc` crate).
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
#[repr(C)]
|
||||||
|
struct MsghdrX {
|
||||||
|
msg_name: *mut libc::c_void,
|
||||||
|
msg_namelen: libc::socklen_t,
|
||||||
|
msg_iov: *mut libc::iovec,
|
||||||
|
msg_iovlen: libc::c_int,
|
||||||
|
msg_control: *mut libc::c_void,
|
||||||
|
msg_controllen: libc::socklen_t,
|
||||||
|
msg_flags: libc::c_int,
|
||||||
|
msg_datalen: libc::size_t,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
extern "C" {
|
||||||
|
/// Darwin batched receive: up to `cnt` datagrams in one syscall; returns the count received and
|
||||||
|
/// sets each `msg_datalen` to its byte length. Present in libSystem on all macOS/iOS.
|
||||||
|
fn recvmsg_x(
|
||||||
|
s: libc::c_int,
|
||||||
|
msgp: *mut MsghdrX,
|
||||||
|
cnt: libc::c_uint,
|
||||||
|
flags: libc::c_int,
|
||||||
|
) -> libc::ssize_t;
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UdpTransport {
|
pub struct UdpTransport {
|
||||||
socket: UdpSocket,
|
socket: UdpSocket,
|
||||||
}
|
}
|
||||||
@@ -87,6 +213,55 @@ impl UdpTransport {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apple batched receive via `recvmsg_x` — drains up to `out.len()` datagrams in one syscall into
|
||||||
|
/// the caller's reused buffers (the recv counterpart of Linux `recvmmsg`, which Darwin lacks).
|
||||||
|
/// SAFETY: each `MsghdrX` holds a raw pointer into `iovs`, which holds raw pointers into `out`'s
|
||||||
|
/// buffers; both `iovs` and `msgs` stay alive and unmoved through the syscall.
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
fn recv_batch_x(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let fd = self.socket.as_raw_fd();
|
||||||
|
let n_bufs = out.len().min(lens.len());
|
||||||
|
if n_bufs == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut iovs: Vec<libc::iovec> = out[..n_bufs]
|
||||||
|
.iter_mut()
|
||||||
|
.map(|b| libc::iovec {
|
||||||
|
iov_base: b.as_mut_ptr() as *mut libc::c_void,
|
||||||
|
iov_len: b.len(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut msgs: Vec<MsghdrX> = iovs
|
||||||
|
.iter_mut()
|
||||||
|
.map(|iov| {
|
||||||
|
let mut m: MsghdrX = unsafe { std::mem::zeroed() };
|
||||||
|
m.msg_iov = iov as *mut libc::iovec;
|
||||||
|
m.msg_iovlen = 1;
|
||||||
|
m
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let n = unsafe {
|
||||||
|
recvmsg_x(
|
||||||
|
fd,
|
||||||
|
msgs.as_mut_ptr(),
|
||||||
|
n_bufs as libc::c_uint,
|
||||||
|
libc::MSG_DONTWAIT,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if n < 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
for (i, m) in msgs[..n as usize].iter().enumerate() {
|
||||||
|
lens[i] = m.msg_datalen;
|
||||||
|
}
|
||||||
|
Ok(n as usize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Transport for UdpTransport {
|
impl Transport for UdpTransport {
|
||||||
@@ -146,6 +321,52 @@ impl Transport for UdpTransport {
|
|||||||
Ok(total_sent)
|
Ok(total_sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// UDP GSO send (see [`Transport::send_gso`]). Coalesces the frame's equal-size packets into a
|
||||||
|
/// reused scratch buffer and hands the kernel ≤64-segment super-buffers via `sendmsg(UDP_SEGMENT)`
|
||||||
|
/// — one GSO skb per chunk instead of one per packet, the multi-Gbps lever. Opt-in
|
||||||
|
/// (`PUNKTFUNK_GSO`); falls back to `send_batch` when off, when packets aren't uniform-size, or on
|
||||||
|
/// any GSO error (which also latches it off for the process). Same lossy short-count contract.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn send_gso(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
if packets.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
if !gso::active() {
|
||||||
|
return self.send_batch(packets);
|
||||||
|
}
|
||||||
|
// GSO needs every segment but the last to be exactly `seg` bytes. Our wire packets are all
|
||||||
|
// identical size (shards zero-padded to shard_payload), but guard and fall back if not.
|
||||||
|
let seg = packets[0].len();
|
||||||
|
let last = packets.len() - 1;
|
||||||
|
if seg == 0 || packets[..last].iter().any(|p| p.len() != seg) || packets[last].len() > seg {
|
||||||
|
return self.send_batch(packets);
|
||||||
|
}
|
||||||
|
let fd = self.socket.as_raw_fd();
|
||||||
|
// A GSO super-buffer is capped at 64 segments AND 65535 payload bytes (kernel limits).
|
||||||
|
let max_seg = (65535 / seg).clamp(1, 64);
|
||||||
|
let mut scratch: Vec<u8> = Vec::with_capacity(seg * max_seg);
|
||||||
|
let mut sent = 0usize;
|
||||||
|
for chunk in packets.chunks(max_seg) {
|
||||||
|
scratch.clear();
|
||||||
|
for p in chunk {
|
||||||
|
scratch.extend_from_slice(p);
|
||||||
|
}
|
||||||
|
match send_one_gso(fd, &scratch, seg as u16) {
|
||||||
|
Ok(()) => sent += chunk.len(),
|
||||||
|
// Send buffer momentarily full — drop the rest (counted by the caller), never block.
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||||
|
// GSO unsupported on this kernel/path — latch off and finish via sendmmsg.
|
||||||
|
Err(e) if gso_unsupported(&e) => {
|
||||||
|
gso::disable();
|
||||||
|
return Ok(sent + self.send_batch(&packets[sent..])?);
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(sent)
|
||||||
|
}
|
||||||
|
|
||||||
fn recv(&self) -> std::io::Result<Option<Vec<u8>>> {
|
fn recv(&self) -> std::io::Result<Option<Vec<u8>>> {
|
||||||
let mut buf = vec![0u8; RECV_BUF];
|
let mut buf = vec![0u8; RECV_BUF];
|
||||||
match self.socket.recv(&mut buf) {
|
match self.socket.recv(&mut buf) {
|
||||||
@@ -165,7 +386,8 @@ impl Transport for UdpTransport {
|
|||||||
/// caller's reused buffers (no per-packet allocation). `MSG_DONTWAIT` keeps it non-blocking
|
/// caller's reused buffers (no per-packet allocation). `MSG_DONTWAIT` keeps it non-blocking
|
||||||
/// (the socket already is); `EAGAIN` → `0`. A datagram larger than a buffer is truncated and
|
/// (the socket already is); `EAGAIN` → `0`. A datagram larger than a buffer is truncated and
|
||||||
/// `lens[i]` reaches the buffer size — the reassembler then rejects it as malformed, matching
|
/// `lens[i]` reaches the buffer size — the reassembler then rejects it as malformed, matching
|
||||||
/// `recv`'s oversized-drop. Non-Linux falls back to the trait's scalar `recv` default.
|
/// `recv`'s oversized-drop. Apple/BSD use the `recv`-loop override below; other non-unix the
|
||||||
|
/// trait's scalar default.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
@@ -204,6 +426,55 @@ impl Transport for UdpTransport {
|
|||||||
}
|
}
|
||||||
Ok(n as usize)
|
Ok(n as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Batched receive for Apple/BSD targets, which have no `recvmmsg(2)`. Drains up to `out.len()`
|
||||||
|
/// datagrams per call with `libc::recv(MSG_DONTWAIT)` straight into the caller's reused `out[i]`
|
||||||
|
/// buffers — eliminating the per-packet 2 KB `vec!` allocation (and its zeroing + a copy) that
|
||||||
|
/// the scalar `recv` + trait-default `recv_batch` incur. THIS is the macOS-client throughput
|
||||||
|
/// fix: at line rate the alloc/free churn — not the syscall — was the single-core wall (Moonlight
|
||||||
|
/// batches; our client per-packet-allocated). It is still one syscall per datagram (a future
|
||||||
|
/// `recvmsg_x` batch would cut that too); `EAGAIN` ends the drain. Oversized datagrams set
|
||||||
|
/// `lens[i] == buf.len()` and the caller (`poll_frame`) drops them — same contract as `recvmmsg`.
|
||||||
|
#[cfg(all(unix, not(target_os = "linux")))]
|
||||||
|
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
|
||||||
|
// Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it
|
||||||
|
// and falls through to the always-correct scalar loop below.
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
if recvx::active() {
|
||||||
|
match self.recv_batch_x(out, lens) {
|
||||||
|
Ok(n) => return Ok(n),
|
||||||
|
Err(_) => recvx::disable(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let fd = self.socket.as_raw_fd();
|
||||||
|
let n_bufs = out.len().min(lens.len());
|
||||||
|
let mut got = 0usize;
|
||||||
|
while got < n_bufs {
|
||||||
|
let buf = &mut out[got];
|
||||||
|
let r = unsafe {
|
||||||
|
libc::recv(
|
||||||
|
fd,
|
||||||
|
buf.as_mut_ptr() as *mut libc::c_void,
|
||||||
|
buf.len(),
|
||||||
|
libc::MSG_DONTWAIT,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if r < 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||||
|
break; // socket drained
|
||||||
|
}
|
||||||
|
if got > 0 {
|
||||||
|
break; // report what we have; surface the error on the next empty poll
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
lens[got] = r as usize;
|
||||||
|
got += 1;
|
||||||
|
}
|
||||||
|
Ok(got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -211,6 +482,45 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::transport::Transport;
|
use crate::transport::Transport;
|
||||||
|
|
||||||
|
/// `send_one_gso` must split one buffer into N separate UDP datagrams of `gso_size` bytes each
|
||||||
|
/// (the kernel UDP GSO segmentation) — the multi-Gbps send lever. Loopback supports GSO; if the
|
||||||
|
/// CI kernel doesn't, skip rather than fail.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn gso_segments_into_separate_datagrams() {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let rx = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
rx.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
||||||
|
.unwrap();
|
||||||
|
let rx_addr = rx.local_addr().unwrap();
|
||||||
|
let tx = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
tx.connect(rx_addr).unwrap();
|
||||||
|
|
||||||
|
let seg = 1000usize;
|
||||||
|
let segs = 5usize;
|
||||||
|
let mut buf = vec![0u8; seg * segs];
|
||||||
|
for i in 0..segs {
|
||||||
|
buf[i * seg..(i + 1) * seg].fill(i as u8 + 1);
|
||||||
|
}
|
||||||
|
if let Err(e) = send_one_gso(tx.as_raw_fd(), &buf, seg as u16) {
|
||||||
|
if gso_unsupported(&e) {
|
||||||
|
eprintln!("UDP GSO unsupported on this kernel — skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panic!("gso sendmsg failed: {e}");
|
||||||
|
}
|
||||||
|
// Each segment arrives as its own datagram, full size, content intact.
|
||||||
|
let mut rbuf = vec![0u8; 4096];
|
||||||
|
for i in 0..segs {
|
||||||
|
let n = rx.recv(&mut rbuf).expect("recv GSO segment");
|
||||||
|
assert_eq!(n, seg, "segment {i} should be a full {seg}-byte datagram");
|
||||||
|
assert!(
|
||||||
|
rbuf[..n].iter().all(|&b| b == i as u8 + 1),
|
||||||
|
"segment {i} content"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
|
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
|
||||||
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
|
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
|
||||||
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.
|
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.
|
||||||
|
|||||||
@@ -466,6 +466,8 @@ mod pipewire {
|
|||||||
negotiated: Arc<AtomicBool>,
|
negotiated: Arc<AtomicBool>,
|
||||||
/// Present when zero-copy is enabled: imports a dmabuf → CUDA device buffer.
|
/// Present when zero-copy is enabled: imports a dmabuf → CUDA device buffer.
|
||||||
importer: Option<crate::zerocopy::EglImporter>,
|
importer: Option<crate::zerocopy::EglImporter>,
|
||||||
|
/// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`).
|
||||||
|
dbg_log_n: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
|
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
|
||||||
@@ -665,6 +667,29 @@ mod pipewire {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a Buffers param for a TRUE SHM path: MemPtr + MemFd only, NO DmaBuf. Forces the
|
||||||
|
/// producer to download into mappable memory (Mutter's `glReadPixels`), which orders against its
|
||||||
|
/// render — so the frame is complete and current by construction. This is the only race-free
|
||||||
|
/// capture of Mutter's virtual monitor on NVIDIA: the compositor renders straight into the buffer
|
||||||
|
/// pool, NVIDIA attaches no implicit dmabuf fence (verified: `EXPORT_SYNC_FILE` waited=false) and
|
||||||
|
/// can't produce an explicit sync_fd, so any dmabuf read (zero-copy OR mmap) races the render and
|
||||||
|
/// flashes the buffer's previous frame. Excluding DmaBuf is what makes the difference vs.
|
||||||
|
/// `build_mappable_buffers` (which still let Mutter hand dmabufs).
|
||||||
|
fn build_shm_only_buffers() -> Result<Vec<u8>> {
|
||||||
|
serialize_pod(pw::spa::pod::Object {
|
||||||
|
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||||
|
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||||
|
properties: vec![pw::spa::pod::Property {
|
||||||
|
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||||
|
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||||
|
value: pw::spa::pod::Value::Int(
|
||||||
|
(1i32 << pw::spa::sys::SPA_DATA_MemPtr)
|
||||||
|
| (1i32 << pw::spa::sys::SPA_DATA_MemFd),
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a Buffers param requesting dmabuf-only buffers.
|
/// Build a Buffers param requesting dmabuf-only buffers.
|
||||||
fn build_dmabuf_buffers() -> Result<Vec<u8>> {
|
fn build_dmabuf_buffers() -> Result<Vec<u8>> {
|
||||||
serialize_pod(pw::spa::pod::Object {
|
serialize_pod(pw::spa::pod::Object {
|
||||||
@@ -678,6 +703,205 @@ mod pipewire {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// De-pad / import a single PipeWire buffer and push it to the encoder. Called from the
|
||||||
|
/// `.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) {
|
||||||
|
// No active stream: release the buffer without the (expensive at 5K) de-pad.
|
||||||
|
if !ud.active.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let datas: &mut [pw::spa::buffer::Data] = unsafe {
|
||||||
|
if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() {
|
||||||
|
&mut []
|
||||||
|
} else {
|
||||||
|
std::slice::from_raw_parts_mut(
|
||||||
|
(*spa_buf).datas as *mut pw::spa::buffer::Data,
|
||||||
|
(*spa_buf).n_datas as usize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if datas.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sz = ud.info.size();
|
||||||
|
let (w, h) = (sz.width as usize, sz.height as usize);
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return; // format not negotiated yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implicit-fence wait: Mutter renders into the dmabuf and hands it over at
|
||||||
|
// GPU-submit time; with no producer explicit sync (Mutter+NVIDIA can't) we snapshot
|
||||||
|
// the buffer's implicit fence and wait the producer's render before sampling —
|
||||||
|
// closing the stale/old-frame race on NVIDIA. No-op for shm buffers or drivers that
|
||||||
|
// attach no fence. Covers both the GPU import and the CPU mmap read below.
|
||||||
|
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||||
|
match crate::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) {
|
||||||
|
Ok(waited) => {
|
||||||
|
static F1: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(true);
|
||||||
|
if F1.swap(false, Ordering::Relaxed) {
|
||||||
|
tracing::info!(
|
||||||
|
waited,
|
||||||
|
"dmabuf implicit-fence sync active (waited=true → driver fences \
|
||||||
|
the render, race closed; false → no implicit fence, zero-copy \
|
||||||
|
may still show stale frames)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
static F2: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(true);
|
||||||
|
if F2.swap(false, Ordering::Relaxed) {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %format!("{e}"),
|
||||||
|
"dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \
|
||||||
|
zero-copy may show stale frames (no producer explicit sync)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-copy path: if the buffer is a dmabuf and we have an importer, import it
|
||||||
|
// into a CUDA device buffer (no CPU touch) and deliver that. Otherwise fall
|
||||||
|
// through to the shm de-pad copy below.
|
||||||
|
let mut gpu_import_broken = false;
|
||||||
|
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
|
||||||
|
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||||
|
let plane = crate::zerocopy::DmabufPlane {
|
||||||
|
fd: datas[0].fd(),
|
||||||
|
offset: datas[0].chunk().offset(),
|
||||||
|
stride: datas[0].chunk().stride().max(0) as u32,
|
||||||
|
};
|
||||||
|
// Tiled modifier → EGL/GL de-tile import; LINEAR (0/unset, e.g.
|
||||||
|
// gamescope) → direct CUDA external-memory import (NVIDIA EGL can't
|
||||||
|
// sample LINEAR).
|
||||||
|
let modifier = (ud.modifier != 0).then_some(ud.modifier);
|
||||||
|
if let Some(fourcc) = crate::zerocopy::drm_fourcc(fmt) {
|
||||||
|
let imported = if modifier.is_some() {
|
||||||
|
importer.import(&plane, w as u32, h as u32, fourcc, modifier)
|
||||||
|
} else {
|
||||||
|
importer.import_linear(&plane, w as u32, h as u32)
|
||||||
|
};
|
||||||
|
match imported {
|
||||||
|
Ok(devbuf) => {
|
||||||
|
static ONCE: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(true);
|
||||||
|
if ONCE.swap(false, Ordering::Relaxed) {
|
||||||
|
tracing::info!(
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
modifier = ud.modifier,
|
||||||
|
"zero-copy: dmabuf imported to CUDA (no CPU copy)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let pts_ns = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let _ = ud.tx.try_send(CapturedFrame {
|
||||||
|
width: w as u32,
|
||||||
|
height: h as u32,
|
||||||
|
pts_ns,
|
||||||
|
format: fmt,
|
||||||
|
payload: FramePayload::Cuda(devbuf),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// GPU import unavailable for this buffer kind (e.g. the
|
||||||
|
// driver rejects LINEAR external-memory import). Disable
|
||||||
|
// the importer and fall through to the CPU mmap path —
|
||||||
|
// degraded, not dead.
|
||||||
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
|
"dmabuf GPU import failed — falling back to the CPU copy path");
|
||||||
|
gpu_import_broken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return; // format has no DRM fourcc mapping — skip the frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gpu_import_broken {
|
||||||
|
ud.importer = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let d = &mut datas[0];
|
||||||
|
// CPU path may also receive LINEAR dmabufs (gamescope offers only those once its
|
||||||
|
// modifier-bearing format pod wins); capture the fd before `data()` borrows `d`.
|
||||||
|
let dmabuf_fd = (d.type_() == pw::spa::buffer::DataType::DmaBuf).then(|| d.fd());
|
||||||
|
let (size, offset, stride) = {
|
||||||
|
let c = d.chunk();
|
||||||
|
(
|
||||||
|
c.size() as usize,
|
||||||
|
c.offset() as usize,
|
||||||
|
c.stride().max(0) as usize,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let Some(fmt) = ud.format else { return }; // unsupported/not negotiated
|
||||||
|
let bpp = fmt.bytes_per_pixel();
|
||||||
|
let row = w * bpp;
|
||||||
|
let stride = if stride == 0 { row } else { stride };
|
||||||
|
if stride < row {
|
||||||
|
warn_once("chunk stride < row — frames dropped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let needed = stride * (h - 1) + row;
|
||||||
|
// dmabuf chunks commonly report size 0; fall back to the computed span.
|
||||||
|
let size = if size == 0 { needed } else { size };
|
||||||
|
// MAP_BUFFERS only maps buffers flagged mappable; Vulkan-exported dmabufs
|
||||||
|
// (gamescope) usually aren't, so mmap the fd ourselves for the de-pad read.
|
||||||
|
let _mapping; // keeps a manual mmap alive for the copy below
|
||||||
|
let buf: &[u8] = if let Some(data) = d.data() {
|
||||||
|
data
|
||||||
|
} else if let Some(fd) = dmabuf_fd.filter(|&fd| fd > 0) {
|
||||||
|
match DmabufMap::new(fd, offset + needed) {
|
||||||
|
Some(m) => {
|
||||||
|
_mapping = m;
|
||||||
|
unsafe { std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len) }
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
warn_once("mmap(dmabuf) failed — frames dropped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn_once("buffer has no mappable data — frames dropped");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Need stride*(h-1)+row valid bytes within [offset, offset+size).
|
||||||
|
if offset > buf.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let avail = buf.len() - offset;
|
||||||
|
if needed > avail || needed > size {
|
||||||
|
warn_once("buffer smaller than frame span — frames dropped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let region = &buf[offset..offset + size.min(avail)];
|
||||||
|
// De-pad into a tightly-packed buffer (chunk stride may exceed w*bpp).
|
||||||
|
let mut tight = vec![0u8; row * h];
|
||||||
|
for y in 0..h {
|
||||||
|
tight[y * row..y * row + row].copy_from_slice(®ion[y * stride..y * stride + row]);
|
||||||
|
}
|
||||||
|
let pts_ns = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let frame = CapturedFrame {
|
||||||
|
width: w as u32,
|
||||||
|
height: h as u32,
|
||||||
|
pts_ns,
|
||||||
|
format: fmt,
|
||||||
|
payload: FramePayload::Cpu(tight),
|
||||||
|
};
|
||||||
|
// Drop if the encoder is behind — never block the pipewire loop.
|
||||||
|
let _ = ud.tx.try_send(frame);
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn pipewire_thread(
|
pub fn pipewire_thread(
|
||||||
fd: Option<OwnedFd>,
|
fd: Option<OwnedFd>,
|
||||||
@@ -736,8 +960,16 @@ mod pipewire {
|
|||||||
if importer.is_some() && !modifiers.contains(&0) {
|
if importer.is_some() && !modifiers.contains(&0) {
|
||||||
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
|
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
|
||||||
}
|
}
|
||||||
let want_dmabuf = importer.is_some() && !modifiers.is_empty();
|
// PUNKTFUNK_FORCE_SHM=1 forces the race-free download path (SHM, no dmabuf) — required on
|
||||||
if zerocopy && !want_dmabuf {
|
// Mutter+NVIDIA where dmabuf capture has no working sync and shows stale frames. KWin/
|
||||||
|
// gamescope don't need it (they blit into the buffer, so no read-before-render race).
|
||||||
|
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
|
||||||
|
let want_dmabuf = importer.is_some() && !modifiers.is_empty() && !force_shm;
|
||||||
|
if force_shm {
|
||||||
|
tracing::info!(
|
||||||
|
"capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)"
|
||||||
|
);
|
||||||
|
} else if zerocopy && !want_dmabuf {
|
||||||
tracing::warn!("zero-copy: no EGL-importable dmabuf modifiers — using CPU path");
|
tracing::warn!("zero-copy: no EGL-importable dmabuf modifiers — using CPU path");
|
||||||
} else if want_dmabuf {
|
} else if want_dmabuf {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -755,6 +987,7 @@ mod pipewire {
|
|||||||
active,
|
active,
|
||||||
negotiated,
|
negotiated,
|
||||||
importer,
|
importer,
|
||||||
|
dbg_log_n: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = pw::stream::StreamBox::new(
|
let stream = pw::stream::StreamBox::new(
|
||||||
@@ -818,159 +1051,84 @@ mod pipewire {
|
|||||||
// PipeWire dispatches this from a C trampoline with no catch_unwind; a
|
// PipeWire dispatches this from a C trampoline with no catch_unwind; a
|
||||||
// panic crossing that FFI boundary would abort the whole host. Contain it.
|
// panic crossing that FFI boundary would abort the whole host. Contain it.
|
||||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
|
||||||
return;
|
// recycles its pool; an older queued buffer carries a STALE frame. Drain all
|
||||||
};
|
// queued buffers, requeue the older ones, keep only the newest.
|
||||||
// No active stream: release the buffer without the (expensive at 5K) de-pad.
|
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
||||||
if !ud.active.load(Ordering::Relaxed) {
|
if newest.is_null() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let datas = buffer.datas_mut();
|
let mut drained = 1u32;
|
||||||
if datas.is_empty() {
|
loop {
|
||||||
return;
|
let next = unsafe { stream.dequeue_raw_buffer() };
|
||||||
}
|
if next.is_null() {
|
||||||
let sz = ud.info.size();
|
break;
|
||||||
let (w, h) = (sz.width as usize, sz.height as usize);
|
}
|
||||||
if w == 0 || h == 0 {
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
return; // format not negotiated yet
|
newest = next;
|
||||||
}
|
drained += 1;
|
||||||
|
}
|
||||||
|
let spa_buf = unsafe { (*newest).buffer };
|
||||||
|
|
||||||
// Zero-copy path: if the buffer is a dmabuf and we have an importer, import it
|
// Inspect the newest buffer's header + first chunk for the diagnostic and the
|
||||||
// into a CUDA device buffer (no CPU touch) and deliver that. Otherwise fall
|
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
|
||||||
// through to the shm de-pad copy below.
|
let hdr = unsafe {
|
||||||
let mut gpu_import_broken = false;
|
spa::sys::spa_buffer_find_meta_data(
|
||||||
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
|
spa_buf,
|
||||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
spa::sys::SPA_META_Header,
|
||||||
let plane = crate::zerocopy::DmabufPlane {
|
std::mem::size_of::<spa::sys::spa_meta_header>(),
|
||||||
fd: datas[0].fd(),
|
) as *const spa::sys::spa_meta_header
|
||||||
offset: datas[0].chunk().offset(),
|
};
|
||||||
stride: datas[0].chunk().stride().max(0) as u32,
|
let hdr_flags = if hdr.is_null() {
|
||||||
};
|
0u32
|
||||||
// Tiled modifier → EGL/GL de-tile import; LINEAR (0/unset, e.g.
|
} else {
|
||||||
// gamescope) → direct CUDA external-memory import (NVIDIA EGL can't
|
unsafe { (*hdr).flags }
|
||||||
// sample LINEAR).
|
};
|
||||||
let modifier = (ud.modifier != 0).then_some(ud.modifier);
|
// First data chunk's size + flags (used for the diagnostic + CORRUPTED check)
|
||||||
if let Some(fourcc) = crate::zerocopy::drm_fourcc(fmt) {
|
// and its data type (a dmabuf legitimately reports chunk size 0, so the size-0
|
||||||
let imported = if modifier.is_some() {
|
// stale skip only applies to mappable SHM buffers).
|
||||||
importer.import(&plane, w as u32, h as u32, fourcc, modifier)
|
let (chunk_size, chunk_flags, is_dmabuf) = unsafe {
|
||||||
} else {
|
if !spa_buf.is_null()
|
||||||
importer.import_linear(&plane, w as u32, h as u32)
|
&& (*spa_buf).n_datas > 0
|
||||||
};
|
&& !(*spa_buf).datas.is_null()
|
||||||
match imported {
|
&& !(*(*spa_buf).datas).chunk.is_null()
|
||||||
Ok(devbuf) => {
|
{
|
||||||
static ONCE: std::sync::atomic::AtomicBool =
|
let d0 = (*spa_buf).datas;
|
||||||
std::sync::atomic::AtomicBool::new(true);
|
let c = (*d0).chunk;
|
||||||
if ONCE.swap(false, Ordering::Relaxed) {
|
let is_dmabuf =
|
||||||
tracing::info!(w, h, modifier = ud.modifier,
|
(*d0).type_ == spa::sys::SPA_DATA_DmaBuf;
|
||||||
"zero-copy: dmabuf imported to CUDA (no CPU copy)");
|
((*c).size, (*c).flags, is_dmabuf)
|
||||||
}
|
|
||||||
let pts_ns = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_nanos() as u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let _ = ud.tx.try_send(CapturedFrame {
|
|
||||||
width: w as u32,
|
|
||||||
height: h as u32,
|
|
||||||
pts_ns,
|
|
||||||
format: fmt,
|
|
||||||
payload: FramePayload::Cuda(devbuf),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// GPU import unavailable for this buffer kind (e.g. the
|
|
||||||
// driver rejects LINEAR external-memory import). Disable
|
|
||||||
// the importer and fall through to the CPU mmap path —
|
|
||||||
// degraded, not dead.
|
|
||||||
tracing::warn!(error = %format!("{e:#}"),
|
|
||||||
"dmabuf GPU import failed — falling back to the CPU copy path");
|
|
||||||
gpu_import_broken = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return; // format has no DRM fourcc mapping — skip the frame
|
(0u32, 0i32, false)
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
|
||||||
if gpu_import_broken {
|
|
||||||
ud.importer = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let d = &mut datas[0];
|
let corrupted = (hdr_flags & spa::sys::SPA_META_HEADER_FLAG_CORRUPTED) != 0
|
||||||
// CPU path may also receive LINEAR dmabufs (gamescope offers only those once its
|
|| (chunk_flags & spa::sys::SPA_CHUNK_FLAG_CORRUPTED as i32) != 0;
|
||||||
// modifier-bearing format pod wins); capture the fd before `data()` borrows `d`.
|
|
||||||
let dmabuf_fd =
|
// THE GNOME FLASH FIX: skip Mutter's CORRUPTED / size-0 cursor-update buffers.
|
||||||
(d.type_() == pw::spa::buffer::DataType::DmaBuf).then(|| d.fd());
|
// When the pointer moves (e.g. dragging a window) Mutter sends metadata-only
|
||||||
let (size, offset, stride) = {
|
// buffers flagged CORRUPTED (chunk size 0) that still reference a RECYCLED old
|
||||||
let c = d.chunk();
|
// frame; consuming them encodes "the window at its old position" — the flash.
|
||||||
(
|
// Confirmed live on worker-3 (chunk_flags=CORRUPTED, size 0) for both the zero-copy
|
||||||
c.size() as usize,
|
// and SHM paths. The size-0 half is SHM-only (a real dmabuf legitimately reports
|
||||||
c.offset() as usize,
|
// chunk size 0). `drained` is the latest-frame-only depth — a cheap extra defense
|
||||||
c.stride().max(0) as usize,
|
// against bursty delivery, though here Mutter sends one buffer per callback.
|
||||||
)
|
if corrupted || (chunk_size == 0 && !is_dmabuf) {
|
||||||
};
|
ud.dbg_log_n += 1;
|
||||||
let Some(fmt) = ud.format else { return }; // unsupported/not negotiated
|
if ud.dbg_log_n.is_power_of_two() {
|
||||||
let bpp = fmt.bytes_per_pixel();
|
tracing::debug!(
|
||||||
let row = w * bpp;
|
skipped = ud.dbg_log_n,
|
||||||
let stride = if stride == 0 { row } else { stride };
|
drained,
|
||||||
if stride < row {
|
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
|
||||||
warn_once("chunk stride < row — frames dropped");
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
let needed = stride * (h - 1) + row;
|
|
||||||
// dmabuf chunks commonly report size 0; fall back to the computed span.
|
|
||||||
let size = if size == 0 { needed } else { size };
|
|
||||||
// MAP_BUFFERS only maps buffers flagged mappable; Vulkan-exported dmabufs
|
|
||||||
// (gamescope) usually aren't, so mmap the fd ourselves for the de-pad read.
|
|
||||||
let _mapping; // keeps a manual mmap alive for the copy below
|
|
||||||
let buf: &[u8] = if let Some(data) = d.data() {
|
|
||||||
data
|
|
||||||
} else if let Some(fd) = dmabuf_fd.filter(|&fd| fd > 0) {
|
|
||||||
match DmabufMap::new(fd, offset + needed) {
|
|
||||||
Some(m) => {
|
|
||||||
_mapping = m;
|
|
||||||
unsafe {
|
|
||||||
std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
warn_once("mmap(dmabuf) failed — frames dropped");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
warn_once("buffer has no mappable data — frames dropped");
|
consume_frame(ud, spa_buf);
|
||||||
return;
|
unsafe { stream.queue_raw_buffer(newest) };
|
||||||
};
|
|
||||||
// Need stride*(h-1)+row valid bytes within [offset, offset+size).
|
|
||||||
if offset > buf.len() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let avail = buf.len() - offset;
|
|
||||||
if needed > avail || needed > size {
|
|
||||||
warn_once("buffer smaller than frame span — frames dropped");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let region = &buf[offset..offset + size.min(avail)];
|
|
||||||
// De-pad into a tightly-packed buffer (chunk stride may exceed w*bpp).
|
|
||||||
let mut tight = vec![0u8; row * h];
|
|
||||||
for y in 0..h {
|
|
||||||
tight[y * row..y * row + row]
|
|
||||||
.copy_from_slice(®ion[y * stride..y * stride + row]);
|
|
||||||
}
|
|
||||||
let pts_ns = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_nanos() as u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let frame = CapturedFrame {
|
|
||||||
width: w as u32,
|
|
||||||
height: h as u32,
|
|
||||||
pts_ns,
|
|
||||||
format: fmt,
|
|
||||||
payload: FramePayload::Cpu(tight),
|
|
||||||
};
|
|
||||||
// Drop if the encoder is behind — never block the pipewire loop.
|
|
||||||
let _ = ud.tx.try_send(frame);
|
|
||||||
}));
|
}));
|
||||||
if outcome.is_err() {
|
if outcome.is_err() {
|
||||||
tracing::error!("panic in pipewire process callback — frame dropped");
|
tracing::error!("panic in pipewire process callback — frame dropped");
|
||||||
@@ -1036,6 +1194,9 @@ mod pipewire {
|
|||||||
Some(build_dmabuf_format(&modifiers, preferred)?),
|
Some(build_dmabuf_format(&modifiers, preferred)?),
|
||||||
Some(build_dmabuf_buffers()?),
|
Some(build_dmabuf_buffers()?),
|
||||||
)
|
)
|
||||||
|
} else if force_shm {
|
||||||
|
// True SHM: exclude DmaBuf so Mutter MUST download (glReadPixels orders against render).
|
||||||
|
(None, Some(build_shm_only_buffers()?))
|
||||||
} else {
|
} else {
|
||||||
// CPU path still accepts mappable dmabufs (gamescope offers only those once its
|
// CPU path still accepts mappable dmabufs (gamescope offers only those once its
|
||||||
// modifier-bearing format pod wins the intersection).
|
// modifier-bearing format pod wins the intersection).
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
//! Consumer-side implicit-fence wait for dmabuf capture (`DMA_BUF_IOCTL_EXPORT_SYNC_FILE`).
|
||||||
|
//!
|
||||||
|
//! Mutter renders its virtual monitor DIRECTLY into the PipeWire dmabuf and hands the buffer over
|
||||||
|
//! at GPU-submit time. With no fencing the consumer can sample mid-render and encode the buffer's
|
||||||
|
//! *previous* contents — the "stale/old frame" flashing on NVIDIA (KWin/gamescope blit into the
|
||||||
|
//! buffer so they don't hit this). The producer-driven fix is PipeWire explicit sync, but
|
||||||
|
//! Mutter+NVIDIA can't produce a sync_fd (`error alloc buffers` / no cogl sync_fd).
|
||||||
|
//!
|
||||||
|
//! So sync from the *consumer* side instead: a dmabuf carries its in-flight GPU work as an implicit
|
||||||
|
//! fence on its reservation object. `DMA_BUF_IOCTL_EXPORT_SYNC_FILE` snapshots that into a sync_file
|
||||||
|
//! fd we can `poll()` — readable once the producer's writes complete. This makes zero-copy capture
|
||||||
|
//! race-free WITHOUT the producer doing anything, *iff* the driver actually attaches the fence. If it
|
||||||
|
//! attaches none, the export yields an already-signaled sync_file (poll returns immediately) — no
|
||||||
|
//! wait, no harm, and `waited=false` tells us the driver doesn't fence (so zero-copy would still race).
|
||||||
|
|
||||||
|
use std::os::fd::RawFd;
|
||||||
|
|
||||||
|
// linux/dma-buf.h ioctls on the DMA_BUF_BASE ('b' = 0x62) magic. _IOWR = dir(3)<<30 | size<<16 | base<<8 | nr.
|
||||||
|
const DMA_BUF_BASE: u64 = 0x62;
|
||||||
|
const fn iowr(nr: u32, size: usize) -> u64 {
|
||||||
|
(3u64 << 30) | ((size as u64) << 16) | (DMA_BUF_BASE << 8) | nr as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct DmaBufExportSyncFile {
|
||||||
|
flags: u32,
|
||||||
|
fd: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DMA_BUF_IOCTL_EXPORT_SYNC_FILE: u64 = iowr(2, std::mem::size_of::<DmaBufExportSyncFile>());
|
||||||
|
/// We will READ the buffer → export the fence(s) we must wait for before reading (the producer's writes).
|
||||||
|
const DMA_BUF_SYNC_READ: u32 = 1 << 0;
|
||||||
|
|
||||||
|
/// Wait until the producer's writes to `dmabuf_fd` complete (or `timeout_ms` elapses). Returns:
|
||||||
|
/// - `Ok(true)` — a render was still in flight and we waited on its fence (the race was real, now closed).
|
||||||
|
/// - `Ok(false)` — no fence / already signaled (the driver attaches no implicit fence; zero-copy can race).
|
||||||
|
/// - `Err` — the ioctl failed (e.g. the kernel/driver lacks `EXPORT_SYNC_FILE`).
|
||||||
|
pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<bool> {
|
||||||
|
let mut req = DmaBufExportSyncFile {
|
||||||
|
flags: DMA_BUF_SYNC_READ,
|
||||||
|
fd: -1,
|
||||||
|
};
|
||||||
|
let r = unsafe { libc::ioctl(dmabuf_fd, DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &mut req) };
|
||||||
|
if r < 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let sync_fd = req.fd;
|
||||||
|
if sync_fd < 0 {
|
||||||
|
return Ok(false); // no sync_file exported
|
||||||
|
}
|
||||||
|
let mut pfd = libc::pollfd {
|
||||||
|
fd: sync_fd,
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
};
|
||||||
|
// Non-blocking probe: not-yet-signaled (poll==0) means the producer is still rendering.
|
||||||
|
let pending = unsafe { libc::poll(&mut pfd, 1, 0) } == 0;
|
||||||
|
if pending {
|
||||||
|
pfd.revents = 0;
|
||||||
|
unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; // block until the render fence signals
|
||||||
|
}
|
||||||
|
unsafe { libc::close(sync_fd) };
|
||||||
|
Ok(pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The ioctl number must match linux/dma-buf.h exactly — it's computed, so lock it down.
|
||||||
|
#[test]
|
||||||
|
fn ioctl_number_matches_dma_buf_h() {
|
||||||
|
assert_eq!(DMA_BUF_IOCTL_EXPORT_SYNC_FILE, 0xC008_6202);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! Minimal DRM timeline-syncobj operations — the consumer side of PipeWire explicit sync
|
||||||
|
//! (`SPA_META_SyncTimeline`).
|
||||||
|
//!
|
||||||
|
//! RETAINED BUT CURRENTLY UNUSED: producer-driven explicit sync is the "right" fix, but no
|
||||||
|
//! compositor we target produces a usable sync_fd today — Mutter+NVIDIA fails buffer allocation
|
||||||
|
//! (`error alloc buffers`, no cogl sync_fd), KWin/gamescope blit so they don't race at all. We sync
|
||||||
|
//! zero-copy from the consumer side instead (see [`crate::dmabuf_fence`]). This module is kept,
|
||||||
|
//! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer
|
||||||
|
//! gains working `SPA_META_SyncTimeline`.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
//!
|
||||||
|
//! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual
|
||||||
|
//! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf
|
||||||
|
//! fencing (NVIDIA) reading immediately races the render and shows the buffer's
|
||||||
|
//! *previous* contents. With explicit sync the producer attaches a timeline syncobj:
|
||||||
|
//! wait the acquire point before touching the buffer, signal the release point when done.
|
||||||
|
//!
|
||||||
|
//! Syncobjs are DRM-core objects: any render node can import and wait them, so this
|
||||||
|
//! opens its own fd independent of the capture GPU path.
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use std::os::fd::RawFd;
|
||||||
|
|
||||||
|
// drm.h ioctls on the 'd' (0x64) magic. _IOWR = dir(3)<<30 | size<<16 | 0x64<<8 | nr.
|
||||||
|
const fn iowr(nr: u32, size: usize) -> u64 {
|
||||||
|
(3u64 << 30) | ((size as u64) << 16) | (0x64u64 << 8) | nr as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DrmSyncobjHandle {
|
||||||
|
handle: u32,
|
||||||
|
flags: u32,
|
||||||
|
fd: i32,
|
||||||
|
pad: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DrmSyncobjDestroy {
|
||||||
|
handle: u32,
|
||||||
|
pad: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DrmSyncobjTimelineWait {
|
||||||
|
handles: u64,
|
||||||
|
points: u64,
|
||||||
|
/// Absolute CLOCK_MONOTONIC deadline, nanoseconds.
|
||||||
|
timeout_nsec: i64,
|
||||||
|
count_handles: u32,
|
||||||
|
flags: u32,
|
||||||
|
first_signaled: u32,
|
||||||
|
pad: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DrmSyncobjTimelineArray {
|
||||||
|
handles: u64,
|
||||||
|
points: u64,
|
||||||
|
count_handles: u32,
|
||||||
|
flags: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRM_IOCTL_SYNCOBJ_DESTROY: u64 = iowr(0xC0, std::mem::size_of::<DrmSyncobjDestroy>());
|
||||||
|
const DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE: u64 = iowr(0xC2, std::mem::size_of::<DrmSyncobjHandle>());
|
||||||
|
const DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT: u64 =
|
||||||
|
iowr(0xCA, std::mem::size_of::<DrmSyncobjTimelineWait>());
|
||||||
|
const DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL: u64 =
|
||||||
|
iowr(0xCD, std::mem::size_of::<DrmSyncobjTimelineArray>());
|
||||||
|
|
||||||
|
/// The producer's point may not be attached yet when the buffer reaches us.
|
||||||
|
const DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT: u32 = 1 << 1;
|
||||||
|
|
||||||
|
pub struct DrmSync {
|
||||||
|
fd: RawFd,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DrmSync {
|
||||||
|
pub fn open() -> Result<DrmSync> {
|
||||||
|
let path = c"/dev/dri/renderD128";
|
||||||
|
let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
|
||||||
|
if fd < 0 {
|
||||||
|
bail!("open /dev/dri/renderD128 for syncobj ops: {}", errno());
|
||||||
|
}
|
||||||
|
Ok(DrmSync { fd })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a syncobj fd into a (temporary) handle on our device.
|
||||||
|
fn import(&self, syncobj_fd: RawFd) -> Result<u32> {
|
||||||
|
let mut req = DrmSyncobjHandle {
|
||||||
|
fd: syncobj_fd,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, &mut req) };
|
||||||
|
if r < 0 {
|
||||||
|
bail!("SYNCOBJ_FD_TO_HANDLE: {}", errno());
|
||||||
|
}
|
||||||
|
Ok(req.handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn destroy(&self, handle: u32) {
|
||||||
|
let mut req = DrmSyncobjDestroy {
|
||||||
|
handle,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_DESTROY, &mut req) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block until `point` on the producer's timeline is signaled (the buffer's contents
|
||||||
|
/// are ready), or `timeout_ms` passes.
|
||||||
|
pub fn wait_point(&self, syncobj_fd: RawFd, point: u64, timeout_ms: u64) -> Result<()> {
|
||||||
|
let handle = self.import(syncobj_fd)?;
|
||||||
|
let mut now = libc::timespec {
|
||||||
|
tv_sec: 0,
|
||||||
|
tv_nsec: 0,
|
||||||
|
};
|
||||||
|
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) };
|
||||||
|
let deadline = now.tv_sec * 1_000_000_000 + now.tv_nsec + timeout_ms as i64 * 1_000_000;
|
||||||
|
let handles = [handle];
|
||||||
|
let points = [point];
|
||||||
|
let mut req = DrmSyncobjTimelineWait {
|
||||||
|
handles: handles.as_ptr() as u64,
|
||||||
|
points: points.as_ptr() as u64,
|
||||||
|
timeout_nsec: deadline,
|
||||||
|
count_handles: 1,
|
||||||
|
flags: DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, &mut req) };
|
||||||
|
let saved = errno();
|
||||||
|
self.destroy(handle);
|
||||||
|
if r < 0 {
|
||||||
|
bail!("SYNCOBJ_TIMELINE_WAIT(point {point}): {saved}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal `point` on the consumer release timeline — the producer may reuse the
|
||||||
|
/// buffer. Must be called for every buffer that carried sync metadata, even when the
|
||||||
|
/// frame was skipped, or the producer stalls waiting for it.
|
||||||
|
pub fn signal_point(&self, syncobj_fd: RawFd, point: u64) -> Result<()> {
|
||||||
|
let handle = self.import(syncobj_fd)?;
|
||||||
|
let handles = [handle];
|
||||||
|
let points = [point];
|
||||||
|
let mut req = DrmSyncobjTimelineArray {
|
||||||
|
handles: handles.as_ptr() as u64,
|
||||||
|
points: points.as_ptr() as u64,
|
||||||
|
count_handles: 1,
|
||||||
|
flags: 0,
|
||||||
|
};
|
||||||
|
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, &mut req) };
|
||||||
|
let saved = errno();
|
||||||
|
self.destroy(handle);
|
||||||
|
if r < 0 {
|
||||||
|
bail!("SYNCOBJ_TIMELINE_SIGNAL(point {point}): {saved}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DrmSync {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { libc::close(self.fd) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn errno() -> std::io::Error {
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// `DrmSync::open` must not panic the PipeWire thread; everything is Result-based and the
|
||||||
|
// caller degrades to unsynchronized capture (with a loud warning) when it fails.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The ioctl numbers must match drm.h exactly — computed, so lock them down.
|
||||||
|
#[test]
|
||||||
|
fn ioctl_numbers_match_drm_h() {
|
||||||
|
assert_eq!(DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, 0xC010_64C2);
|
||||||
|
assert_eq!(DRM_IOCTL_SYNCOBJ_DESTROY, 0xC008_64C0);
|
||||||
|
assert_eq!(DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, 0xC028_64CA);
|
||||||
|
assert_eq!(DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, 0xC018_64CD);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round-trip against the real DRM device when one exists (CI containers skip).
|
||||||
|
#[test]
|
||||||
|
fn signal_then_wait_roundtrip() {
|
||||||
|
let Ok(sync) = DrmSync::open() else {
|
||||||
|
eprintln!("no render node — skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Create a fresh syncobj (CREATE = 0xBF), export it, signal point 1, wait point 1.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Create {
|
||||||
|
handle: u32,
|
||||||
|
flags: u32,
|
||||||
|
}
|
||||||
|
const CREATE: u64 = iowr(0xBF, std::mem::size_of::<Create>());
|
||||||
|
const HANDLE_TO_FD: u64 = iowr(0xC1, std::mem::size_of::<DrmSyncobjHandle>());
|
||||||
|
let mut c = Create::default();
|
||||||
|
assert!(unsafe { libc::ioctl(sync.fd, CREATE, &mut c) } >= 0);
|
||||||
|
let mut h = DrmSyncobjHandle {
|
||||||
|
handle: c.handle,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(unsafe { libc::ioctl(sync.fd, HANDLE_TO_FD, &mut h) } >= 0);
|
||||||
|
sync.signal_point(h.fd, 1).expect("signal");
|
||||||
|
sync.wait_point(h.fd, 1, 100).expect("wait after signal");
|
||||||
|
unsafe { libc::close(h.fd) };
|
||||||
|
sync.destroy(c.handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,24 @@ impl NvencEncoder {
|
|||||||
video.set_frame_rate(Some(Rational(fps as i32, 1)));
|
video.set_frame_rate(Some(Rational(fps as i32, 1)));
|
||||||
video.set_bit_rate(bitrate_bps as usize);
|
video.set_bit_rate(bitrate_bps as usize);
|
||||||
video.set_max_bit_rate(bitrate_bps as usize);
|
video.set_max_bit_rate(bitrate_bps as usize);
|
||||||
|
// VBV/HRD buffer — bound the SIZE of any single frame. Under CBR with no buffer set, NVENC
|
||||||
|
// uses a loose default VBV, so a high-motion P-frame is allowed to balloon to many times the
|
||||||
|
// average; those extra packets overflow the bounded send queue + kernel socket buffer and
|
||||||
|
// get dropped, which the client sees as framedrops/jitter (and, on the infinite-GOP path, as
|
||||||
|
// old/stale frames flashing until the next RFI). A tight ~1-frame buffer makes the encoder
|
||||||
|
// hold frame size roughly constant and absorb motion as a momentary QP (quality) dip instead
|
||||||
|
// — the trade we want. Default = 1 frame of bits (bitrate/fps); PUNKTFUNK_VBV_FRAMES tunes it
|
||||||
|
// (larger = better motion quality but bigger per-frame bursts).
|
||||||
|
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<f32>().ok())
|
||||||
|
.filter(|v| v.is_finite() && *v > 0.0)
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
|
||||||
|
.clamp(1.0, i32::MAX as f64);
|
||||||
|
unsafe {
|
||||||
|
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
|
||||||
|
}
|
||||||
video.set_max_b_frames(0);
|
video.set_max_b_frames(0);
|
||||||
// Infinite GOP — NO periodic IDR. A keyframe at 5120x1440 is ~20-40x a P-frame, so a
|
// Infinite GOP — NO periodic IDR. A keyframe at 5120x1440 is ~20-40x a P-frame, so a
|
||||||
// periodic IDR is a recurring multi-millisecond encode+packetize+send spike — the ~2s
|
// periodic IDR is a recurring multi-millisecond encode+packetize+send spike — the ~2s
|
||||||
|
|||||||
@@ -163,6 +163,11 @@ async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A client that vanished mid-press must not leave keys/buttons latched in the
|
||||||
|
// compositor — Mutter keeps the implicit grab of a destroyed device's button and the
|
||||||
|
// focused app stops taking clicks until it is restarted. Release everything still
|
||||||
|
// held before the EIS connection (and its devices) go away.
|
||||||
|
state.release_all(&context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tie down the verbose tuple the connect step returns. The keep-alive must stay alive for the
|
/// Tie down the verbose tuple the connect step returns. The keep-alive must stay alive for the
|
||||||
@@ -360,6 +365,14 @@ struct EiState {
|
|||||||
/// kind a client sends + whether it emitted, so an unexpected client — e.g. a touch-only
|
/// kind a client sends + whether it emitted, so an unexpected client — e.g. a touch-only
|
||||||
/// tablet hitting a compositor without ei_touchscreen — is immediately diagnosable).
|
/// tablet hitting a compositor without ei_touchscreen — is immediately diagnosable).
|
||||||
seen_kinds: u32,
|
seen_kinds: u32,
|
||||||
|
/// Wire codes currently held down (keys = VK, buttons = GameStream ids, touches = ids)
|
||||||
|
/// — synthesized back up at session end ([`EiState::release_all`]). A client that
|
||||||
|
/// vanishes mid-press must not leave the compositor with a latched key or an implicit
|
||||||
|
/// pointer grab: observed on Mutter, a button held by a destroyed EIS device wedges
|
||||||
|
/// click delivery to the focused app until that app is restarted.
|
||||||
|
held_keys: Vec<u32>,
|
||||||
|
held_buttons: Vec<u32>,
|
||||||
|
held_touches: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stable small index per [`InputKind`] for the `seen_kinds` bitmask.
|
/// Stable small index per [`InputKind`] for the `seen_kinds` bitmask.
|
||||||
@@ -390,6 +403,47 @@ impl EiState {
|
|||||||
start: Instant::now(),
|
start: Instant::now(),
|
||||||
injected: 0,
|
injected: 0,
|
||||||
seen_kinds: 0,
|
seen_kinds: 0,
|
||||||
|
held_keys: Vec::new(),
|
||||||
|
held_buttons: Vec::new(),
|
||||||
|
held_touches: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release everything the remote client still holds — called when the session ends
|
||||||
|
/// (client gone, EIS closing). Synthesizes wire-level release events through the
|
||||||
|
/// normal [`EiState::inject`] path so the compositor sees proper key-up / button-up /
|
||||||
|
/// touch-up frames before the devices disappear.
|
||||||
|
fn release_all(&mut self, ctx: &ei::Context) {
|
||||||
|
let (keys, buttons, touches) = (
|
||||||
|
std::mem::take(&mut self.held_keys),
|
||||||
|
std::mem::take(&mut self.held_buttons),
|
||||||
|
std::mem::take(&mut self.held_touches),
|
||||||
|
);
|
||||||
|
if keys.is_empty() && buttons.is_empty() && touches.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
keys = keys.len(),
|
||||||
|
buttons = buttons.len(),
|
||||||
|
touches = touches.len(),
|
||||||
|
"libei: releasing input still held at session end"
|
||||||
|
);
|
||||||
|
let release = |kind: InputKind, code: u32| InputEvent {
|
||||||
|
kind,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
flags: 0,
|
||||||
|
};
|
||||||
|
for code in buttons {
|
||||||
|
self.inject(&release(InputKind::MouseButtonUp, code), ctx);
|
||||||
|
}
|
||||||
|
for code in keys {
|
||||||
|
self.inject(&release(InputKind::KeyUp, code), ctx);
|
||||||
|
}
|
||||||
|
for id in touches {
|
||||||
|
self.inject(&release(InputKind::TouchUp, id), ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,15 +615,24 @@ impl EiState {
|
|||||||
}
|
}
|
||||||
InputKind::MouseScroll => match slot.interface::<ei::Scroll>() {
|
InputKind::MouseScroll => match slot.interface::<ei::Scroll>() {
|
||||||
Some(s) => {
|
Some(s) => {
|
||||||
// GameStream sends WHEEL_DELTA(120)-scaled deltas in `x`; ei scroll_discrete
|
// Wire deltas are WHEEL_DELTA(120)-scaled in `x`. Emit BOTH ei scroll axes
|
||||||
// uses the same 120-per-detent unit. Positive GameStream = up (vertical),
|
// from it: `scroll_discrete` (120-per-detent — drives line/page scrolling)
|
||||||
// which is negative on the ei axis, but = RIGHT (horizontal), which is
|
// AND the continuous `scroll` axis in logical px (≈15 px/detent). Without
|
||||||
// already positive there (moonlight-qt/Sunshine pass horizontal through
|
// the continuous axis Mutter floors a sub-detent delta (trackpad / precise
|
||||||
// unnegated) — only the vertical axis flips.
|
// wheel / fractional smooth scroll) to zero whole clicks, so small scrolls
|
||||||
|
// never register and you have to spin the wheel a lot — emitting the pixel
|
||||||
|
// axis too makes every delta move proportionally (matches the wlr backend's
|
||||||
|
// 15 px/notch). Positive wire = up (vertical, negated on the ei axis) /
|
||||||
|
// RIGHT (horizontal, already positive — moonlight-qt/Sunshine pass it
|
||||||
|
// through unnegated); only the vertical axis flips.
|
||||||
|
const PX_PER_DETENT: f32 = 15.0;
|
||||||
|
let px = ev.x as f32 / 120.0 * PX_PER_DETENT;
|
||||||
if ev.code == SCROLL_HORIZONTAL {
|
if ev.code == SCROLL_HORIZONTAL {
|
||||||
s.scroll_discrete(ev.x, 0);
|
s.scroll_discrete(ev.x, 0);
|
||||||
|
s.scroll(px, 0.0);
|
||||||
} else {
|
} else {
|
||||||
s.scroll_discrete(0, -ev.x);
|
s.scroll_discrete(0, -ev.x);
|
||||||
|
s.scroll(0.0, -px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => emitted = false,
|
None => emitted = false,
|
||||||
@@ -620,6 +683,23 @@ impl EiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if emitted {
|
if emitted {
|
||||||
|
// Track held state on the wire codes so `release_all` can undo it at
|
||||||
|
// session end (vanished clients must not leave anything latched).
|
||||||
|
match ev.kind {
|
||||||
|
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
|
||||||
|
self.held_keys.push(ev.code);
|
||||||
|
}
|
||||||
|
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code),
|
||||||
|
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
|
||||||
|
self.held_buttons.push(ev.code);
|
||||||
|
}
|
||||||
|
InputKind::MouseButtonUp => self.held_buttons.retain(|&c| c != ev.code),
|
||||||
|
InputKind::TouchDown if !self.held_touches.contains(&ev.code) => {
|
||||||
|
self.held_touches.push(ev.code);
|
||||||
|
}
|
||||||
|
InputKind::TouchUp => self.held_touches.retain(|&c| c != ev.code),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
dev.frame(self.last_serial, self.now_us());
|
dev.frame(self.last_serial, self.now_us());
|
||||||
}
|
}
|
||||||
if let Err(e) = ctx.flush() {
|
if let Err(e) = ctx.flush() {
|
||||||
|
|||||||
+224
-15
@@ -28,7 +28,7 @@ use punktfunk_core::input::{InputEvent, InputKind};
|
|||||||
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
||||||
use punktfunk_core::quic::{
|
use punktfunk_core::quic::{
|
||||||
endpoint, io, ClockEcho, ClockProbe, Hello, PairChallenge, PairProof, PairRequest, PairResult,
|
endpoint, io, ClockEcho, ClockProbe, Hello, PairChallenge, PairProof, PairRequest, PairResult,
|
||||||
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, Start, Welcome,
|
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, Start, Welcome,
|
||||||
};
|
};
|
||||||
use punktfunk_core::transport::UdpTransport;
|
use punktfunk_core::transport::UdpTransport;
|
||||||
use punktfunk_core::Session;
|
use punktfunk_core::Session;
|
||||||
@@ -313,7 +313,11 @@ const DEFAULT_BITRATE_KBPS: u32 = 20_000;
|
|||||||
/// clean 1 Gbps with zero send-buffer drops; sustained overruns are still counted as
|
/// clean 1 Gbps with zero send-buffer drops; sustained overruns are still counted as
|
||||||
/// `packets_send_dropped`.
|
/// `packets_send_dropped`.
|
||||||
const MIN_BITRATE_KBPS: u32 = 500;
|
const MIN_BITRATE_KBPS: u32 = 500;
|
||||||
const MAX_BITRATE_KBPS: u32 = 2_000_000;
|
// 8 Gbps ceiling — headroom for a 2.5 Gbps link and the 5 Gbps path (home-worker-3 → Mac Studio,
|
||||||
|
// Mac is 10G). The encoder is pixel-rate bound, not bitrate bound (NVENC emits multi-Gbps trivially;
|
||||||
|
// ~1 Gpix/s per engine, ~2 with the auto 2-way split), so the real ceiling is the transport send
|
||||||
|
// path (UDP GSO + per-packet alloc removal), not this number.
|
||||||
|
const MAX_BITRATE_KBPS: u32 = 8_000_000;
|
||||||
|
|
||||||
/// Resolve a client's [`Hello::bitrate_kbps`] request to the rate the host will configure:
|
/// Resolve a client's [`Hello::bitrate_kbps`] request to the rate the host will configure:
|
||||||
/// `0` → host default; anything else clamped into `[MIN, MAX]`.
|
/// `0` → host default; anything else clamped into `[MIN, MAX]`.
|
||||||
@@ -325,6 +329,17 @@ fn resolve_bitrate_kbps(requested: u32) -> u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FEC recovery percent for the session's Welcome. Default 20% (Sunshine's default too); a clean
|
||||||
|
/// wired LAN can lower it (every recovery shard is wire bytes + packets), so `PUNKTFUNK_FEC_PCT`
|
||||||
|
/// overrides it — e.g. `0` disables FEC entirely, `10` halves the overhead. Clamped to ≤ 90.
|
||||||
|
fn fec_percent_from_env() -> u8 {
|
||||||
|
std::env::var("PUNKTFUNK_FEC_PCT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse::<u8>().ok())
|
||||||
|
.map(|p| p.min(90))
|
||||||
|
.unwrap_or(20)
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistent audio-capturer slot, reused across sessions (same pattern as the GameStream
|
/// Persistent audio-capturer slot, reused across sessions (same pattern as the GameStream
|
||||||
/// path): keeps one warm PipeWire capture stream instead of a connect/negotiate cycle —
|
/// path): keeps one warm PipeWire capture stream instead of a connect/negotiate cycle —
|
||||||
/// and a daemon-side node churn — per session. (Drop now tears a capturer down cleanly.)
|
/// and a daemon-side node churn — per session. (Drop now tears a capturer down cleanly.)
|
||||||
@@ -454,13 +469,34 @@ async fn serve_session(
|
|||||||
punktfunk_core::ABI_VERSION
|
punktfunk_core::ABI_VERSION
|
||||||
);
|
);
|
||||||
if opts.require_pairing {
|
if opts.require_pairing {
|
||||||
let known = endpoint::peer_fingerprint(&conn)
|
let fp = endpoint::peer_fingerprint(&conn);
|
||||||
.map(|fp| np.is_paired(&fingerprint_hex(&fp)))
|
let known = fp
|
||||||
|
.as_ref()
|
||||||
|
.map(|fp| np.is_paired(&fingerprint_hex(fp)))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
anyhow::ensure!(
|
if !known {
|
||||||
known,
|
// Delegated approval (§8b-1): an identified-but-unpaired knock becomes a pending
|
||||||
"unpaired client rejected (this host requires pairing — run the PIN ceremony first)"
|
// request the operator can approve from the console — no PIN fetched out of band.
|
||||||
);
|
// The label is the client's Hello name, else fingerprint-derived. An anonymous
|
||||||
|
// client (no certificate) has no identity to approve, so nothing is recorded.
|
||||||
|
if let Some(fp) = &fp {
|
||||||
|
let fp_hex = fingerprint_hex(fp);
|
||||||
|
// Sanitize the wire-supplied name before it reaches the log (untrusted: an
|
||||||
|
// unpaired device could embed terminal escapes / bidi overrides); note_pending
|
||||||
|
// stores the same sanitized form and derives a fingerprint label when empty.
|
||||||
|
let label = crate::native_pairing::sanitize_device_name(
|
||||||
|
hello.name.as_deref().unwrap_or(""),
|
||||||
|
&fp_hex,
|
||||||
|
);
|
||||||
|
tracing::info!(name = %label, fingerprint = %fp_hex,
|
||||||
|
"unpaired device knocked — held for approval in the console");
|
||||||
|
np.note_pending(&label, &fp_hex);
|
||||||
|
}
|
||||||
|
anyhow::bail!(
|
||||||
|
"unpaired client rejected (this host requires pairing — approve the device \
|
||||||
|
in the console, or run the PIN ceremony)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
crate::encode::validate_dimensions(
|
crate::encode::validate_dimensions(
|
||||||
crate::encode::Codec::H265,
|
crate::encode::Codec::H265,
|
||||||
@@ -510,10 +546,14 @@ async fn serve_session(
|
|||||||
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
|
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
|
||||||
fec: FecConfig {
|
fec: FecConfig {
|
||||||
scheme: FecScheme::Gf16,
|
scheme: FecScheme::Gf16,
|
||||||
fec_percent: 20,
|
fec_percent: fec_percent_from_env(),
|
||||||
max_data_per_block: 4096,
|
max_data_per_block: 4096,
|
||||||
},
|
},
|
||||||
shard_payload: 1200,
|
// ~1452-byte payload keeps the IP datagram within a 1500 MTU (1452 + 40 header + 24
|
||||||
|
// crypto + 8 IP/UDP ≈ 1500), vs the old 1200 — ~17% fewer packets for free, and an even
|
||||||
|
// size (FEC requires even shards). Negotiated, so the client follows. Jumbo (≈8900) is a
|
||||||
|
// future negotiated bump (needs MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
|
||||||
|
shard_payload: 1452,
|
||||||
encrypt: true,
|
encrypt: true,
|
||||||
key,
|
key,
|
||||||
salt: *b"pkf1",
|
salt: *b"pkf1",
|
||||||
@@ -557,6 +597,7 @@ async fn serve_session(
|
|||||||
// hands back a ProbeResult that this task writes to the client. The two control directions
|
// hands back a ProbeResult that this task writes to the client. The two control directions
|
||||||
// (inbound requests, outbound probe results) are multiplexed with `select!`.
|
// (inbound requests, outbound probe results) are multiplexed with `select!`.
|
||||||
let (reconfig_tx, reconfig_rx) = std::sync::mpsc::channel::<punktfunk_core::Mode>();
|
let (reconfig_tx, reconfig_rx) = std::sync::mpsc::channel::<punktfunk_core::Mode>();
|
||||||
|
let (keyframe_tx, keyframe_rx) = std::sync::mpsc::channel::<()>();
|
||||||
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
let (probe_tx, probe_rx) = std::sync::mpsc::channel::<ProbeRequest>();
|
||||||
let (probe_result_tx, mut probe_result_rx) =
|
let (probe_result_tx, mut probe_result_rx) =
|
||||||
tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
tokio::sync::mpsc::unbounded_channel::<ProbeResult>();
|
||||||
@@ -587,6 +628,14 @@ async fn serve_session(
|
|||||||
if ok && reconfig_tx.send(req.mode).is_err() {
|
if ok && reconfig_tx.send(req.mode).is_err() {
|
||||||
break; // data plane gone
|
break; // data plane gone
|
||||||
}
|
}
|
||||||
|
} else if RequestKeyframe::decode(&msg).is_ok() {
|
||||||
|
// Client recovery: its decoder wedged — force the next encoded frame to
|
||||||
|
// be an IDR. Coalesced in the encode loop (a wedge fires several before
|
||||||
|
// the IDR lands); a send error just means the data plane is gone.
|
||||||
|
tracing::debug!("client requested keyframe (decode recovery)");
|
||||||
|
if keyframe_tx.send(()).is_err() {
|
||||||
|
break; // data plane gone
|
||||||
|
}
|
||||||
} else if let Ok(req) = ProbeRequest::decode(&msg) {
|
} else if let Ok(req) = ProbeRequest::decode(&msg) {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_kbps = req.target_kbps,
|
target_kbps = req.target_kbps,
|
||||||
@@ -761,6 +810,7 @@ async fn serve_session(
|
|||||||
seconds,
|
seconds,
|
||||||
stop_stream,
|
stop_stream,
|
||||||
&reconfig_rx,
|
&reconfig_rx,
|
||||||
|
&keyframe_rx,
|
||||||
compositor,
|
compositor,
|
||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
probe_rx,
|
probe_rx,
|
||||||
@@ -1117,6 +1167,14 @@ fn input_thread(
|
|||||||
let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS];
|
let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS];
|
||||||
let mut rumble_seen = [false; MAX_WIRE_PADS];
|
let mut rumble_seen = [false; MAX_WIRE_PADS];
|
||||||
let mut last_refresh = std::time::Instant::now();
|
let mut last_refresh = std::time::Instant::now();
|
||||||
|
// Pointer buttons / keys the client currently holds down. The injector is host-lifetime, so a
|
||||||
|
// press left dangling by an abrupt client disconnect stays latched in the compositor across the
|
||||||
|
// reconnect (Mutter keeps the implicit pointer grab of the still-pressed button — a stuck
|
||||||
|
// left-button-down then turns every later click into a drag: windows move, but clicking buttons
|
||||||
|
// and text inputs does nothing). We synthesize the matching up-events when this session ends —
|
||||||
|
// see the release loop after the `break`.
|
||||||
|
let mut held_buttons: Vec<u32> = Vec::new();
|
||||||
|
let mut held_keys: Vec<u32> = Vec::new();
|
||||||
loop {
|
loop {
|
||||||
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
|
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
|
||||||
Ok(ev) => match ev.kind {
|
Ok(ev) => match ev.kind {
|
||||||
@@ -1132,6 +1190,18 @@ fn input_thread(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
// Track press/release so a mid-press disconnect can be undone below.
|
||||||
|
match ev.kind {
|
||||||
|
InputKind::MouseButtonDown if !held_buttons.contains(&ev.code) => {
|
||||||
|
held_buttons.push(ev.code)
|
||||||
|
}
|
||||||
|
InputKind::MouseButtonUp => held_buttons.retain(|&c| c != ev.code),
|
||||||
|
InputKind::KeyDown if !held_keys.contains(&ev.code) => {
|
||||||
|
held_keys.push(ev.code)
|
||||||
|
}
|
||||||
|
InputKind::KeyUp => held_keys.retain(|&c| c != ev.code),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
// Pointer/keyboard → the host-lifetime injector service (one persistent
|
// Pointer/keyboard → the host-lifetime injector service (one persistent
|
||||||
// portal session for every punktfunk/1 session). A send error only means the
|
// portal session for every punktfunk/1 session). A send error only means the
|
||||||
// service thread is gone (host shutting down) — dropping the event is fine,
|
// service thread is gone (host shutting down) — dropping the event is fine,
|
||||||
@@ -1172,6 +1242,38 @@ fn input_thread(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Session ended (client gone). Release anything still held through the host-lifetime injector —
|
||||||
|
// its EIS connection (and any implicit grab Mutter holds for our pressed button) outlives this
|
||||||
|
// session, so without this a button pressed at disconnect stays latched and breaks clicks for
|
||||||
|
// the next session. Mirror of the injector's own release_all, but keyed off the session, which
|
||||||
|
// is where a client actually vanishes mid-press.
|
||||||
|
if !held_buttons.is_empty() || !held_keys.is_empty() {
|
||||||
|
tracing::debug!(
|
||||||
|
buttons = held_buttons.len(),
|
||||||
|
keys = held_keys.len(),
|
||||||
|
"input: releasing held buttons/keys at session end"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for code in held_buttons {
|
||||||
|
let _ = inj_tx.send(InputEvent {
|
||||||
|
kind: InputKind::MouseButtonUp,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
flags: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for code in held_keys {
|
||||||
|
let _ = inj_tx.send(InputEvent {
|
||||||
|
kind: InputKind::KeyUp,
|
||||||
|
_pad: [0; 3],
|
||||||
|
code,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
flags: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The audio thread: desktop capture → Opus (48 kHz stereo, 5 ms, CBR — same tuning as the
|
/// The audio thread: desktop capture → Opus (48 kHz stereo, 5 ms, CBR — same tuning as the
|
||||||
@@ -1388,7 +1490,7 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
|||||||
/// bitrate cap ([`MAX_BITRATE_KBPS`], 2 Gbps) on purpose — a probe should be able to demonstrate
|
/// bitrate cap ([`MAX_BITRATE_KBPS`], 2 Gbps) on purpose — a probe should be able to demonstrate
|
||||||
/// headroom past the rate a session will actually be configured to use, so the client can pick a
|
/// headroom past the rate a session will actually be configured to use, so the client can pick a
|
||||||
/// confident 1 Gbps+ bitrate. GF(2¹⁶) FEC makes multi-Gbps reachable on a LAN.
|
/// confident 1 Gbps+ bitrate. GF(2¹⁶) FEC makes multi-Gbps reachable on a LAN.
|
||||||
const MAX_PROBE_KBPS: u32 = 3_000_000;
|
const MAX_PROBE_KBPS: u32 = 10_000_000;
|
||||||
const MAX_PROBE_MS: u32 = 5_000;
|
const MAX_PROBE_MS: u32 = 5_000;
|
||||||
|
|
||||||
/// Run a bandwidth probe over `session`: burst zero-filled access units flagged [`FLAG_PROBE`] at
|
/// Run a bandwidth probe over `session`: burst zero-filled access units flagged [`FLAG_PROBE`] at
|
||||||
@@ -1538,10 +1640,10 @@ fn paced_submit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(PaceStat {
|
let spread_us = start.elapsed().as_micros() as u32;
|
||||||
spread_us: start.elapsed().as_micros() as u32,
|
drop(refs); // release the borrow of `wires` so it can return to the seal pool
|
||||||
paced,
|
session.reclaim_wires(wires);
|
||||||
})
|
Ok(PaceStat { spread_us, paced })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percentile of a slice (sorts it in place first). `q` in 0.0..=1.0.
|
/// Percentile of a slice (sorts it in place first). `q` in 0.0..=1.0.
|
||||||
@@ -1667,6 +1769,7 @@ fn virtual_stream(
|
|||||||
seconds: u32,
|
seconds: u32,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||||
|
keyframe: &std::sync::mpsc::Receiver<()>,
|
||||||
compositor: crate::vdisplay::Compositor,
|
compositor: crate::vdisplay::Compositor,
|
||||||
bitrate_kbps: u32,
|
bitrate_kbps: u32,
|
||||||
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
|
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
|
||||||
@@ -1741,6 +1844,18 @@ fn virtual_stream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Client recovery: it asked for a fresh IDR (its decoder wedged on the cold opening
|
||||||
|
// GOP). Coalesce the backlog — several requests fire before the IDR lands — and force
|
||||||
|
// the next encoded frame to be a keyframe. (A reconfig rebuild above already opens with
|
||||||
|
// an IDR, so this is for the steady-state wedge, not mode switches.)
|
||||||
|
let mut want_kf = false;
|
||||||
|
while keyframe.try_recv().is_ok() {
|
||||||
|
want_kf = true;
|
||||||
|
}
|
||||||
|
if want_kf {
|
||||||
|
tracing::debug!("forcing keyframe (client decode recovery)");
|
||||||
|
enc.request_keyframe();
|
||||||
|
}
|
||||||
if let Some(f) = capturer.try_latest().context("capture")? {
|
if let Some(f) = capturer.try_latest().context("capture")? {
|
||||||
frame = f;
|
frame = f;
|
||||||
}
|
}
|
||||||
@@ -2206,6 +2321,100 @@ mod tests {
|
|||||||
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
|
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delegated approval (§8b-1) end to end in-process: an identified-but-unpaired client's
|
||||||
|
/// knock on a pairing-required host is held as a pending request (fingerprint-derived label —
|
||||||
|
/// the connector sends no Hello name); approving it pairs the fingerprint, and the same
|
||||||
|
/// identity then gets a session with no PIN ceremony.
|
||||||
|
#[test]
|
||||||
|
fn delegated_approval_admits_after_knock() {
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::quic::endpoint;
|
||||||
|
|
||||||
|
let store =
|
||||||
|
std::env::temp_dir().join(format!("pf-approval-test-{}.json", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&store);
|
||||||
|
let np = Arc::new(NativePairing::load_with(Some(store.clone()), None, false).unwrap());
|
||||||
|
let np_host = np.clone();
|
||||||
|
let host = std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
rt.block_on(serve(
|
||||||
|
M3Options {
|
||||||
|
port: 19779,
|
||||||
|
source: M3Source::Synthetic,
|
||||||
|
seconds: 0,
|
||||||
|
frames: 25,
|
||||||
|
max_sessions: 2, // the knock + the post-approval session
|
||||||
|
max_concurrent: 1,
|
||||||
|
require_pairing: true,
|
||||||
|
allow_pairing: false,
|
||||||
|
pairing_pin: None,
|
||||||
|
paired_store: None, // unused: the shared `np` IS the store handle
|
||||||
|
},
|
||||||
|
np_host,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
let timeout = std::time::Duration::from_secs(10);
|
||||||
|
let (cert, key) = endpoint::generate_identity().unwrap();
|
||||||
|
let mode = punktfunk_core::Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1: the knock — an identified-but-unpaired connect is rejected, but lands in pending.
|
||||||
|
assert!(
|
||||||
|
NativeClient::connect(
|
||||||
|
"127.0.0.1",
|
||||||
|
19779,
|
||||||
|
mode,
|
||||||
|
CompositorPref::Auto,
|
||||||
|
GamepadPref::Auto,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
Some((cert.clone(), key.clone())),
|
||||||
|
timeout
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"unpaired knock must still be rejected"
|
||||||
|
);
|
||||||
|
let expected_fp = fingerprint_hex(&endpoint::fingerprint_of_pem(&cert).unwrap());
|
||||||
|
let pend = np.pending();
|
||||||
|
assert_eq!(pend.len(), 1, "the knock must be held for approval");
|
||||||
|
assert_eq!(pend[0].fingerprint, expected_fp);
|
||||||
|
assert!(
|
||||||
|
pend[0].name.starts_with("device "),
|
||||||
|
"no Hello name → fingerprint-derived label, got {:?}",
|
||||||
|
pend[0].name
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2: approve (with an operator label) → the same identity now gets a session, no PIN.
|
||||||
|
let approved = np
|
||||||
|
.approve_pending(pend[0].id, Some("Approved Device"))
|
||||||
|
.unwrap()
|
||||||
|
.expect("pending id must approve");
|
||||||
|
assert_eq!(approved.fingerprint, expected_fp);
|
||||||
|
let client = NativeClient::connect(
|
||||||
|
"127.0.0.1",
|
||||||
|
19779,
|
||||||
|
mode,
|
||||||
|
CompositorPref::Auto,
|
||||||
|
GamepadPref::Auto,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
Some((cert, key)),
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
.expect("approved identity gets a session");
|
||||||
|
drop(client);
|
||||||
|
let _ = std::fs::remove_file(&store);
|
||||||
|
host.join().unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// The PIN pairing ceremony + the --require-pairing gate, end to end in-process:
|
/// The PIN pairing ceremony + the --require-pairing gate, end to end in-process:
|
||||||
/// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired
|
/// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired
|
||||||
/// identity gets a session on a pairing-required host; an anonymous client does not.
|
/// identity gets a session on a pairing-required host; an anonymous client does not.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
mod audio;
|
mod audio;
|
||||||
mod capture;
|
mod capture;
|
||||||
mod discovery;
|
mod discovery;
|
||||||
|
mod dmabuf_fence;
|
||||||
|
mod drm_sync;
|
||||||
mod encode;
|
mod encode;
|
||||||
mod gamestream;
|
mod gamestream;
|
||||||
mod inject;
|
mod inject;
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(disarm_native_pairing))
|
.routes(routes!(disarm_native_pairing))
|
||||||
.routes(routes!(list_native_clients))
|
.routes(routes!(list_native_clients))
|
||||||
.routes(routes!(unpair_native_client))
|
.routes(routes!(unpair_native_client))
|
||||||
|
.routes(routes!(list_pending_devices))
|
||||||
|
.routes(routes!(approve_pending_device))
|
||||||
|
.routes(routes!(deny_pending_device))
|
||||||
.routes(routes!(stop_session))
|
.routes(routes!(stop_session))
|
||||||
.routes(routes!(request_idr)),
|
.routes(routes!(request_idr)),
|
||||||
)
|
)
|
||||||
@@ -379,6 +382,29 @@ struct NativeClient {
|
|||||||
fingerprint: String,
|
fingerprint: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An unpaired device that tried to connect while the host requires pairing — awaiting
|
||||||
|
/// **delegated approval** (approve it here instead of fetching the host PIN out of band).
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
struct PendingDevice {
|
||||||
|
/// Id to address approve/deny (per-process; entries expire after ~10 minutes).
|
||||||
|
id: u32,
|
||||||
|
/// Best-effort device label (the client's own name, else fingerprint-derived).
|
||||||
|
#[schema(example = "Enrico's MacBook")]
|
||||||
|
name: String,
|
||||||
|
/// Hex SHA-256 of the device's certificate — what approval pins.
|
||||||
|
fingerprint: String,
|
||||||
|
/// Seconds since the device last knocked.
|
||||||
|
age_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approve-pending-device request body. Send `{}` to keep the device's own name.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
struct ApprovePending {
|
||||||
|
/// Operator-chosen label for the device (defaults to the name it knocked with).
|
||||||
|
#[schema(example = "Living Room TV")]
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Error envelope for every non-2xx response.
|
/// Error envelope for every non-2xx response.
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
struct ApiError {
|
struct ApiError {
|
||||||
@@ -885,6 +911,116 @@ async fn unpair_native_client(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List devices awaiting pairing approval
|
||||||
|
///
|
||||||
|
/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair
|
||||||
|
/// it without a PIN (delegated approval); entries expire after ~10 minutes.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/native/pending",
|
||||||
|
tag = "native",
|
||||||
|
operation_id = "listPendingDevices",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Devices awaiting approval (empty when none, or when the \
|
||||||
|
native host is not enabled)", body = Vec<PendingDevice>),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn list_pending_devices(State(st): State<Arc<MgmtState>>) -> Json<Vec<PendingDevice>> {
|
||||||
|
let pending = st
|
||||||
|
.native
|
||||||
|
.as_ref()
|
||||||
|
.map(|np| np.pending())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Json(
|
||||||
|
pending
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| PendingDevice {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
fingerprint: p.fingerprint,
|
||||||
|
age_secs: p.age_secs,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approve a pending device
|
||||||
|
///
|
||||||
|
/// Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally
|
||||||
|
/// relabel it via the body; send `{}` to keep the name it knocked with.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/native/pending/{id}/approve",
|
||||||
|
tag = "native",
|
||||||
|
operation_id = "approvePendingDevice",
|
||||||
|
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
|
||||||
|
request_body = ApprovePending,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Device paired", body = NativeClient),
|
||||||
|
(status = NOT_FOUND, description = "No pending request with that id (expired?)", body = ApiError),
|
||||||
|
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
|
||||||
|
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn approve_pending_device(
|
||||||
|
State(st): State<Arc<MgmtState>>,
|
||||||
|
Path(id): Path<u32>,
|
||||||
|
ApiJson(req): ApiJson<ApprovePending>,
|
||||||
|
) -> Response {
|
||||||
|
let Some(np) = &st.native else {
|
||||||
|
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
|
||||||
|
};
|
||||||
|
match np.approve_pending(id, req.name.as_deref()) {
|
||||||
|
Ok(Some(client)) => {
|
||||||
|
tracing::info!(name = %client.name, fingerprint = %client.fingerprint,
|
||||||
|
"management API: pending device approved (delegated pairing)");
|
||||||
|
Json(NativeClient {
|
||||||
|
name: client.name,
|
||||||
|
fingerprint: client.fingerprint,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => api_error(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"no pending request with that id (it may have expired — have the device retry)",
|
||||||
|
),
|
||||||
|
Err(e) => api_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("could not persist trust store: {e}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deny a pending device
|
||||||
|
///
|
||||||
|
/// Drops the request. Not a blocklist — the device's next attempt knocks again.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/native/pending/{id}/deny",
|
||||||
|
tag = "native",
|
||||||
|
operation_id = "denyPendingDevice",
|
||||||
|
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
|
||||||
|
responses(
|
||||||
|
(status = NO_CONTENT, description = "Request dropped"),
|
||||||
|
(status = NOT_FOUND, description = "No pending request with that id", body = ApiError),
|
||||||
|
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn deny_pending_device(State(st): State<Arc<MgmtState>>, Path(id): Path<u32>) -> Response {
|
||||||
|
let Some(np) = &st.native else {
|
||||||
|
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
|
||||||
|
};
|
||||||
|
if np.deny_pending(id) {
|
||||||
|
tracing::info!(id, "management API: pending device denied");
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
} else {
|
||||||
|
api_error(StatusCode::NOT_FOUND, "no pending request with that id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the active session
|
/// Stop the active session
|
||||||
///
|
///
|
||||||
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
|
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
|
||||||
@@ -1344,6 +1480,77 @@ mod tests {
|
|||||||
assert_eq!(b["armed"], false);
|
assert_eq!(b["armed"], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pending_devices_approve_and_deny() {
|
||||||
|
let np = Arc::new(
|
||||||
|
crate::native_pairing::NativePairing::load_with(
|
||||||
|
Some(
|
||||||
|
std::env::temp_dir()
|
||||||
|
.join(format!("pf-mgmt-pending-{}.json", std::process::id())),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let app = test_app_native(test_state(), np.clone());
|
||||||
|
|
||||||
|
// Empty queue.
|
||||||
|
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(b.as_array().unwrap().len(), 0);
|
||||||
|
|
||||||
|
// Two devices knock (what the QUIC gate records); they appear in the list.
|
||||||
|
np.note_pending("Enrico's MacBook", "aa11");
|
||||||
|
np.note_pending("device bb22cc33", "bb22");
|
||||||
|
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
|
||||||
|
assert_eq!(b.as_array().unwrap().len(), 2);
|
||||||
|
assert_eq!(b[0]["name"], "Enrico's MacBook");
|
||||||
|
let approve_id = b[0]["id"].as_u64().unwrap();
|
||||||
|
let deny_id = b[1]["id"].as_u64().unwrap();
|
||||||
|
|
||||||
|
// Approve the first with an operator label → paired under that name, gone from pending.
|
||||||
|
let (s, b) = send(
|
||||||
|
&app,
|
||||||
|
post_json(
|
||||||
|
&format!("/api/v1/native/pending/{approve_id}/approve"),
|
||||||
|
serde_json::json!({"name": "Office MacBook"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(b["name"], "Office MacBook");
|
||||||
|
assert_eq!(b["fingerprint"], "aa11");
|
||||||
|
assert!(np.is_paired("AA11"), "approval pins the fingerprint");
|
||||||
|
|
||||||
|
// Deny the second → dropped, not paired; a re-deny is 404.
|
||||||
|
let deny = post_json(
|
||||||
|
&format!("/api/v1/native/pending/{deny_id}/deny"),
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
assert_eq!(send(&app, deny).await.0, StatusCode::NO_CONTENT);
|
||||||
|
assert!(!np.is_paired("bb22"));
|
||||||
|
let (s, _) = send(
|
||||||
|
&app,
|
||||||
|
post_json(
|
||||||
|
&format!("/api/v1/native/pending/{deny_id}/deny"),
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
// Queue is empty again; approving a stale id is 404 (keep `{}` = device's own name).
|
||||||
|
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
|
||||||
|
assert_eq!(b.as_array().unwrap().len(), 0);
|
||||||
|
let (s, _) = send(
|
||||||
|
&app,
|
||||||
|
post_json("/api/v1/native/pending/123/approve", serde_json::json!({})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn native_endpoints_report_disabled_without_native_host() {
|
async fn native_endpoints_report_disabled_without_native_host() {
|
||||||
let app = test_app(test_state(), None);
|
let app = test_app(test_state(), None);
|
||||||
@@ -1357,5 +1564,22 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
|
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
// Pending list reads as an empty array (like /native/clients), not a 503.
|
||||||
|
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(b.as_array().unwrap().len(), 0);
|
||||||
|
// Approve/deny without a native host are 503.
|
||||||
|
let (s, _) = send(
|
||||||
|
&app,
|
||||||
|
post_json("/api/v1/native/pending/0/approve", serde_json::json!({})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
let (s, _) = send(
|
||||||
|
&app,
|
||||||
|
post_json("/api/v1/native/pending/0/deny", serde_json::json!({})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,47 @@ struct Armed {
|
|||||||
expires_at: Option<Instant>,
|
expires_at: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared native-pairing state: the arming PIN window + the persistent trust store.
|
/// An unpaired (but identified) device that knocked on a pairing-required host — held for
|
||||||
|
/// **delegated approval** from the management console (roadmap §8b-1) instead of being silently
|
||||||
|
/// forgotten. In-memory only: pending knocks don't survive a restart (the device just knocks
|
||||||
|
/// again), and they expire after [`PENDING_TTL`].
|
||||||
|
struct Pending {
|
||||||
|
id: u32,
|
||||||
|
name: String,
|
||||||
|
fp_hex: String,
|
||||||
|
requested_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PendingState {
|
||||||
|
next_id: u32,
|
||||||
|
items: Vec<Pending>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pending-approval snapshot for the management API / web console.
|
||||||
|
pub struct PendingRequest {
|
||||||
|
/// Per-process id used to address approve/deny (stable for the entry's lifetime).
|
||||||
|
pub id: u32,
|
||||||
|
/// Best-effort device label (the client's `Hello` name, else fingerprint-derived).
|
||||||
|
pub name: String,
|
||||||
|
/// Hex SHA-256 of the knocking client's certificate — what approval pins.
|
||||||
|
pub fingerprint: String,
|
||||||
|
/// Seconds since the (most recent) knock.
|
||||||
|
pub age_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
|
||||||
|
/// approvable days later when the operator no longer remembers the context).
|
||||||
|
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
|
||||||
|
/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
|
||||||
|
const PENDING_CAP: usize = 32;
|
||||||
|
|
||||||
|
/// Shared native-pairing state: the arming PIN window + the persistent trust store + the
|
||||||
|
/// pending-approval queue.
|
||||||
pub struct NativePairing {
|
pub struct NativePairing {
|
||||||
arm: Mutex<Armed>,
|
arm: Mutex<Armed>,
|
||||||
paired: Mutex<PairedState>,
|
paired: Mutex<PairedState>,
|
||||||
|
pending: Mutex<PendingState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A snapshot for the management API / web console.
|
/// A snapshot for the management API / web console.
|
||||||
@@ -92,6 +129,48 @@ fn random_pin() -> String {
|
|||||||
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
|
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sanitize a client-supplied device name before it's stored, listed, or logged. The name comes
|
||||||
|
/// straight off the wire (the `Hello`/`PairRequest` of an *unpaired* device), so it's untrusted: a
|
||||||
|
/// hostile LAN device could embed terminal escapes / control characters (log + console injection) or
|
||||||
|
/// bidi overrides (`U+202E` etc.) to make a malicious device *look* like a trusted one in the
|
||||||
|
/// approval UI. Strip C0/C1 controls and Unicode bidi/format controls, collapse whitespace, trim, and
|
||||||
|
/// cap the length; an empty/all-control name falls back to a fingerprint-derived label.
|
||||||
|
pub(crate) fn sanitize_device_name(name: &str, fp_hex: &str) -> String {
|
||||||
|
let cleaned: String = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c == '\t' || c == '\n' { ' ' } else { c })
|
||||||
|
.filter(|&c| {
|
||||||
|
!c.is_control()
|
||||||
|
// Bidi/format controls that could spoof or reorder the displayed name.
|
||||||
|
&& !('\u{202A}'..='\u{202E}').contains(&c) // LRE..RLO/PDF
|
||||||
|
&& !('\u{2066}'..='\u{2069}').contains(&c) // LRI..PDI
|
||||||
|
&& c != '\u{200E}' // LRM
|
||||||
|
&& c != '\u{200F}' // RLM
|
||||||
|
&& c != '\u{061C}' // ALM
|
||||||
|
&& c != '\u{FEFF}' // BOM / zero-width no-break space
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Collapse internal whitespace runs, trim, cap at the wire limit.
|
||||||
|
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let mut trimmed = collapsed.as_str();
|
||||||
|
while trimmed.len() > NAME_MAX {
|
||||||
|
let mut cut = NAME_MAX;
|
||||||
|
while !trimmed.is_char_boundary(cut) {
|
||||||
|
cut -= 1;
|
||||||
|
}
|
||||||
|
trimmed = &trimmed[..cut];
|
||||||
|
}
|
||||||
|
let trimmed = trimmed.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
format!("device {}", &fp_hex[..8.min(fp_hex.len())])
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max stored device-name length (matches the `Hello` wire cap, `quic::HELLO_NAME_MAX`).
|
||||||
|
const NAME_MAX: usize = 64;
|
||||||
|
|
||||||
impl NativePairing {
|
impl NativePairing {
|
||||||
/// Load the trust store. `store_path = None` uses the default config path. If `arm_at_start`
|
/// Load the trust store. `store_path = None` uses the default config path. If `arm_at_start`
|
||||||
/// (the CLI `--allow-pairing`/`--require-pairing` flags), arm immediately with `fixed_pin`
|
/// (the CLI `--allow-pairing`/`--require-pairing` flags), arm immediately with `fixed_pin`
|
||||||
@@ -117,6 +196,7 @@ impl NativePairing {
|
|||||||
Ok(NativePairing {
|
Ok(NativePairing {
|
||||||
arm: Mutex::new(arm),
|
arm: Mutex::new(arm),
|
||||||
paired: Mutex::new(PairedState { path, clients }),
|
paired: Mutex::new(PairedState { path, clients }),
|
||||||
|
pending: Mutex::new(PendingState::default()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,15 +252,33 @@ impl NativePairing {
|
|||||||
self.paired.lock().unwrap().clients.contains(fp_hex)
|
self.paired.lock().unwrap().clients.contains(fp_hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a successful pairing (re-pairing the same fingerprint just updates the name).
|
/// Record a successful pairing (re-pairing the same fingerprint just updates the name —
|
||||||
|
/// matched case-insensitively, like every other fingerprint comparison here). The name is
|
||||||
|
/// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never
|
||||||
|
/// diverges from disk. Also clears any pending knock for this fingerprint (it's now paired).
|
||||||
pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
|
pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
|
||||||
let mut p = self.paired.lock().unwrap();
|
let name = sanitize_device_name(name, fp_hex);
|
||||||
p.clients.clients.retain(|c| c.fingerprint != fp_hex);
|
{
|
||||||
p.clients.clients.push(PairedClient {
|
let mut p = self.paired.lock().unwrap();
|
||||||
name: name.to_string(),
|
let snapshot = p.clients.clients.clone(); // restore on a failed save
|
||||||
fingerprint: fp_hex.to_string(),
|
p.clients
|
||||||
});
|
.clients
|
||||||
save(&p)
|
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
|
||||||
|
p.clients.clients.push(PairedClient {
|
||||||
|
name,
|
||||||
|
fingerprint: fp_hex.to_string(),
|
||||||
|
});
|
||||||
|
if let Err(e) = save(&p) {
|
||||||
|
p.clients.clients = snapshot;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A device that knocked and is now paired shouldn't linger in the approval list.
|
||||||
|
let mut pending = self.pending.lock().unwrap();
|
||||||
|
pending
|
||||||
|
.items
|
||||||
|
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The paired clients (for the management API's device list).
|
/// The paired clients (for the management API's device list).
|
||||||
@@ -188,19 +286,122 @@ impl NativePairing {
|
|||||||
self.paired.lock().unwrap().clients.clients.clone()
|
self.paired.lock().unwrap().clients.clients.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a paired client by fingerprint. Returns whether one was removed.
|
/// Remove a paired client by fingerprint. Returns whether one was removed. On a persist
|
||||||
|
/// failure the in-memory store is rolled back (it never diverges from disk).
|
||||||
pub fn remove(&self, fp_hex: &str) -> Result<bool> {
|
pub fn remove(&self, fp_hex: &str) -> Result<bool> {
|
||||||
let mut p = self.paired.lock().unwrap();
|
let mut p = self.paired.lock().unwrap();
|
||||||
let before = p.clients.clients.len();
|
let before = p.clients.clients.len();
|
||||||
|
let snapshot = p.clients.clients.clone();
|
||||||
p.clients
|
p.clients
|
||||||
.clients
|
.clients
|
||||||
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
|
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
|
||||||
let removed = p.clients.clients.len() != before;
|
let removed = p.clients.clients.len() != before;
|
||||||
if removed {
|
if removed {
|
||||||
save(&p)?;
|
if let Err(e) = save(&p) {
|
||||||
|
p.clients.clients = snapshot;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(removed)
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Delegated approval (roadmap §8b-1) --------------------------------
|
||||||
|
|
||||||
|
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]).
|
||||||
|
fn expire_pending(pending: &mut PendingState) {
|
||||||
|
pending
|
||||||
|
.items
|
||||||
|
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same
|
||||||
|
/// fingerprint refresh the existing entry in place (same id; a connect-retry loop must not spam
|
||||||
|
/// the list); a fresh fingerprint gets a new id, evicting the **least-recently-active** entry
|
||||||
|
/// past [`PENDING_CAP`]. The name is sanitized (untrusted; see [`sanitize_device_name`]).
|
||||||
|
pub fn note_pending(&self, name: &str, fp_hex: &str) {
|
||||||
|
let name = sanitize_device_name(name, fp_hex);
|
||||||
|
let mut pending = self.pending.lock().unwrap();
|
||||||
|
Self::expire_pending(&mut pending);
|
||||||
|
if let Some(p) = pending
|
||||||
|
.items
|
||||||
|
.iter_mut()
|
||||||
|
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
|
||||||
|
{
|
||||||
|
p.requested_at = Instant::now();
|
||||||
|
p.name = name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if pending.items.len() >= PENDING_CAP {
|
||||||
|
// Evict the least-recently-active entry. NOT index 0: the in-place refresh above means
|
||||||
|
// Vec order no longer tracks recency, so pick the minimum `requested_at` explicitly.
|
||||||
|
if let Some(at) = pending
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.min_by_key(|(_, p)| p.requested_at)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
{
|
||||||
|
pending.items.remove(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let id = pending.next_id;
|
||||||
|
pending.next_id = pending.next_id.wrapping_add(1);
|
||||||
|
pending.items.push(Pending {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
fp_hex: fp_hex.to_string(),
|
||||||
|
requested_at: Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The devices currently awaiting approval (for the management API).
|
||||||
|
pub fn pending(&self) -> Vec<PendingRequest> {
|
||||||
|
let mut pending = self.pending.lock().unwrap();
|
||||||
|
Self::expire_pending(&mut pending);
|
||||||
|
pending
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|p| PendingRequest {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name.clone(),
|
||||||
|
fingerprint: p.fp_hex.clone(),
|
||||||
|
age_secs: p.requested_at.elapsed().as_secs(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator
|
||||||
|
/// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such
|
||||||
|
/// (or expired) id.
|
||||||
|
pub fn approve_pending(
|
||||||
|
&self,
|
||||||
|
id: u32,
|
||||||
|
name_override: Option<&str>,
|
||||||
|
) -> Result<Option<PairedClient>> {
|
||||||
|
let entry = {
|
||||||
|
let mut pending = self.pending.lock().unwrap();
|
||||||
|
Self::expire_pending(&mut pending);
|
||||||
|
let Some(at) = pending.items.iter().position(|p| p.id == id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
pending.items.remove(at)
|
||||||
|
}; // pending lock released — add() takes the paired lock
|
||||||
|
let name = name_override.unwrap_or(&entry.name);
|
||||||
|
self.add(name, &entry.fp_hex)?;
|
||||||
|
Ok(Some(PairedClient {
|
||||||
|
name: name.to_string(),
|
||||||
|
fingerprint: entry.fp_hex,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
|
||||||
|
/// re-creates an entry — deny is "not now", not a blocklist.
|
||||||
|
pub fn deny_pending(&self, id: u32) -> bool {
|
||||||
|
let mut pending = self.pending.lock().unwrap();
|
||||||
|
let before = pending.items.len();
|
||||||
|
pending.items.retain(|p| p.id != id);
|
||||||
|
pending.items.len() != before
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -250,6 +451,101 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&p);
|
let _ = std::fs::remove_file(&p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_knock_approve_and_deny() {
|
||||||
|
let p = temp();
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
|
||||||
|
assert!(np.pending().is_empty());
|
||||||
|
|
||||||
|
// A knock appears; a re-knock from the same fingerprint refreshes (same id, new name)
|
||||||
|
// instead of duplicating.
|
||||||
|
np.note_pending("device aa11", "AA11");
|
||||||
|
np.note_pending("Bedroom TV", "aa11");
|
||||||
|
let pend = np.pending();
|
||||||
|
assert_eq!(pend.len(), 1, "re-knock dedups by fingerprint");
|
||||||
|
assert_eq!(pend[0].name, "Bedroom TV");
|
||||||
|
let id = pend[0].id;
|
||||||
|
|
||||||
|
// Deny drops it without pairing; the next knock gets a fresh id.
|
||||||
|
assert!(np.deny_pending(id));
|
||||||
|
assert!(!np.deny_pending(id));
|
||||||
|
assert!(np.pending().is_empty());
|
||||||
|
assert!(!np.is_paired("aa11"));
|
||||||
|
|
||||||
|
// Approve pairs the fingerprint (operator label wins) and clears the entry.
|
||||||
|
np.note_pending("device bb22", "BB22");
|
||||||
|
let id = np.pending()[0].id;
|
||||||
|
assert!(
|
||||||
|
np.approve_pending(9999, None).unwrap().is_none(),
|
||||||
|
"unknown id"
|
||||||
|
);
|
||||||
|
let client = np
|
||||||
|
.approve_pending(id, Some("Living Room"))
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(client.name, "Living Room");
|
||||||
|
assert!(np.is_paired("bb22"), "approval pins the fingerprint");
|
||||||
|
assert!(np.pending().is_empty());
|
||||||
|
assert_eq!(np.list()[0].name, "Living Room");
|
||||||
|
|
||||||
|
// The cap evicts the oldest knock.
|
||||||
|
for i in 0..(PENDING_CAP + 3) {
|
||||||
|
np.note_pending("flood", &format!("f{i:03}"));
|
||||||
|
}
|
||||||
|
let pend = np.pending();
|
||||||
|
assert_eq!(pend.len(), PENDING_CAP);
|
||||||
|
assert_eq!(pend[0].fingerprint, "f003", "oldest entries evicted first");
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_strips_control_and_bidi() {
|
||||||
|
// ANSI escape + newline + a bidi override that could spoof the displayed name.
|
||||||
|
let dirty = "\u{1b}]0;evil\u{07}Good\nDevice\u{202E}xfp";
|
||||||
|
let clean = sanitize_device_name(dirty, "deadbeef00");
|
||||||
|
assert!(!clean.contains('\u{1b}') && !clean.contains('\n') && !clean.contains('\u{202E}'));
|
||||||
|
// ESC dropped (']' survives), BEL dropped, '\n'→space (Good Device), RLO dropped (no space).
|
||||||
|
assert_eq!(clean, "]0;evilGood Devicexfp");
|
||||||
|
// All-control / empty → fingerprint-derived fallback.
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_device_name("\u{1b}\u{07}", "deadbeef00"),
|
||||||
|
"device deadbeef"
|
||||||
|
);
|
||||||
|
assert_eq!(sanitize_device_name(" ", "abc"), "device abc");
|
||||||
|
// Over-long names cap at a char boundary.
|
||||||
|
assert!(sanitize_device_name(&"x".repeat(200), "ab").len() <= 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pairing_clears_a_pending_knock() {
|
||||||
|
let p = temp();
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
|
||||||
|
np.note_pending("Knocker", "cc44");
|
||||||
|
assert_eq!(np.pending().len(), 1);
|
||||||
|
// Pairing the same fingerprint (e.g. via the PIN ceremony) drops the stale pending entry.
|
||||||
|
np.add("Knocker", "CC44").unwrap();
|
||||||
|
assert!(
|
||||||
|
np.pending().is_empty(),
|
||||||
|
"a now-paired device must leave the approval list"
|
||||||
|
);
|
||||||
|
assert!(np.is_paired("cc44"));
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_replaces_case_insensitively() {
|
||||||
|
let p = temp();
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
|
||||||
|
np.add("First", "AB12").unwrap();
|
||||||
|
np.add("Second", "ab12").unwrap(); // same device, different hex case
|
||||||
|
assert_eq!(np.list().len(), 1, "re-add must replace, not duplicate");
|
||||||
|
assert_eq!(np.list()[0].name, "Second");
|
||||||
|
let _ = std::fs::remove_file(&p);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cli_flag_arms_with_no_expiry() {
|
fn cli_flag_arms_with_no_expiry() {
|
||||||
let p = temp();
|
let p = temp();
|
||||||
|
|||||||
@@ -151,13 +151,9 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
|
|||||||
// windows land on the surface we stream. Without this, on a host that also has a physical
|
// windows land on the surface we stream. Without this, on a host that also has a physical
|
||||||
// monitor attached, the virtual output is an empty extended desktop — you stream only the
|
// monitor attached, the virtual output is an empty extended desktop — you stream only the
|
||||||
// wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
|
// wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
|
||||||
let mut restore: Option<(zbus::Proxy<'static>, Vec<ApplyLogical>)> = None;
|
|
||||||
if let Some((dc, pre)) = &dc_pre {
|
if let Some((dc, pre)) = &dc_pre {
|
||||||
match make_virtual_primary(dc, mode, pre).await {
|
match make_virtual_primary(dc, mode, pre).await {
|
||||||
Ok(()) => {
|
Ok(()) => tracing::info!("mutter: virtual output set as the primary monitor"),
|
||||||
restore = Some((dc.clone(), to_apply_logicals(pre)));
|
|
||||||
tracing::info!("mutter: virtual output set as the primary monitor");
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!(
|
||||||
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
|
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
|
||||||
),
|
),
|
||||||
@@ -169,19 +165,17 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
|
|||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tear down: STOP the screencast FIRST so Mutter removes the virtual output and auto-reverts
|
// Tear down: STOP the screencast so Mutter removes the virtual output. We deliberately do NOT
|
||||||
// the temporary monitor config (physical → primary). Reconfiguring an *actively-captured*
|
// re-assert the physical layout with our own ApplyMonitorsConfig. Issuing a monitor reconfig
|
||||||
// high-refresh virtual output via ApplyMonitorsConfig was SIGSEGVing gnome-shell on teardown,
|
// while the just-removed high-refresh virtual output is still tearing down SIGSEGVs gnome-shell
|
||||||
// so we never touch the layout while the virtual output is still live.
|
// on Mutter 50 + NVIDIA — observed live on home-worker-3: the teardown ApplyMonitorsConfig
|
||||||
|
// returned "recipient disconnected from message bus" because the shell crashed mid-call, after
|
||||||
|
// which GDM's crash-loop guard dropped to the greeter and wedged EVERY subsequent reconnect.
|
||||||
|
// make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once
|
||||||
|
// the virtual output disappears and our DisplayConfig connection (`dc_pre`) closes — so we just
|
||||||
|
// drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
|
||||||
let _ = session.rd_session.call_method("Stop", &()).await;
|
let _ = session.rd_session.call_method("Stop", &()).await;
|
||||||
if let Some((dc, original)) = restore {
|
drop(dc_pre);
|
||||||
// Let Mutter drop the virtual output, then re-assert the physical layout deterministically
|
|
||||||
// (a no-op if the temporary config already auto-reverted) — safe now: no live virtual.
|
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
||||||
if let Err(e) = apply_config(&dc, &original).await {
|
|
||||||
tracing::warn!("mutter: monitor-layout restore after stop failed ({e:#}); Mutter auto-reverts the temporary config on teardown");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,42 +474,3 @@ fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
|
|||||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||||
)]
|
)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a captured `GetCurrentState` layout back into an `ApplyMonitorsConfig` argument (used
|
|
||||||
/// to restore the physical-primary layout on teardown).
|
|
||||||
fn to_apply_logicals(state: &CurrentState) -> Vec<ApplyLogical> {
|
|
||||||
state
|
|
||||||
.2
|
|
||||||
.iter()
|
|
||||||
.filter_map(|lm| {
|
|
||||||
let mons: Vec<ApplyMon> = lm
|
|
||||||
.5
|
|
||||||
.iter()
|
|
||||||
.filter_map(|s| {
|
|
||||||
current_mode(state, &s.0).map(|(id, _, _)| (s.0.clone(), id, HashMap::new()))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if mons.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some((lm.0, lm.1, lm.2, lm.3, lm.4, mons))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn apply_config(dc: &zbus::Proxy<'_>, logicals: &[ApplyLogical]) -> Result<()> {
|
|
||||||
let state = get_state(dc).await?;
|
|
||||||
let _: () = dc
|
|
||||||
.call(
|
|
||||||
"ApplyMonitorsConfig",
|
|
||||||
&(
|
|
||||||
state.0,
|
|
||||||
APPLY_TEMPORARY,
|
|
||||||
logicals.to_vec(),
|
|
||||||
HashMap::<String, Value<'static>>::new(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.context("DisplayConfig.ApplyMonitorsConfig (restore)")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ title: Pairing & Trust
|
|||||||
description: How a client and host establish trust — PIN pairing once, pinned reconnects after.
|
description: How a client and host establish trust — PIN pairing once, pinned reconnects after.
|
||||||
---
|
---
|
||||||
|
|
||||||
punktfunk has no accounts and no cloud. Trust is established directly between a client and a host, on
|
punktfunk has no accounts and no cloud. Trust is established directly between a client and a host,
|
||||||
your network, with a one-time **PIN pairing**. After that, the device reconnects automatically on a
|
on your network, with a one-time pairing — either an **approval click in the host's console** or a
|
||||||
pinned cryptographic identity.
|
**PIN ceremony**. After that, the device reconnects automatically on a pinned cryptographic
|
||||||
|
identity.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -17,7 +18,24 @@ pinned cryptographic identity.
|
|||||||
- After pairing, the host stores the client's identity in its allow-list, and the client stores the
|
- After pairing, the host stores the client's identity in its allow-list, and the client stores the
|
||||||
host's fingerprint. Reconnects are automatic — no PIN.
|
host's fingerprint. Reconnects are automatic — no PIN.
|
||||||
|
|
||||||
## Arming pairing on the host
|
## Approving a device from the console (no PIN)
|
||||||
|
|
||||||
|
The fastest way to admit a new device: just **try to connect** from it. On a pairing-required host,
|
||||||
|
the attempt shows up in the web console's Pairing page under **Waiting for approval** — with the
|
||||||
|
device's name and identity fingerprint. Click **Approve** (and optionally give it a label like
|
||||||
|
"Living Room TV"), and the device is paired on the spot: its next connect goes straight through. No
|
||||||
|
PIN to read or type.
|
||||||
|
|
||||||
|
**Deny** just dismisses the request (the device can knock again later — it's "not now", not a
|
||||||
|
blocklist). Requests expire on their own after a few minutes.
|
||||||
|
|
||||||
|
This works because approval happens on the host's authenticated management surface — only someone
|
||||||
|
with console access can admit a device.
|
||||||
|
|
||||||
|
## Pairing with a PIN
|
||||||
|
|
||||||
|
The PIN ceremony is the other path — useful for the *first* device (before the console has admitted
|
||||||
|
anything) or when you're at the client and the console isn't handy.
|
||||||
|
|
||||||
Pairing has to be **armed** on the host before a client can pair (so a random device can't pair
|
Pairing has to be **armed** on the host before a client can pair (so a random device can't pair
|
||||||
itself). Two ways:
|
itself). Two ways:
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ mostly-mechanical port. Recommended start: **Phase 0** — capture an existing m
|
|||||||
stack end to end; **Phase 1** wires SudoVDA for the native-resolution output. Deferred only because
|
stack end to end; **Phase 1** wires SudoVDA for the native-resolution output. Deferred only because
|
||||||
it's unbuildable on the Linux dev box; the trait boundaries are already in the right places.
|
it's unbuildable on the Linux dev box; the trait boundaries are already in the right places.
|
||||||
|
|
||||||
## 8. Pairing & trust hardening *(next)*
|
## 8. Pairing & trust hardening *(§8a + §8b-1 done; §8b-2 next)*
|
||||||
|
|
||||||
The unified host + web-console pairing (arm a window → display the host PIN → user enters it on the
|
The unified host + web-console pairing (arm a window → display the host PIN → user enters it on the
|
||||||
client) is built and live. Two changes harden it from "works" to "secure by default":
|
client) is built and live. Two changes harden it from "works" to "secure by default":
|
||||||
@@ -154,24 +154,23 @@ client) is built and live. Two changes harden it from "works" to "secure by defa
|
|||||||
is via the SPAKE2 PIN ceremony (one online guess, no offline attack) armed from the web console.
|
is via the SPAKE2 PIN ceremony (one online guess, no offline attack) armed from the web console.
|
||||||
Validated live: unpaired → "this host requires pairing", then web-armed PIN → "client trusted".
|
Validated live: unpaired → "this host requires pairing", then web-armed PIN → "client trusted".
|
||||||
Deployed to the dev box + Bazzite.
|
Deployed to the dev box + Bazzite.
|
||||||
- **Delegated pairing approval** *(next — the ergonomic enabler for "mandatory": pair a device
|
- ✅ **§8b-1 Delegated approval via the console — done (2026-06-12)** *(the ergonomic enabler for
|
||||||
without fetching the host PIN out of band).* Target flow:
|
"mandatory": pair a device without fetching the host PIN out of band).* An identified-but-unpaired
|
||||||
1. Device A is already paired (authenticated) to Host X.
|
device that knocks on a pairing-required host is held as a **pending request** in `NativePairing`
|
||||||
2. The user tries to connect Device B to Host X.
|
(in-memory, deduped by fingerprint, 32-entry cap, 10-min expiry — a LAN scanner can't grow it,
|
||||||
3. Host X surfaces a request: *"Allow Device B to pair with Host X?"*
|
and an anonymous client with no certificate records nothing). The mgmt API gains
|
||||||
4. The user approves/denies; on approve, Host X admits Device B — binding B's certificate
|
`GET /native/pending` + `POST /native/pending/{id}/approve` (optional `{name}` to relabel) +
|
||||||
fingerprint — with no PIN typed.
|
`POST /native/pending/{id}/deny`; the web console's Pairing page shows a **Waiting for approval**
|
||||||
|
section (live-polling) with Approve/Deny — approve pins the fingerprint on the spot, no PIN.
|
||||||
Two buildable layers:
|
The `Hello` carries an optional trailing **device name** (same back-compat pattern as
|
||||||
- **§8b-1 (host + web — achievable now):** an unpaired B that connects to an approval-enabled host
|
compositor/gamepad/bitrate; `client-rs --name` sends it, fingerprint-derived label otherwise) so
|
||||||
is held as a **pending request** `{id, name, fingerprint, requested_at}` in `NativePairing`
|
the pending list is human-readable. End-to-end tested (knock → pending → approve → same identity
|
||||||
instead of a flat reject; mgmt gains `GET /native/pending` + `POST /native/pending/{id}/{approve,
|
streams) + unit/mgmt tests.
|
||||||
deny}`; the web console lists pending requests with Approve/Deny. The **operator approves from
|
- **§8b-2 (peer push — needs the client):** the host also pushes the pending request over a paired
|
||||||
the console** — delegated approval via the management surface.
|
**Device A**'s live QUIC connection (a new control-plane message); A's app renders the prompt and
|
||||||
- **§8b-2 (peer push — needs the client):** the host also pushes the pending request over a paired
|
replies approve/deny — the user's exact "Device A gets a notification" flow. The native/Apple UI
|
||||||
**Device A**'s live QUIC connection (a new control-plane message); A's app renders the prompt and
|
is a client-agent task. The Apple connector should also start sending the `Hello` device name
|
||||||
replies approve/deny — the user's exact "Device A gets a notification" flow. The native/Apple UI
|
(needs a connect-ABI name parameter; the wire field is already live).
|
||||||
is a client-agent task.
|
|
||||||
|
|
||||||
PIN pairing (§8a) stays the bootstrap — the first device, or when no approver is online.
|
PIN pairing (§8a) stays the bootstrap — the first device, or when no approver is online.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ and the design in the [Implementation Plan](/docs/implementation-plan); this pag
|
|||||||
| **M1** — `punktfunk-core` + C ABI (protocol · FEC · crypto) | ✅ complete & hardened |
|
| **M1** — `punktfunk-core` + C ABI (protocol · FEC · crypto) | ✅ complete & hardened |
|
||||||
| **M2** — GameStream host (Moonlight-compatible) | ✅ working end-to-end; HDR/surround-audio polish open |
|
| **M2** — GameStream host (Moonlight-compatible) | ✅ working end-to-end; HDR/surround-audio polish open |
|
||||||
| **M3** — `punktfunk/1` native protocol (QUIC control + UDP data) | ✅ full session planes, validated live |
|
| **M3** — `punktfunk/1` native protocol (QUIC control + UDP data) | ✅ full session planes, validated live |
|
||||||
| **M4** — native client decode + present (Apple first) | 🟡 stage 1 live; stage-2 presenter built + decode-tested (opt-in, present needs live validation) |
|
| **M4** — native client decode + present (Apple first) | 🟡 macOS stage 1 live; stage-2 presenter built + decode-tested (opt-in, present needs live validation). **Linux GTK client stage 1 live** (2026-06-12) |
|
||||||
|
|
||||||
## Live on the boxes
|
## Live on the boxes
|
||||||
|
|
||||||
@@ -29,6 +29,28 @@ All three appliances advertise over mDNS (`_punktfunk._udp`) and require PIN pai
|
|||||||
## Progress log
|
## Progress log
|
||||||
|
|
||||||
### 2026-06-12
|
### 2026-06-12
|
||||||
|
- **Native Linux client — stage 1, first light** (`crates/punktfunk-client-linux`, binary
|
||||||
|
`punktfunk-client`). GTK4/libadwaita app on the **Option A** architecture picked after a
|
||||||
|
six-angle research pass (toolkits / hw decode / Wayland presentation / input capture /
|
||||||
|
prior art / codebase): links `punktfunk-core` directly as a crate (no C ABI;
|
||||||
|
`NativeClient` is `Sync` now), mDNS host list, TOFU + SPAKE2 PIN pairing dialogs
|
||||||
|
(identity shared with `client-rs`), FFmpeg software HEVC decode (`LOW_DELAY` + slice
|
||||||
|
threads) into a `GtkGraphicsOffload`-wrapped picture, PipeWire playback with the host
|
||||||
|
mic-player's jitter ring inverted, SDL3 gamepad capture + rumble/lightbar feedback,
|
||||||
|
layout-independent keyboard (exact inverse of the host's VK table), absolute mouse +
|
||||||
|
WHEEL_DELTA scroll, compositor-shortcut inhibition, fullscreen, stats overlay.
|
||||||
|
**Validated live** against this box's `serve --native`: 1080p60 at a locked 60 fps,
|
||||||
|
capture→decoded **p50 ≈ 6.4 ms** (software decode, debug build). Next: VAAPI dmabuf →
|
||||||
|
`GdkDmabufTexture` (Tier-1 zero-copy on Intel/AMD clients), DualSense
|
||||||
|
touchpad/motion/trigger replay over SDL3, then the stage-2 raw-Wayland presenter
|
||||||
|
(wp_presentation feedback, tearing-control, Vulkan Video for NVIDIA clients).
|
||||||
|
- **Delegated pairing approval (§8b-1)** — an unpaired device that tries to connect to a
|
||||||
|
pairing-required host now shows up as a **pending request** in the web console's Pairing page;
|
||||||
|
one click approves it (optionally relabeling) and pairs its certificate fingerprint — no PIN
|
||||||
|
fetched out of band. New mgmt endpoints (`/native/pending` + approve/deny), an in-memory pending
|
||||||
|
queue in `NativePairing` (fp-deduped, capped, 10-min expiry), and an optional **device name** in
|
||||||
|
the `Hello` (back-compat trailing field; `client-rs --name` sends it). End-to-end tested.
|
||||||
|
§8b-2 (approve from a paired device's own app) is the client-side follow-up.
|
||||||
- **CI + deployment landed** (see the [CI & Docker](/docs/ci) guide). Gitea Actions, three
|
- **CI + deployment landed** (see the [CI & Docker](/docs/ci) guide). Gitea Actions, three
|
||||||
workflows: Rust workspace checks inside the new `punktfunk-rust-ci` builder image (Ubuntu 26.04,
|
workflows: Rust workspace checks inside the new `punktfunk-rust-ci` builder image (Ubuntu 26.04,
|
||||||
full link-dep stack incl. a libcuda stub — 141/141 tests green in-container), web + docs-site
|
full link-dep stack incl. a libcuda stub — 141/141 tests green in-container), web + docs-site
|
||||||
|
|||||||
@@ -401,6 +401,184 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/native/pending": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"native"
|
||||||
|
],
|
||||||
|
"summary": "List devices awaiting pairing approval",
|
||||||
|
"description": "Unpaired devices that tried to connect while the host requires pairing. Approve one to pair\nit without a PIN (delegated approval); entries expire after ~10 minutes.",
|
||||||
|
"operationId": "listPendingDevices",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Devices awaiting approval (empty when none, or when the native host is not enabled)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PendingDevice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/native/pending/{id}/approve": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"native"
|
||||||
|
],
|
||||||
|
"summary": "Approve a pending device",
|
||||||
|
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.",
|
||||||
|
"operationId": "approvePendingDevice",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Pending-request id from the pending list",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApprovePending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Device paired",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/NativeClient"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No pending request with that id (expired?)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Could not persist the trust store",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Native host not enabled",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/native/pending/{id}/deny": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"native"
|
||||||
|
],
|
||||||
|
"summary": "Deny a pending device",
|
||||||
|
"description": "Drops the request. Not a blocklist — the device's next attempt knocks again.",
|
||||||
|
"operationId": "denyPendingDevice",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "Pending-request id from the pending list",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Request dropped"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No pending request with that id",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Native host not enabled",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/pair": {
|
"/api/v1/pair": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -623,6 +801,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ApprovePending": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Operator-chosen label for the device (defaults to the name it knocked with).",
|
||||||
|
"example": "Living Room TV"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ArmNativePairing": {
|
"ArmNativePairing": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Arm-native-pairing request body.",
|
"description": "Arm-native-pairing request body.",
|
||||||
@@ -860,6 +1052,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"PendingDevice": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "An unpaired device that tried to connect while the host requires pairing — awaiting\n**delegated approval** (approve it here instead of fetching the host PIN out of band).",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"fingerprint",
|
||||||
|
"age_secs"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"age_secs": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Seconds since the device last knocked.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Hex SHA-256 of the device's certificate — what approval pins."
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Id to address approve/deny (per-process; entries expire after ~10 minutes).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Best-effort device label (the client's own name, else fingerprint-derived).",
|
||||||
|
"example": "Enrico's MacBook"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"PortMap": {
|
"PortMap": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
|
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
|
||||||
|
|||||||
@@ -146,6 +146,12 @@
|
|||||||
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||||
#define MAX_DATAGRAM_BYTES 2048
|
#define MAX_DATAGRAM_BYTES 2048
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
|
||||||
|
// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
|
||||||
|
#define HELLO_NAME_MAX 64
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Type byte of [`Reconfigure`] (first byte after the magic).
|
// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||||
#define MSG_RECONFIGURE 1
|
#define MSG_RECONFIGURE 1
|
||||||
@@ -156,6 +162,11 @@
|
|||||||
#define MSG_RECONFIGURED 2
|
#define MSG_RECONFIGURED 2
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`RequestKeyframe`].
|
||||||
|
#define MSG_REQUEST_KEYFRAME 3
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Type byte of [`ProbeRequest`].
|
// Type byte of [`ProbeRequest`].
|
||||||
#define MSG_PROBE_REQUEST 32
|
#define MSG_PROBE_REQUEST 32
|
||||||
@@ -267,7 +278,10 @@ enum PunktfunkInputKind
|
|||||||
PUNKTFUNK_INPUT_KIND_KEY_UP = 1,
|
PUNKTFUNK_INPUT_KIND_KEY_UP = 1,
|
||||||
// Relative motion: `x`/`y` carry `dx`/`dy`.
|
// Relative motion: `x`/`y` carry `dx`/`dy`.
|
||||||
PUNKTFUNK_INPUT_KIND_MOUSE_MOVE = 2,
|
PUNKTFUNK_INPUT_KIND_MOUSE_MOVE = 2,
|
||||||
// Absolute motion: `x`/`y` carry pixel coordinates.
|
// Absolute motion: `x`/`y` carry pixel coordinates and `flags` packs the client's
|
||||||
|
// coordinate-space size as `(width << 16) | height` (the same contract as
|
||||||
|
// [`TouchDown`](Self::TouchDown)) — injectors normalize against it before mapping
|
||||||
|
// into the output region and **drop the event when it is zero**.
|
||||||
PUNKTFUNK_INPUT_KIND_MOUSE_MOVE_ABS = 3,
|
PUNKTFUNK_INPUT_KIND_MOUSE_MOVE_ABS = 3,
|
||||||
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_DOWN = 4,
|
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_DOWN = 4,
|
||||||
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_UP = 5,
|
PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_UP = 5,
|
||||||
@@ -828,6 +842,19 @@ PunktfunkStatus punktfunk_connection_request_mode(const PunktfunkConnection *c,
|
|||||||
uint32_t refresh_hz);
|
uint32_t refresh_hz);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Ask the host's encoder to emit a fresh IDR keyframe now — client recovery when the
|
||||||
|
// decoder has stalled (the infinite-GOP stream sends one opening IDR then P-frames only, so
|
||||||
|
// a wedged decoder would otherwise freeze until the next loss-triggered recovery keyframe).
|
||||||
|
// Non-blocking, fire-and-forget; the recovered keyframe is the only ack. The caller should
|
||||||
|
// THROTTLE — the decode stays wedged for several frames until the IDR lands, so requesting
|
||||||
|
// every frame would flood the control stream.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle.
|
||||||
|
PunktfunkStatus punktfunk_connection_request_keyframe(const PunktfunkConnection *c);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||||
// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
|
// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
|
||||||
|
|||||||
+25
-1
@@ -4,6 +4,9 @@ The punktfunk host is Linux-only and links system FFmpeg (NVENC), PipeWire, Opus
|
|||||||
the NVIDIA driver. This directory packages it for the **Fedora Atomic / Bazzite** world
|
the NVIDIA driver. This directory packages it for the **Fedora Atomic / Bazzite** world
|
||||||
(rpm-ostree + bootc), where most of those deps are already present.
|
(rpm-ostree + bootc), where most of those deps are already present.
|
||||||
|
|
||||||
|
> 👉 **Ubuntu/Debian hosts** install via `apt` from Gitea's package registry — see
|
||||||
|
> [`debian/README.md`](debian/README.md) (`apt update && apt upgrade` for new builds).
|
||||||
|
|
||||||
> 👉 **End-to-end Bazzite setup walkthrough** (install → udev/group → `host.env` → service →
|
> 👉 **End-to-end Bazzite setup walkthrough** (install → udev/group → `host.env` → service →
|
||||||
> firewall → verify → troubleshooting): [`bazzite/README.md`](bazzite/README.md). This file is the
|
> firewall → verify → troubleshooting): [`bazzite/README.md`](bazzite/README.md). This file is the
|
||||||
> higher-level packaging rationale.
|
> higher-level packaging rationale.
|
||||||
@@ -30,7 +33,28 @@ On **Bazzite** the only genuinely new runtime bits are `ffmpeg-libs` (RPM Fusion
|
|||||||
`libei` — the rest of the stack is already there. The default backend is **gamescope**
|
`libei` — the rest of the stack is already there. The default backend is **gamescope**
|
||||||
(`packaging/bazzite/host.env`), which the host spawns headless per session — no desktop login.
|
(`packaging/bazzite/host.env`), which the host spawns headless per session — no desktop login.
|
||||||
|
|
||||||
## Option A — COPR (per-host, `rpm-ostree install`)
|
## Option A — Gitea RPM registry (recommended; per-host, `rpm-ostree`)
|
||||||
|
|
||||||
|
The host's RPM is published to **unom's self-hosted Gitea RPM registry** (CI builds it on every
|
||||||
|
push), mirroring the [Debian/apt](debian/README.md) setup. Add one repo file, install, and track
|
||||||
|
updates with `rpm-ostree upgrade` — no COPR account needed. Full guide: [`rpm/README.md`](rpm/README.md).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# unsigned pkgs + Gitea-signed metadata → repo_gpgcheck=1, gpgcheck=0 (see rpm/README.md)
|
||||||
|
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
|
||||||
|
[gitea-unom-bazzite]
|
||||||
|
name=punktfunk (unom, Bazzite)
|
||||||
|
baseurl=https://git.unom.io/api/packages/unom/rpm/bazzite
|
||||||
|
enabled=1
|
||||||
|
gpgcheck=0
|
||||||
|
repo_gpgcheck=1
|
||||||
|
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
|
||||||
|
REPO
|
||||||
|
rpm-ostree install punktfunk && systemctl reboot
|
||||||
|
# updates: rpm-ostree upgrade && systemctl reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Option B — COPR (per-host, `rpm-ostree install`)
|
||||||
|
|
||||||
1. Create a COPR project, enable **build-from-SCM** pointing at this repo, spec path
|
1. Create a COPR project, enable **build-from-SCM** pointing at this repo, spec path
|
||||||
`packaging/rpm/punktfunk.spec` (see `copr/README.md`). Under *External Repositories* add
|
`packaging/rpm/punktfunk.spec` (see `copr/README.md`). Under *External Repositories* add
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# punktfunk-host — Debian/Ubuntu package (apt)
|
||||||
|
|
||||||
|
`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 `0.0.1~ciN.<sha>` build) and on `v*` tags
|
||||||
|
(a clean `X.Y.Z`).
|
||||||
|
|
||||||
|
Package layout mirrors the Fedora RPM (`../rpm/punktfunk.spec`): the host binary, the `/dev/uinput`
|
||||||
|
udev rule, the systemd **user** unit, headless session helpers, the example config, and the OpenAPI
|
||||||
|
doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself (built in the Ubuntu
|
||||||
|
26.04 rust-ci image, so the lib soname package names match the target). The NVIDIA driver
|
||||||
|
(`libnvidia-encode` / `libEGL_nvidia` / `libcuda`) is **not** a dependency — it's installed out of
|
||||||
|
band, like on the RPM side.
|
||||||
|
|
||||||
|
## Install on a host (one-time)
|
||||||
|
|
||||||
|
The registry is public, so no apt auth is needed — just trust the repo's 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, as the desktop user:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo usermod -aG input "$USER" # virtual gamepads (re-login to take effect)
|
||||||
|
mkdir -p ~/.config/punktfunk
|
||||||
|
cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env # then edit
|
||||||
|
systemctl --user enable --now punktfunk-host
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt update && sudo apt upgrade # picks up the newest published build
|
||||||
|
systemctl --user restart punktfunk-host # if the unit was already running
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build a `.deb` locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
VERSION=0.0.1 bash packaging/debian/build-deb.sh # -> dist/punktfunk-host_0.0.1_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs `dpkg-dev` (`dpkg-shlibdeps`, `dpkg-deb`). It builds the release binary first if missing.
|
||||||
|
Build it in the rust-ci image (or on an Ubuntu 26.04 box) so the resolved `Depends` match the
|
||||||
|
hosts; building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the punktfunk-client .deb (the native GTK4 client) for Ubuntu/Debian desktops.
|
||||||
|
#
|
||||||
|
# Counterpart to build-deb.sh (the host package); same conventions: runtime Depends are
|
||||||
|
# computed by dpkg-shlibdeps from the binary's DT_NEEDED (GTK4/libadwaita, SDL3, the
|
||||||
|
# FFmpeg/PipeWire/Opus sonames), so build inside the Ubuntu 26.04 rust-ci image to pin
|
||||||
|
# the package names the target boxes ship. The client links no NVIDIA libs — no filter
|
||||||
|
# needed.
|
||||||
|
#
|
||||||
|
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] bash packaging/debian/build-client-deb.sh
|
||||||
|
# Output: dist/punktfunk-client_<version>_<arch>.deb
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
||||||
|
ARCH="${ARCH:-amd64}"
|
||||||
|
PKG="punktfunk-client"
|
||||||
|
CRATE="punktfunk-client-linux"
|
||||||
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$ROOTDIR"
|
||||||
|
|
||||||
|
BIN="target/release/$PKG"
|
||||||
|
if [ ! -x "$BIN" ]; then
|
||||||
|
echo "==> building $CRATE (release)"
|
||||||
|
cargo build --release -p "$CRATE" --locked
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
DOCDIR="$STAGE/usr/share/doc/$PKG"
|
||||||
|
|
||||||
|
# --- file layout --------------------------------------------------------------
|
||||||
|
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
|
||||||
|
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
|
||||||
|
"$STAGE/usr/share/applications/io.unom.Punktfunk.desktop"
|
||||||
|
# DualSense hidraw access (full pad fidelity through SDL's HIDAPI driver).
|
||||||
|
install -Dm0644 scripts/70-punktfunk-client.rules \
|
||||||
|
"$STAGE/usr/lib/udev/rules.d/70-punktfunk-client.rules"
|
||||||
|
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
|
||||||
|
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
|
||||||
|
install -Dm0644 README.md "$DOCDIR/README.md"
|
||||||
|
|
||||||
|
cat > "$DOCDIR/copyright" <<EOF
|
||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: punktfunk
|
||||||
|
Source: https://git.unom.io/unom/punktfunk
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: punktfunk contributors
|
||||||
|
License: MIT or Apache-2.0
|
||||||
|
Dual-licensed. Full texts in /usr/share/doc/$PKG/LICENSE-MIT and
|
||||||
|
/usr/share/doc/$PKG/LICENSE-APACHE.
|
||||||
|
EOF
|
||||||
|
printf '%s (%s) stable; urgency=medium\n\n * Automated build %s.\n\n -- unom <noreply@anthropic.com> %s\n' \
|
||||||
|
"$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \
|
||||||
|
| gzip -9n > "$DOCDIR/changelog.Debian.gz"
|
||||||
|
|
||||||
|
# --- dependencies --------------------------------------------------------------
|
||||||
|
SHLIB_TMP="$(mktemp -d)"
|
||||||
|
mkdir -p "$SHLIB_TMP/debian"
|
||||||
|
cat > "$SHLIB_TMP/debian/control" <<EOF
|
||||||
|
Source: $PKG
|
||||||
|
|
||||||
|
Package: $PKG
|
||||||
|
Architecture: any
|
||||||
|
Depends: \${shlibs:Depends}
|
||||||
|
EOF
|
||||||
|
SHDEPS="$(cd "$SHLIB_TMP" && dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" 2>/dev/null \
|
||||||
|
| sed -n 's/^shlibs:Depends=//p')"
|
||||||
|
rm -rf "$SHLIB_TMP"
|
||||||
|
[ -n "$SHDEPS" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Manual additions shlibdeps can't see: the PipeWire daemon + session manager are runtime
|
||||||
|
# services (audio playback / mic capture degrade gracefully without them — Recommends).
|
||||||
|
RECOMMENDS="pipewire, wireplumber, pipewire-pulse"
|
||||||
|
|
||||||
|
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
|
||||||
|
|
||||||
|
install -d "$STAGE/DEBIAN"
|
||||||
|
cat > "$STAGE/DEBIAN/control" <<EOF
|
||||||
|
Package: $PKG
|
||||||
|
Version: $VERSION
|
||||||
|
Architecture: $ARCH
|
||||||
|
Maintainer: unom <noreply@anthropic.com>
|
||||||
|
Installed-Size: $INSTALLED_KB
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Homepage: https://git.unom.io/unom/punktfunk
|
||||||
|
Depends: $SHDEPS
|
||||||
|
Recommends: $RECOMMENDS
|
||||||
|
Description: Low-latency desktop/game streaming client (punktfunk/1, GTK4)
|
||||||
|
The native Linux client for punktfunk, a Linux-first low-latency desktop and
|
||||||
|
game streaming stack. Discovers hosts on the LAN (mDNS), trusts them via
|
||||||
|
certificate pinning with a SPAKE2 PIN pairing ceremony, and streams HEVC video
|
||||||
|
(GF(2^16) Leopard FEC + AES-GCM over UDP, QUIC control plane) with Opus audio,
|
||||||
|
microphone passthrough, and full gamepad support including DualSense touchpad,
|
||||||
|
motion, adaptive triggers and lightbar through SDL3.
|
||||||
|
.
|
||||||
|
The host creates a virtual output at exactly this client's resolution and
|
||||||
|
refresh rate — no scaling. See the punktfunk-host package for the host side.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = "configure" ]; then
|
||||||
|
# Pick up the DualSense hidraw rule without a reboot (best-effort, no-op in containers).
|
||||||
|
udevadm control --reload-rules 2>/dev/null || true
|
||||||
|
udevadm trigger --subsystem-match=hidraw 2>/dev/null || true
|
||||||
|
update-desktop-database /usr/share/applications 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod 0755 "$STAGE/DEBIAN/postinst"
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
OUT="dist/${PKG}_${VERSION}_${ARCH}.deb"
|
||||||
|
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
|
||||||
|
echo "built $OUT"
|
||||||
|
echo " Depends: $SHDEPS"
|
||||||
|
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size' || true
|
||||||
Executable
+159
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a punktfunk-host .deb for Ubuntu/Debian hosts.
|
||||||
|
#
|
||||||
|
# Mirrors the Fedora RPM (../rpm/punktfunk.spec): the host binary + the uinput udev rule
|
||||||
|
# + the systemd *user* unit + headless session helpers + example config + the OpenAPI doc.
|
||||||
|
#
|
||||||
|
# Runtime Depends are computed by `dpkg-shlibdeps` from the binary's actual DT_NEEDED, NOT
|
||||||
|
# hand-listed: the binary pulls a large transitive lib closure (most of it via ffmpeg) and
|
||||||
|
# the exact soname package names (libavcodec62, libpipewire-0.3-0t64, …) drift across distro
|
||||||
|
# releases — shlibdeps tracks them automatically and pins them to whatever the BUILD distro
|
||||||
|
# ships. Build this inside the Ubuntu 26.04 rust-ci image so those names match the target
|
||||||
|
# boxes exactly. `--ignore-missing-info` drops libcuda.so.1 (the NVIDIA driver lib, linked via
|
||||||
|
# FFI): on a GPU-less builder it resolves to no package, and we must never hard-depend on a
|
||||||
|
# specific libnvidia-compute-<ver> anyway — NVENC/EGL come from the driver, out of band.
|
||||||
|
#
|
||||||
|
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] bash packaging/debian/build-deb.sh
|
||||||
|
# Output: dist/punktfunk-host_<version>_<arch>.deb
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
||||||
|
ARCH="${ARCH:-amd64}"
|
||||||
|
PKG="punktfunk-host"
|
||||||
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$ROOTDIR"
|
||||||
|
|
||||||
|
BIN="target/release/$PKG"
|
||||||
|
if [ ! -x "$BIN" ]; then
|
||||||
|
echo "==> building $PKG (release)"
|
||||||
|
cargo build --release -p "$PKG" --locked
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
DOCDIR="$STAGE/usr/share/doc/$PKG"
|
||||||
|
SHAREDIR="$STAGE/usr/share/$PKG"
|
||||||
|
|
||||||
|
# --- file layout (matches the RPM %install) ----------------------------------
|
||||||
|
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
|
||||||
|
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||||
|
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
|
||||||
|
# and high-bitrate frames overflow it (send-side packet loss). systemd-sysctl applies it at boot.
|
||||||
|
install -Dm0644 scripts/99-punktfunk-net.conf "$STAGE/usr/lib/sysctl.d/99-punktfunk-net.conf"
|
||||||
|
install -Dm0644 scripts/punktfunk-host.service "$STAGE/usr/lib/systemd/user/punktfunk-host.service"
|
||||||
|
# The source unit's ExecStart points at the dev source tree; a packaged install has the binary at
|
||||||
|
# /usr/bin. Rewrite it so a fresh apt install (no hand-rolled unit) starts the installed binary.
|
||||||
|
sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \
|
||||||
|
"$STAGE/usr/lib/systemd/user/punktfunk-host.service"
|
||||||
|
install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh"
|
||||||
|
install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh"
|
||||||
|
install -Dm0644 scripts/host.env.example "$SHAREDIR/host.env.example"
|
||||||
|
install -Dm0644 packaging/bazzite/host.env "$SHAREDIR/host.env.bazzite"
|
||||||
|
install -Dm0644 docs/api/openapi.json "$SHAREDIR/openapi.json"
|
||||||
|
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
|
||||||
|
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
|
||||||
|
install -Dm0644 README.md "$DOCDIR/README.md"
|
||||||
|
|
||||||
|
# Debian copyright + changelog (cheap, keeps the package well-formed).
|
||||||
|
cat > "$DOCDIR/copyright" <<EOF
|
||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: punktfunk
|
||||||
|
Source: https://git.unom.io/unom/punktfunk
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: punktfunk contributors
|
||||||
|
License: MIT or Apache-2.0
|
||||||
|
Dual-licensed. Full texts in /usr/share/doc/$PKG/LICENSE-MIT and
|
||||||
|
/usr/share/doc/$PKG/LICENSE-APACHE.
|
||||||
|
EOF
|
||||||
|
printf '%s (%s) stable; urgency=medium\n\n * Automated build %s.\n\n -- unom <noreply@anthropic.com> %s\n' \
|
||||||
|
"$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \
|
||||||
|
| gzip -9n > "$DOCDIR/changelog.Debian.gz"
|
||||||
|
|
||||||
|
# --- dependencies ------------------------------------------------------------
|
||||||
|
# Auto: the binary's directly-linked shared libs (libcuda ignored, see header).
|
||||||
|
SHLIB_TMP="$(mktemp -d)"
|
||||||
|
mkdir -p "$SHLIB_TMP/debian"
|
||||||
|
cat > "$SHLIB_TMP/debian/control" <<EOF
|
||||||
|
Source: $PKG
|
||||||
|
|
||||||
|
Package: $PKG
|
||||||
|
Architecture: any
|
||||||
|
Depends: \${shlibs:Depends}
|
||||||
|
EOF
|
||||||
|
SHDEPS_RAW="$(cd "$SHLIB_TMP" && dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" 2>/dev/null \
|
||||||
|
| sed -n 's/^shlibs:Depends=//p')"
|
||||||
|
rm -rf "$SHLIB_TMP"
|
||||||
|
[ -n "$SHDEPS_RAW" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Drop the NVIDIA driver lib unconditionally. --ignore-missing-info already skips libcuda on a
|
||||||
|
# GPU-less builder (stub, no owning package), but on a box WITH the driver shlibdeps resolves
|
||||||
|
# libcuda.so.1 -> libnvidia-compute-<ver> and would pin that exact driver build. NVENC/EGL are
|
||||||
|
# provided by whatever driver the host runs, so this must never be a package dependency.
|
||||||
|
SHDEPS="$(printf '%s' "$SHDEPS_RAW" | tr ',' '\n' | sed 's/^ *//; s/ *$//' \
|
||||||
|
| grep -ivE '^(libnvidia-compute|libcuda)' | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')"
|
||||||
|
[ -n "$SHDEPS" ] || { echo "no deps left after filtering — unexpected" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Manual additions shlibdeps can't see:
|
||||||
|
# - libei1: input injection (libei) is loaded at runtime, not in DT_NEEDED.
|
||||||
|
# - pipewire/wireplumber: runtime services (the daemon + session manager), not linked libs.
|
||||||
|
DEPENDS="$SHDEPS, libei1, pipewire, wireplumber"
|
||||||
|
# ffmpeg: Ubuntu's ffmpeg ships the NVENC-enabled libav* the binary links AND is the encoder
|
||||||
|
# runtime; the libav* sonames are already hard Depends via shlibdeps, so the ffmpeg metapackage
|
||||||
|
# is a Recommends. gamescope = a ready compositor backend; pipewire-pulse = desktop audio.
|
||||||
|
RECOMMENDS="ffmpeg, gamescope, pipewire-pulse"
|
||||||
|
SUGGESTS="kwin-wayland, mutter"
|
||||||
|
|
||||||
|
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
|
||||||
|
|
||||||
|
install -d "$STAGE/DEBIAN"
|
||||||
|
cat > "$STAGE/DEBIAN/control" <<EOF
|
||||||
|
Package: $PKG
|
||||||
|
Version: $VERSION
|
||||||
|
Architecture: $ARCH
|
||||||
|
Maintainer: unom <noreply@anthropic.com>
|
||||||
|
Installed-Size: $INSTALLED_KB
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Homepage: https://git.unom.io/unom/punktfunk
|
||||||
|
Depends: $DEPENDS
|
||||||
|
Recommends: $RECOMMENDS
|
||||||
|
Suggests: $SUGGESTS
|
||||||
|
Description: Low-latency desktop/game streaming host (Moonlight + punktfunk/1)
|
||||||
|
punktfunk is a Linux-first, low-latency desktop and game streaming host. It speaks
|
||||||
|
the Moonlight/GameStream protocol (pair a stock Moonlight client) and its own native
|
||||||
|
punktfunk/1 protocol (GF(2^16) Leopard FEC + AES-GCM, mid-stream mode renegotiation,
|
||||||
|
client microphone passthrough). Each session gets a virtual output at the client's
|
||||||
|
exact resolution and refresh via a per-compositor backend (KWin, gamescope, Mutter,
|
||||||
|
Sway/wlroots), captured zero-copy (dmabuf -> CUDA -> NVENC). Input (mouse, keyboard,
|
||||||
|
gamepads) is injected back into the session.
|
||||||
|
.
|
||||||
|
NVENC + GPU EGL come from the NVIDIA driver (libnvidia-encode / libEGL_nvidia),
|
||||||
|
installed out of band. After install: add yourself to the 'input' group for virtual
|
||||||
|
gamepads, then enable the systemd user service punktfunk-host.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = "configure" ]; then
|
||||||
|
# Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers).
|
||||||
|
udevadm control --reload-rules 2>/dev/null || true
|
||||||
|
udevadm trigger --subsystem-match=misc 2>/dev/null || true
|
||||||
|
# Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl).
|
||||||
|
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
|
||||||
|
echo "punktfunk-host installed. Add yourself to the 'input' group for virtual gamepads:"
|
||||||
|
echo " sudo usermod -aG input \"\$USER\" # then re-login"
|
||||||
|
echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env"
|
||||||
|
echo "Enable: systemctl --user enable --now punktfunk-host"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod 0755 "$STAGE/DEBIAN/postinst"
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
OUT="dist/${PKG}_${VERSION}_${ARCH}.deb"
|
||||||
|
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
|
||||||
|
echo "built $OUT"
|
||||||
|
echo " Depends: $DEPENDS"
|
||||||
|
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size' || true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Punktfunk
|
||||||
|
Comment=Stream a remote punktfunk host
|
||||||
|
Exec=punktfunk-client
|
||||||
|
Icon=video-display
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;Game;
|
||||||
|
Keywords=streaming;remote;game;moonlight;
|
||||||
|
StartupNotify=true
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# punktfunk-host — RPM (Bazzite / Fedora Atomic) via the Gitea registry
|
||||||
|
|
||||||
|
`punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom`
|
||||||
|
org (group `bazzite`), so Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`.
|
||||||
|
CI (`.gitea/workflows/rpm.yml`) builds and publishes on every push to `main` (a rolling
|
||||||
|
`0.0.1-0.ciN.<sha>` build) and on `v*` tags (a clean `X.Y.Z-1`). The RPM is built in the
|
||||||
|
Fedora 43 image (`ci/fedora-rpm.Dockerfile`) so its auto-generated library Requires
|
||||||
|
(`libavcodec.so.NN`, …) match Bazzite's sonames; the NVIDIA driver lib (`libcuda.so.1`) is
|
||||||
|
excluded — NVENC/EGL come from whatever NVIDIA stack the host runs (a weak Recommends).
|
||||||
|
|
||||||
|
This is the same package as the [COPR](../copr/README.md) / [bootc](../bootc/Containerfile)
|
||||||
|
paths — same spec (`punktfunk.spec`) — just self-hosted in Gitea instead of COPR, mirroring the
|
||||||
|
[Debian/apt](../debian/README.md) setup.
|
||||||
|
|
||||||
|
## Install on a Bazzite host (one-time)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Add the repo. Our RPMs are unsigned, but Gitea GPG-signs the repo METADATA — so verify that
|
||||||
|
# (repo_gpgcheck=1) and skip the per-package signature check (gpgcheck=0). The signed metadata
|
||||||
|
# carries each package's SHA256, so authenticity still holds. (Don't just curl Gitea's served
|
||||||
|
# bazzite.repo — it sets gpgcheck=1, which fails on unsigned packages.)
|
||||||
|
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
|
||||||
|
[gitea-unom-bazzite]
|
||||||
|
name=punktfunk (unom, Bazzite)
|
||||||
|
baseurl=https://git.unom.io/api/packages/unom/rpm/bazzite
|
||||||
|
enabled=1
|
||||||
|
gpgcheck=0
|
||||||
|
repo_gpgcheck=1
|
||||||
|
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
|
||||||
|
REPO
|
||||||
|
|
||||||
|
# Layer the package, then reboot into the new deployment.
|
||||||
|
rpm-ostree install punktfunk
|
||||||
|
systemctl reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
> If `rpm-ostree` can't complete the metadata GPG check non-interactively, set `repo_gpgcheck=0`
|
||||||
|
> (TLS-only trust to the self-hosted registry). Proper per-package signing (`gpgcheck=1`) would
|
||||||
|
> need a CI signing key + `rpm --addsign` — future hardening, not wired up.
|
||||||
|
|
||||||
|
After reboot, as the desktop user:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ujust add-user-to-input-group # virtual gamepads need /dev/uinput (re-login).
|
||||||
|
# Bazzite is atomic — use ujust, NOT `usermod -aG input`.
|
||||||
|
mkdir -p ~/.config/punktfunk
|
||||||
|
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env # gamescope defaults
|
||||||
|
systemctl --user enable --now punktfunk-host
|
||||||
|
```
|
||||||
|
|
||||||
|
(See [`../bazzite/README.md`](../bazzite/README.md) for the full appliance walkthrough —
|
||||||
|
udev/group, `host.env`, the Steam session unit, firewall, verify.)
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rpm-ostree upgrade # pulls the newest punktfunk with the system update
|
||||||
|
systemctl reboot # rpm-ostree changes apply on reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
Layered packages are re-resolved against their repos on every `rpm-ostree upgrade`, so the box
|
||||||
|
tracks new builds automatically (Bazzite's auto-update timer does this for you). To pin or stop
|
||||||
|
tracking: `rpm-ostree override` / `rpm-ostree uninstall punktfunk`.
|
||||||
|
|
||||||
|
## Build an RPM locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh # -> dist/punktfunk-0.0.1-1.fcNN.x86_64.rpm
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it inside the Fedora 43 builder image so the deps resolve and match Bazzite:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -f ci/fedora-rpm.Dockerfile -t punktfunk-fedora-rpm ci
|
||||||
|
docker run --rm -v "$PWD:/src" -w /src punktfunk-fedora-rpm \
|
||||||
|
bash -lc 'git config --global --add safe.directory /src && PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
A plain `rpmbuild`/COPR build with no `pf_version`/`pf_release` defines produces `0.0.1-1`.
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the punktfunk-host RPM from the committed tree, for the Gitea RPM registry (Bazzite).
|
||||||
|
#
|
||||||
|
# Counterpart to ../debian/build-deb.sh. The library Requires (libavcodec.so.NN, …) are
|
||||||
|
# auto-generated by rpmbuild from the binary it links — so build this in the Fedora 43 image
|
||||||
|
# (ci/fedora-rpm.Dockerfile) to match Bazzite's sonames. libcuda is excluded in the spec.
|
||||||
|
#
|
||||||
|
# Usage: PF_VERSION=0.0.1 [PF_RELEASE=0.ci42.gdeadbee] bash packaging/rpm/build-rpm.sh
|
||||||
|
# Output: dist/punktfunk-<version>-<release>.<arch>.rpm (+ the -debuginfo/-debugsource subpkgs)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PF_VERSION="${PF_VERSION:-0.0.1}"
|
||||||
|
PF_RELEASE="${PF_RELEASE:-1}"
|
||||||
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$ROOTDIR"
|
||||||
|
|
||||||
|
TOP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TOP"' EXIT
|
||||||
|
mkdir -p "$TOP"/{SOURCES,SPECS,BUILD,BUILDROOT,RPMS,SRPMS}
|
||||||
|
|
||||||
|
# Source tarball with the prefix %autosetup expects (punktfunk-<version>/). From HEAD so the
|
||||||
|
# build is reproducible from a commit (CI checks one out); the spec is read from the working
|
||||||
|
# tree directly, so spec edits apply without a re-commit.
|
||||||
|
git archive --format=tar.gz --prefix="punktfunk-${PF_VERSION}/" \
|
||||||
|
-o "$TOP/SOURCES/punktfunk-${PF_VERSION}.tar.gz" HEAD
|
||||||
|
|
||||||
|
# --nodeps: the spec's BuildRequires (cargo, rust, *-devel) are for COPR's mock chroot, which
|
||||||
|
# resolves them from RPMs. Our builder image provides the toolchain via rustup (so
|
||||||
|
# rust-toolchain.toml's pinned channel works) and the -devel libs via dnf, neither of which
|
||||||
|
# rpmbuild's RPM-level check sees — skip it; a genuinely missing dep fails the compile/link.
|
||||||
|
rpmbuild -bb --nodeps \
|
||||||
|
--define "_topdir $TOP" \
|
||||||
|
--define "pf_version ${PF_VERSION}" \
|
||||||
|
--define "pf_release ${PF_RELEASE}" \
|
||||||
|
packaging/rpm/punktfunk.spec
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
find "$TOP/RPMS" -name '*.rpm' -exec cp -v {} dist/ \;
|
||||||
|
echo "== Requires (must NOT contain libcuda) =="
|
||||||
|
rpm -qp --requires dist/punktfunk-${PF_VERSION}-*.rpm 2>/dev/null | grep -iE 'cuda|nvidia' \
|
||||||
|
&& echo " !! NVIDIA/CUDA leak !!" || echo " clean"
|
||||||
@@ -17,8 +17,12 @@
|
|||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
Name: punktfunk
|
Name: punktfunk
|
||||||
Version: 0.0.1
|
# Version/Release are overridable so CI can stamp a rolling snapshot: a main build passes
|
||||||
Release: 1%{?dist}
|
# --define "pf_version 0.0.1" --define "pf_release 0.ci42.gdeadbee"
|
||||||
|
# (Release starting "0." sorts BEFORE the eventual "1" release), a v* tag passes the clean
|
||||||
|
# version with "pf_release 1". A plain `rpmbuild` (or COPR) with no defines builds 0.0.1-1.
|
||||||
|
Version: %{?pf_version}%{!?pf_version:0.0.1}
|
||||||
|
Release: %{?pf_release}%{!?pf_release:1}%{?dist}
|
||||||
Summary: Low-latency desktop/game streaming host (Moonlight-compatible + punktfunk/1)
|
Summary: Low-latency desktop/game streaming host (Moonlight-compatible + punktfunk/1)
|
||||||
|
|
||||||
License: MIT OR Apache-2.0
|
License: MIT OR Apache-2.0
|
||||||
@@ -29,6 +33,12 @@ Source0: %{name}-%{version}.tar.gz
|
|||||||
# punktfunk-host is Linux-only and links system FFmpeg/PipeWire/Opus.
|
# punktfunk-host is Linux-only and links system FFmpeg/PipeWire/Opus.
|
||||||
ExclusiveArch: x86_64 aarch64
|
ExclusiveArch: x86_64 aarch64
|
||||||
|
|
||||||
|
# The zerocopy FFI links the NVIDIA driver's libcuda.so.1; rpm's auto-dep generator would turn
|
||||||
|
# that into a hard Requires on libcuda.so.1 (and we never want to pin the driver — NVENC/EGL come
|
||||||
|
# from whatever NVIDIA stack the host runs, expressed below as the weak xorg-x11-drv-nvidia-cuda
|
||||||
|
# Recommends). Drop it from the auto-Requires, mirroring the Debian package's NVIDIA filter.
|
||||||
|
%global __requires_exclude ^libcuda\\.so.*$
|
||||||
|
|
||||||
# --- Build toolchain ---------------------------------------------------------
|
# --- Build toolchain ---------------------------------------------------------
|
||||||
BuildRequires: cargo
|
BuildRequires: cargo
|
||||||
BuildRequires: rust
|
BuildRequires: rust
|
||||||
@@ -55,6 +65,10 @@ BuildRequires: pkgconfig(libavutil)
|
|||||||
# Zero-copy GPU path: src/zerocopy/ links libGL + libgbm (mesa) via hand-rolled FFI.
|
# Zero-copy GPU path: src/zerocopy/ links libGL + libgbm (mesa) via hand-rolled FFI.
|
||||||
BuildRequires: pkgconfig(gl)
|
BuildRequires: pkgconfig(gl)
|
||||||
BuildRequires: pkgconfig(gbm)
|
BuildRequires: pkgconfig(gbm)
|
||||||
|
# The client subpackage (GTK4 shell + SDL3 gamepads).
|
||||||
|
BuildRequires: pkgconfig(gtk4)
|
||||||
|
BuildRequires: pkgconfig(libadwaita-1)
|
||||||
|
BuildRequires: pkgconfig(sdl3)
|
||||||
# It ALSO links the NVIDIA CUDA driver lib (-lcuda) via FFI, so libcuda.so must be present
|
# It ALSO links the NVIDIA CUDA driver lib (-lcuda) via FFI, so libcuda.so must be present
|
||||||
# at LINK time. A normal NVIDIA host (or Bazzite -nvidia) has it; a headless COPR/koji builder
|
# at LINK time. A normal NVIDIA host (or Bazzite -nvidia) has it; a headless COPR/koji builder
|
||||||
# without a GPU does NOT — point %build at the CUDA toolkit stub (…/stubs/libcuda.so) there,
|
# without a GPU does NOT — point %build at the CUDA toolkit stub (…/stubs/libcuda.so) there,
|
||||||
@@ -86,14 +100,28 @@ exact resolution and refresh via a per-compositor backend (KWin, gamescope, Mutt
|
|||||||
Sway/wlroots), captured zero-copy (dmabuf -> CUDA -> NVENC) and split-encoded above
|
Sway/wlroots), captured zero-copy (dmabuf -> CUDA -> NVENC) and split-encoded above
|
||||||
~1 Gpix/s. Input (mouse/keyboard/gamepads) is injected back into the session.
|
~1 Gpix/s. Input (mouse/keyboard/gamepads) is injected back into the session.
|
||||||
|
|
||||||
|
%package client
|
||||||
|
Summary: Low-latency desktop/game streaming client (punktfunk/1, GTK4)
|
||||||
|
# Audio playback / mic capture want the PipeWire daemon; degrade gracefully without it.
|
||||||
|
Recommends: pipewire
|
||||||
|
Recommends: wireplumber
|
||||||
|
|
||||||
|
%description client
|
||||||
|
The native Linux client for punktfunk. Discovers hosts on the LAN (mDNS), trusts
|
||||||
|
them via certificate pinning with a SPAKE2 PIN pairing ceremony, and streams HEVC
|
||||||
|
video (GF(2^16) Leopard FEC + AES-GCM over UDP, QUIC control plane) with Opus
|
||||||
|
audio, microphone passthrough, and full gamepad support including DualSense
|
||||||
|
touchpad, motion, adaptive triggers and lightbar through SDL3. The host creates a
|
||||||
|
virtual output at exactly this client's resolution and refresh rate — no scaling.
|
||||||
|
|
||||||
%prep
|
%prep
|
||||||
%autosetup -n %{name}-%{version}
|
%autosetup -n %{name}-%{version}
|
||||||
|
|
||||||
%build
|
%build
|
||||||
# Release build of the host binary only (the workspace also has the core lib + clients).
|
# Release build of the host + client binaries (the workspace also has the core lib).
|
||||||
# cargo fetches crates over the network; COPR build hosts allow this.
|
# cargo fetches crates over the network; COPR build hosts allow this.
|
||||||
export RUSTFLAGS="%{?build_rustflags}"
|
export RUSTFLAGS="%{?build_rustflags}"
|
||||||
cargo build --release -p punktfunk-host
|
cargo build --release -p punktfunk-host -p punktfunk-client-linux
|
||||||
|
|
||||||
%install
|
%install
|
||||||
# Binary
|
# Binary
|
||||||
@@ -102,8 +130,23 @@ install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-h
|
|||||||
# udev rule — /dev/uinput access for virtual gamepads (input group).
|
# udev rule — /dev/uinput access for virtual gamepads (input group).
|
||||||
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
|
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
|
||||||
|
|
||||||
|
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
|
||||||
|
# and high-bitrate frames overflow it (send-side loss). systemd-sysctl applies it at boot.
|
||||||
|
install -Dm0644 scripts/99-punktfunk-net.conf %{buildroot}%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||||
|
|
||||||
# systemd *user* unit (the host runs in the graphical session, not as root).
|
# systemd *user* unit (the host runs in the graphical session, not as root).
|
||||||
install -Dm0644 scripts/punktfunk-host.service %{buildroot}%{_userunitdir}/punktfunk-host.service
|
install -Dm0644 scripts/punktfunk-host.service %{buildroot}%{_userunitdir}/punktfunk-host.service
|
||||||
|
# The source unit's ExecStart points at the dev source tree; a packaged install has the binary at
|
||||||
|
# %{_bindir}. Rewrite it so a fresh install (no hand-rolled unit) starts the installed binary.
|
||||||
|
sed -i 's#%h/punktfunk/target/release/punktfunk-host#%{_bindir}/punktfunk-host#' %{buildroot}%{_userunitdir}/punktfunk-host.service
|
||||||
|
|
||||||
|
# --- client subpackage ---
|
||||||
|
install -Dm0755 target/release/punktfunk-client %{buildroot}%{_bindir}/punktfunk-client
|
||||||
|
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
|
||||||
|
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.desktop
|
||||||
|
# DualSense hidraw access (full pad fidelity through SDL's HIDAPI driver).
|
||||||
|
install -Dm0644 scripts/70-punktfunk-client.rules \
|
||||||
|
%{buildroot}%{_udevrulesdir}/70-punktfunk-client.rules
|
||||||
|
|
||||||
# Headless session helpers + example config + OpenAPI doc (reference material).
|
# Headless session helpers + example config + OpenAPI doc (reference material).
|
||||||
install -d %{buildroot}%{_datadir}/%{name}/headless
|
install -d %{buildroot}%{_datadir}/%{name}/headless
|
||||||
@@ -118,18 +161,34 @@ install -Dm0644 docs/api/openapi.json %{buildroot}%{_datadir}/%
|
|||||||
%doc README.md docs/implementation-plan.md packaging/README.md
|
%doc README.md docs/implementation-plan.md packaging/README.md
|
||||||
%{_bindir}/punktfunk-host
|
%{_bindir}/punktfunk-host
|
||||||
%{_udevrulesdir}/60-punktfunk.rules
|
%{_udevrulesdir}/60-punktfunk.rules
|
||||||
|
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||||
%{_userunitdir}/punktfunk-host.service
|
%{_userunitdir}/punktfunk-host.service
|
||||||
%dir %{_datadir}/%{name}
|
%dir %{_datadir}/%{name}
|
||||||
%{_datadir}/%{name}/*
|
%{_datadir}/%{name}/*
|
||||||
|
|
||||||
|
%files client
|
||||||
|
%license LICENSE-MIT LICENSE-APACHE
|
||||||
|
%{_bindir}/punktfunk-client
|
||||||
|
%{_datadir}/applications/io.unom.Punktfunk.desktop
|
||||||
|
%{_udevrulesdir}/70-punktfunk-client.rules
|
||||||
|
|
||||||
|
%post client
|
||||||
|
# Pick up the DualSense hidraw rule without a reboot (best-effort; on rpm-ostree it
|
||||||
|
# applies on the next boot into the layered deployment).
|
||||||
|
udevadm control --reload-rules 2>/dev/null || :
|
||||||
|
udevadm trigger --subsystem-match=hidraw 2>/dev/null || :
|
||||||
|
|
||||||
%post
|
%post
|
||||||
# Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort).
|
# Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort).
|
||||||
udevadm control --reload-rules 2>/dev/null || :
|
udevadm control --reload-rules 2>/dev/null || :
|
||||||
udevadm trigger --subsystem-match=misc 2>/dev/null || :
|
udevadm trigger --subsystem-match=misc 2>/dev/null || :
|
||||||
|
# Apply the UDP socket-buffer tuning (also auto-applied at boot by systemd-sysctl; on rpm-ostree
|
||||||
|
# it takes effect on the next boot into the layered deployment).
|
||||||
|
sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || :
|
||||||
echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)"
|
echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)"
|
||||||
echo "then enable the host: systemctl --user enable --now punktfunk-host"
|
echo "then enable the host: systemctl --user enable --now punktfunk-host"
|
||||||
echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env"
|
echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env"
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
* Tue Jun 10 2026 punktfunk <noreply@anthropic.com> - 0.0.1-1
|
* Wed Jun 10 2026 punktfunk <noreply@anthropic.com> - 0.0.1-1
|
||||||
- Initial RPM: punktfunk-host + udev rule + systemd user unit + headless helpers.
|
- Initial RPM: punktfunk-host + udev rule + systemd user unit + headless helpers.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# punktfunk-client: hidraw access for the seated user's DualSense (SDL's HIDAPI driver
|
||||||
|
# needs it for touchpad / motion / lightbar / player LEDs / adaptive triggers — without it
|
||||||
|
# SDL silently degrades to plain evdev, which has none of those). evdev joystick nodes are
|
||||||
|
# already uaccess-tagged by systemd; hidraw nodes are root-only by default and systemd
|
||||||
|
# declined a generic gamepad hwdb (systemd#22681), so we ship the rule, steam-devices
|
||||||
|
# style: the ATTRS match covers USB, the KERNELS match covers Bluetooth.
|
||||||
|
# DualSense (054c:0ce6)
|
||||||
|
KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0ce6", MODE="0660", TAG+="uaccess"
|
||||||
|
KERNEL=="hidraw*", KERNELS=="*054C:0CE6*", MODE="0660", TAG+="uaccess"
|
||||||
|
# DualSense Edge (054c:0df2)
|
||||||
|
KERNEL=="hidraw*", ATTRS{idVendor}=="054c", ATTRS{idProduct}=="0df2", MODE="0660", TAG+="uaccess"
|
||||||
|
KERNEL=="hidraw*", KERNELS=="*054C:0DF2*", MODE="0660", TAG+="uaccess"
|
||||||
@@ -58,6 +58,14 @@
|
|||||||
"pairing_native_devices": "Gekoppelte Geräte",
|
"pairing_native_devices": "Gekoppelte Geräte",
|
||||||
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
|
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
|
||||||
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||||
|
"pairing_pending_title": "Warten auf Freigabe",
|
||||||
|
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
|
||||||
|
"pairing_pending_approve": "Freigeben",
|
||||||
|
"pairing_pending_deny": "Ablehnen",
|
||||||
|
"pairing_pending_name_prompt": "Gerät benennen:",
|
||||||
|
"pairing_pending_age_just_now": "gerade eben",
|
||||||
|
"pairing_pending_age_secs": "vor {s}s",
|
||||||
|
"pairing_pending_age_mins": "vor {min} min",
|
||||||
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
||||||
"settings_title": "Einstellungen",
|
"settings_title": "Einstellungen",
|
||||||
"settings_token_label": "API-Token",
|
"settings_token_label": "API-Token",
|
||||||
|
|||||||
@@ -58,6 +58,14 @@
|
|||||||
"pairing_native_devices": "Paired devices",
|
"pairing_native_devices": "Paired devices",
|
||||||
"pairing_native_empty": "No devices paired yet.",
|
"pairing_native_empty": "No devices paired yet.",
|
||||||
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
||||||
|
"pairing_pending_title": "Waiting for approval",
|
||||||
|
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
||||||
|
"pairing_pending_approve": "Approve",
|
||||||
|
"pairing_pending_deny": "Deny",
|
||||||
|
"pairing_pending_name_prompt": "Name this device:",
|
||||||
|
"pairing_pending_age_just_now": "just now",
|
||||||
|
"pairing_pending_age_secs": "{s}s ago",
|
||||||
|
"pairing_pending_age_mins": "{min} min ago",
|
||||||
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
"settings_token_label": "API token",
|
"settings_token_label": "API token",
|
||||||
|
|||||||
+197
-56
@@ -1,21 +1,33 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { KeyRound, CheckCircle2, Smartphone, Timer, Trash2 } from 'lucide-react'
|
import {
|
||||||
|
KeyRound,
|
||||||
|
CheckCircle2,
|
||||||
|
Smartphone,
|
||||||
|
Timer,
|
||||||
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useGetNativePairing,
|
useGetNativePairing,
|
||||||
useArmNativePairing,
|
useArmNativePairing,
|
||||||
useDisarmNativePairing,
|
useDisarmNativePairing,
|
||||||
useListNativeClients,
|
useListNativeClients,
|
||||||
useUnpairNativeClient,
|
useUnpairNativeClient,
|
||||||
|
useListPendingDevices,
|
||||||
|
useApprovePendingDevice,
|
||||||
|
useDenyPendingDevice,
|
||||||
getGetNativePairingQueryKey,
|
getGetNativePairingQueryKey,
|
||||||
getListNativeClientsQueryKey,
|
getListNativeClientsQueryKey,
|
||||||
} from '@/api/gen/native/native'
|
getListPendingDevicesQueryKey,
|
||||||
|
} from "@/api/gen/native/native";
|
||||||
import {
|
import {
|
||||||
useGetPairingStatus,
|
useGetPairingStatus,
|
||||||
useSubmitPairingPin,
|
useSubmitPairingPin,
|
||||||
getGetPairingStatusQueryKey,
|
getGetPairingStatusQueryKey,
|
||||||
} from '@/api/gen/pairing/pairing'
|
} from "@/api/gen/pairing/pairing";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -23,49 +35,151 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from "@/components/ui/table";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from "@/components/ui/label";
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from "@/components/query-state";
|
||||||
import { m } from '@/paraglide/messages'
|
import { m } from "@/paraglide/messages";
|
||||||
import { useLocale } from '@/lib/i18n'
|
import { useLocale } from "@/lib/i18n";
|
||||||
|
|
||||||
export const Route = createFileRoute('/pairing')({ component: PairingPage })
|
export const Route = createFileRoute("/pairing")({ component: PairingPage });
|
||||||
|
|
||||||
/** Seconds → `m:ss`. */
|
/** Seconds → `m:ss`. */
|
||||||
function fmtTime(secs: number): string {
|
function fmtTime(secs: number): string {
|
||||||
const s = Math.max(0, Math.floor(secs))
|
const s = Math.max(0, Math.floor(secs));
|
||||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`
|
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PairingPage() {
|
function PairingPage() {
|
||||||
useLocale()
|
useLocale();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
||||||
|
<PendingDevices />
|
||||||
<NativePairingCard />
|
<NativePairingCard />
|
||||||
<NativeDevices />
|
<NativeDevices />
|
||||||
<MoonlightPairingCard />
|
<MoonlightPairingCard />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds since a knock → a short relative label. */
|
||||||
|
function fmtAge(secs: number): string {
|
||||||
|
if (secs < 10) return m.pairing_pending_age_just_now();
|
||||||
|
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
|
||||||
|
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devices awaiting delegated approval: an unpaired device that tried to connect shows up here,
|
||||||
|
* and Approve pairs it on the spot — no PIN fetched out of band. Renders nothing while empty
|
||||||
|
* (the common case); polls so a knock appears while the operator is looking at the page.
|
||||||
|
*/
|
||||||
|
function PendingDevices() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
|
||||||
|
const approve = useApprovePendingDevice();
|
||||||
|
const deny = useDenyPendingDevice();
|
||||||
|
const rows = pending.data ?? [];
|
||||||
|
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
|
||||||
|
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
|
||||||
|
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
|
||||||
|
if (rows.length === 0 && !pending.error) return null;
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
|
||||||
|
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
|
||||||
|
};
|
||||||
|
const onApprove = (id: number, currentName: string) => {
|
||||||
|
const name = prompt(m.pairing_pending_name_prompt(), currentName);
|
||||||
|
if (name == null) return; // operator cancelled
|
||||||
|
approve.mutate(
|
||||||
|
{ id, data: { name: name.trim() ? name.trim() : null } },
|
||||||
|
{ onSuccess: refresh },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-medium">
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
{m.pairing_pending_title()}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.pairing_pending_desc()}
|
||||||
|
</p>
|
||||||
|
<QueryState
|
||||||
|
isLoading={pending.isLoading}
|
||||||
|
error={pending.error}
|
||||||
|
refetch={pending.refetch}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">{p.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{p.fingerprint.slice(0, 16)}…
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{fmtAge(p.age_secs)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={approve.isPending || deny.isPending}
|
||||||
|
onClick={() => onApprove(p.id, p.name)}
|
||||||
|
>
|
||||||
|
{m.pairing_pending_approve()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={m.pairing_pending_deny()}
|
||||||
|
disabled={approve.isPending || deny.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
deny.mutate({ id: p.id }, { onSuccess: refresh })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</QueryState>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
||||||
function NativePairingCard() {
|
function NativePairingCard() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient();
|
||||||
// Poll fast while armed (live countdown), slow otherwise.
|
// Poll fast while armed (live countdown), slow otherwise.
|
||||||
const status = useGetNativePairing({
|
const status = useGetNativePairing({
|
||||||
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
|
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
|
||||||
})
|
});
|
||||||
const arm = useArmNativePairing()
|
const arm = useArmNativePairing();
|
||||||
const disarm = useDisarmNativePairing()
|
const disarm = useDisarmNativePairing();
|
||||||
const d = status.data
|
const d = status.data;
|
||||||
const refresh = () => qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() })
|
const refresh = () =>
|
||||||
|
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryState isLoading={status.isLoading} error={status.error} refetch={status.refetch}>
|
<QueryState
|
||||||
|
isLoading={status.isLoading}
|
||||||
|
error={status.error}
|
||||||
|
refetch={status.refetch}
|
||||||
|
>
|
||||||
<Card className="max-w-md">
|
<Card className="max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -75,7 +189,9 @@ function NativePairingCard() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{!d?.enabled ? (
|
{!d?.enabled ? (
|
||||||
<p className="text-sm text-muted-foreground">{m.pairing_native_disabled()}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.pairing_native_disabled()}
|
||||||
|
</p>
|
||||||
) : d.armed && d.pin ? (
|
) : d.armed && d.pin ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm">{m.pairing_native_enter()}</p>
|
<p className="text-sm">{m.pairing_native_enter()}</p>
|
||||||
@@ -99,10 +215,17 @@ function NativePairingCard() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">{m.pairing_native_desc()}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{m.pairing_native_desc()}
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
disabled={arm.isPending}
|
disabled={arm.isPending}
|
||||||
onClick={() => arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refresh })}
|
onClick={() =>
|
||||||
|
arm.mutate(
|
||||||
|
{ data: { ttl_secs: 120 } },
|
||||||
|
{ onSuccess: refresh },
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<KeyRound className="size-4" />
|
<KeyRound className="size-4" />
|
||||||
{m.pairing_native_arm()}
|
{m.pairing_native_arm()}
|
||||||
@@ -112,28 +235,35 @@ function NativePairingCard() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</QueryState>
|
</QueryState>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The paired native (punktfunk/1) devices, with unpair. */
|
/** The paired native (punktfunk/1) devices, with unpair. */
|
||||||
function NativeDevices() {
|
function NativeDevices() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient();
|
||||||
const clients = useListNativeClients()
|
const clients = useListNativeClients();
|
||||||
const unpair = useUnpairNativeClient()
|
const unpair = useUnpairNativeClient();
|
||||||
const rows = clients.data ?? []
|
const rows = clients.data ?? [];
|
||||||
|
|
||||||
const onUnpair = (fingerprint: string) => {
|
const onUnpair = (fingerprint: string) => {
|
||||||
if (!confirm(m.pairing_native_unpair_confirm())) return
|
if (!confirm(m.pairing_native_unpair_confirm())) return;
|
||||||
unpair.mutate(
|
unpair.mutate(
|
||||||
{ fingerprint },
|
{ fingerprint },
|
||||||
{ onSuccess: () => qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }) },
|
{
|
||||||
)
|
onSuccess: () =>
|
||||||
}
|
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
||||||
<QueryState isLoading={clients.isLoading} error={clients.error} refetch={clients.refetch}>
|
<QueryState
|
||||||
|
isLoading={clients.isLoading}
|
||||||
|
error={clients.error}
|
||||||
|
refetch={clients.refetch}
|
||||||
|
>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6 text-center text-sm text-muted-foreground">
|
<CardContent className="p-6 text-center text-sm text-muted-foreground">
|
||||||
@@ -154,7 +284,9 @@ function NativeDevices() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((c) => (
|
{rows.map((c) => (
|
||||||
<TableRow key={c.fingerprint}>
|
<TableRow key={c.fingerprint}>
|
||||||
<TableCell className="font-medium">{c.name || '—'}</TableCell>
|
<TableCell className="font-medium">
|
||||||
|
{c.name || "—"}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
{c.fingerprint.slice(0, 16)}…
|
{c.fingerprint.slice(0, 16)}…
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -178,32 +310,36 @@ function NativeDevices() {
|
|||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
|
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
|
||||||
function MoonlightPairingCard() {
|
function MoonlightPairingCard() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient();
|
||||||
const [pin, setPin] = useState('')
|
const [pin, setPin] = useState("");
|
||||||
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } })
|
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
|
||||||
const submit = useSubmitPairingPin()
|
const submit = useSubmitPairingPin();
|
||||||
const pending = pairing.data?.pin_pending ?? false
|
const pending = pairing.data?.pin_pending ?? false;
|
||||||
|
|
||||||
const onSubmit = (e: React.FormEvent) => {
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
submit.mutate(
|
submit.mutate(
|
||||||
{ data: { pin } },
|
{ data: { pin } },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setPin('')
|
setPin("");
|
||||||
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() })
|
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryState isLoading={pairing.isLoading} error={pairing.error} refetch={pairing.refetch}>
|
<QueryState
|
||||||
|
isLoading={pairing.isLoading}
|
||||||
|
error={pairing.error}
|
||||||
|
refetch={pairing.refetch}
|
||||||
|
>
|
||||||
<Card className="max-w-md">
|
<Card className="max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -225,12 +361,15 @@ function MoonlightPairingCard() {
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
value={pin}
|
value={pin}
|
||||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
|
||||||
placeholder="0000"
|
placeholder="0000"
|
||||||
className="font-mono text-lg tracking-widest"
|
className="font-mono text-lg tracking-widest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={pin.length < 4 || submit.isPending}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={pin.length < 4 || submit.isPending}
|
||||||
|
>
|
||||||
{m.pairing_submit()}
|
{m.pairing_submit()}
|
||||||
</Button>
|
</Button>
|
||||||
{submit.isSuccess && (
|
{submit.isSuccess && (
|
||||||
@@ -239,11 +378,13 @@ function MoonlightPairingCard() {
|
|||||||
{m.pairing_success()}
|
{m.pairing_success()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{submit.isError && <p className="text-sm text-destructive">{m.pairing_failed()}</p>}
|
{submit.isError && (
|
||||||
|
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</QueryState>
|
</QueryState>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user