Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 202f40fd4e | |||
| 8f90563ffd | |||
| 2e6b822fd6 | |||
| f7c5314b5e | |||
| d6669fc3fb | |||
| e292084225 | |||
| c758b0393a | |||
| d6a659a1ee | |||
| 2190dad2ad | |||
| 5b5ec15ead | |||
| c9ff144492 | |||
| 7930d2f0f4 | |||
| 160b67d043 | |||
| 6c4ba77606 | |||
| eeee2782f5 | |||
| b488bd1d99 | |||
| 7e6561aaa2 | |||
| e9c5030190 | |||
| 22c0d92f2e | |||
| 097cc6faf4 | |||
| 8b37badae4 | |||
| 90c2d8b3a0 | |||
| 853e7fe92f | |||
| df496776b0 | |||
| 5310176ab5 | |||
| 76ff616dcf |
@@ -0,0 +1,142 @@
|
||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
|
||||
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
|
||||
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
|
||||
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
|
||||
# resolves today — the same sonames an up-to-date Arch box runs.
|
||||
#
|
||||
# Registry (public, unom org) — box setup (once), see packaging/arch/README.md. The registry
|
||||
# SIGNS the DB + packages, so the box imports the registry key first (pacman-key --add +
|
||||
# --lsign-key), then no SigLevel line is needed (pacman's default Required verifies):
|
||||
# [punktfunk] # or [punktfunk-canary] for main-push builds
|
||||
# Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
|
||||
# NOTE: this token + the registry-held private key are the trust root — a token holder can
|
||||
# publish a validly-signed package (the signature attests "via the registry", not "built by CI").
|
||||
name: arch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the
|
||||
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
|
||||
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: docker.io/library/archlinux:base-devel
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_HOME: /usr/local/cargo
|
||||
steps:
|
||||
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
|
||||
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
|
||||
- name: Install build + runtime-dev deps
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake nasm pkgconf python \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive
|
||||
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
||||
# it's AUR-only on Arch, so bootstrap the official binary.
|
||||
command -v bun >/dev/null || {
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
}
|
||||
bun --version
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Cache cargo's git dir too, not just the registry: the workspace includes
|
||||
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
|
||||
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
|
||||
# is ever compiled here. Cached, that cost is paid once per runner.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-arch-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-arch-
|
||||
|
||||
- name: Version + channel
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
|
||||
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
|
||||
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
|
||||
echo "REPO=$REPO" >> "$GITHUB_ENV"
|
||||
echo "pacman $V-$R -> repo '$REPO'"
|
||||
|
||||
- name: Build packages (makepkg)
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# libcuda link stub — same trick as packaging/rpm/build-rpm.sh: the zerocopy FFI
|
||||
# links -lcuda but the builder has no GPU; synthesize every cu* symbol the source
|
||||
# references so a newly-added call can't silently break the link.
|
||||
CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)"
|
||||
if [ -n "$CU_SYMS" ] && [ ! -e /usr/lib/libcuda.so ]; then
|
||||
STUB_C="$(mktemp --suffix=.c)"
|
||||
for s in $CU_SYMS; do printf 'int %s(void){return 0;}\n' "$s" >> "$STUB_C"; done
|
||||
gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib/libcuda.so.1 "$STUB_C"
|
||||
ln -sf libcuda.so.1 /usr/lib/libcuda.so
|
||||
rm -f "$STUB_C"; ldconfig
|
||||
echo "== libcuda stub: $(printf '%s\n' "$CU_SYMS" | wc -l) symbols =="
|
||||
fi
|
||||
# makepkg refuses to run as root; deps are already installed above (-d skips the
|
||||
# RPM-level check that can't see the script-installed bun anyway).
|
||||
useradd -m builder
|
||||
mkdir -p "$CARGO_HOME" # actions/cache doesn't create it on a cache miss
|
||||
chown -R builder: "$PWD" "$CARGO_HOME"
|
||||
sudo -u builder git config --global --add safe.directory "$PWD"
|
||||
mkdir -p dist && chown builder: dist
|
||||
cd packaging/arch
|
||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
|
||||
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
||||
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
|
||||
- name: Publish to the Gitea Arch registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
echo "uploading $pkg"
|
||||
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
|
||||
VER=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p')
|
||||
ARCH=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^arch = //p')
|
||||
# A re-tagged release re-fires this workflow and the registry 409s on duplicate
|
||||
# package versions — delete any prior copy first (404 on the first publish is fine).
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$pkg" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO"
|
||||
done
|
||||
echo "published to $OWNER/arch/$REPO"
|
||||
|
||||
# On a real release, also attach the packages to the unified Gitea Release.
|
||||
- name: Attach packages to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
upsert_asset "$RID" "$pkg"
|
||||
done
|
||||
@@ -14,8 +14,12 @@
|
||||
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
|
||||
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
|
||||
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
|
||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements, so what we test
|
||||
# locally equals what App Store users get.
|
||||
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
|
||||
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
|
||||
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
|
||||
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
|
||||
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
|
||||
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
|
||||
#
|
||||
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
|
||||
# step is continue-on-error until they exist):
|
||||
@@ -27,6 +31,15 @@
|
||||
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
|
||||
# .pkg is installer-signed with it.
|
||||
#
|
||||
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
|
||||
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
|
||||
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
|
||||
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
|
||||
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
|
||||
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
|
||||
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
|
||||
# a launchable app.
|
||||
#
|
||||
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
|
||||
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
|
||||
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
|
||||
@@ -156,9 +169,8 @@ jobs:
|
||||
run: |
|
||||
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
|
||||
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
|
||||
# provisioning-profile gate; codesign just needs the (now valid) identity + the
|
||||
# team-prefixed entitlements, no profile (App Sandbox + the network/device
|
||||
# capabilities are self-asserted for Developer ID — no profile entry needed).
|
||||
# provisioning-profile gate at archive time; we re-assert that authorization below by
|
||||
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
|
||||
# Bundle is a single static binary.
|
||||
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
|
||||
-project "$PROJECT" -scheme Punktfunk \
|
||||
@@ -173,6 +185,35 @@ jobs:
|
||||
RESOLVED="$RUNNER_TEMP/macos.entitlements"
|
||||
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
|
||||
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
|
||||
|
||||
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
|
||||
# network/device keys are self-asserted for Developer ID, but a keychain access group
|
||||
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
|
||||
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
|
||||
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
|
||||
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
|
||||
# entitlements authorize the access group, exactly like the App Store build's profile
|
||||
# does. Located by profile Name among the profiles installed on the runner (see header).
|
||||
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
|
||||
PROFILE_SRC=""
|
||||
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
|
||||
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
|
||||
[ -e "$p" ] || continue
|
||||
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
|
||||
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
|
||||
done
|
||||
if [ -n "$PROFILE_SRC" ]; then
|
||||
# Must land BEFORE codesign so it's sealed into the bundle.
|
||||
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
|
||||
echo "embedded Developer ID profile: $PROFILE_SRC"
|
||||
else
|
||||
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
|
||||
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
|
||||
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
|
||||
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
|
||||
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$RESOLVED" \
|
||||
--sign "Developer ID Application" "$APP"
|
||||
|
||||
@@ -35,8 +35,10 @@ jobs:
|
||||
include:
|
||||
- image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base
|
||||
group: bazzite
|
||||
fedver: 43
|
||||
- image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin
|
||||
group: fedora-44
|
||||
fedver: 44
|
||||
container:
|
||||
image: git.unom.io/unom/${{ matrix.image }}:latest
|
||||
timeout-minutes: 90
|
||||
@@ -53,6 +55,8 @@ jobs:
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
|
||||
command -v bun >/dev/null || {
|
||||
@@ -117,6 +121,27 @@ jobs:
|
||||
done
|
||||
echo "published to $OWNER/rpm/$GROUP"
|
||||
|
||||
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||
- name: Build the sysext image
|
||||
run: |
|
||||
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
- name: Publish the sysext feed
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
case "$GROUP" in
|
||||
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up
|
||||
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release
|
||||
esac
|
||||
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
|
||||
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
|
||||
|
||||
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
|
||||
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
|
||||
# both on the release; canary builds live in the `*-canary` rpm groups (no release page).
|
||||
@@ -132,3 +157,6 @@ jobs:
|
||||
base="$(basename "$rpm" .rpm)"
|
||||
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
|
||||
done
|
||||
for raw in dist-sysext/*.raw; do
|
||||
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
|
||||
done
|
||||
|
||||
Generated
+71
-12
@@ -1952,6 +1952,16 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.15.0"
|
||||
@@ -2119,7 +2129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2195,7 +2205,7 @@ dependencies = [
|
||||
"cookie-factory",
|
||||
"libc",
|
||||
"libspa-sys",
|
||||
"nix",
|
||||
"nix 0.30.1",
|
||||
"nom 8.0.0",
|
||||
"system-deps",
|
||||
]
|
||||
@@ -2251,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2262,6 +2272,16 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac_address"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||
dependencies = [
|
||||
"nix 0.29.0",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2285,7 +2305,7 @@ checksum = "fb75febbe5fa1837a52fdbd1c735e168286c5c645fc2ddd31526f65c49941c2e"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"flume",
|
||||
"if-addrs",
|
||||
"if-addrs 0.15.0",
|
||||
"log",
|
||||
"mio",
|
||||
"socket-pktinfo",
|
||||
@@ -2383,6 +2403,19 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
@@ -2742,7 +2775,7 @@ dependencies = [
|
||||
"libc",
|
||||
"libspa",
|
||||
"libspa-sys",
|
||||
"nix",
|
||||
"nix 0.30.1",
|
||||
"once_cell",
|
||||
"pipewire-sys",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2875,7 +2908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -2889,12 +2922,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"gtk4",
|
||||
"khronos-egl",
|
||||
"libadwaita",
|
||||
"mdns-sd",
|
||||
"opus",
|
||||
@@ -2911,7 +2945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2934,7 +2968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2942,6 +2976,7 @@ dependencies = [
|
||||
"criterion",
|
||||
"fec-rs",
|
||||
"hmac",
|
||||
"if-addrs 0.13.4",
|
||||
"libc",
|
||||
"opus",
|
||||
"proptest",
|
||||
@@ -2964,7 +2999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -2982,10 +3017,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"if-addrs 0.13.4",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"mac_address",
|
||||
"mdns-sd",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
@@ -3034,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3048,7 +3085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -4765,6 +4802,22 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4774,6 +4827,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ members = [
|
||||
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -83,8 +83,9 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
| Platform | Install | Guide |
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
|
||||
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
|
||||
@@ -124,6 +124,25 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
val identityStore = remember { IdentityStore(context) }
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients),
|
||||
// so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the
|
||||
// prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC
|
||||
// was actually newly learned.
|
||||
LaunchedEffect(discovered) {
|
||||
val learned = withContext(Dispatchers.IO) {
|
||||
var any = false
|
||||
discovered.forEach { dh ->
|
||||
if (dh.mac.isNotEmpty() &&
|
||||
knownHostStore.get(dh.host, dh.port)?.let { it.mac != dh.mac } == true
|
||||
) {
|
||||
knownHostStore.learnMac(dh.host, dh.port, dh.mac)
|
||||
any = true
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
if (learned) savedHosts = knownHostStore.all()
|
||||
}
|
||||
// Mint-once on genuine first run; an Unrecoverable store (decrypt failure) surfaces here and
|
||||
// refuses to connect — never silently shadow-minting a new identity (which would force re-pair).
|
||||
var identity by remember { mutableStateOf<ClientIdentity?>(null) }
|
||||
@@ -176,6 +195,14 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
}
|
||||
connecting = true
|
||||
status = "Connecting to $targetHost:$targetPort…"
|
||||
// Auto-wake: reconnecting to a saved host that may be asleep. If we learned its MAC while it
|
||||
// was online and it isn't currently advertising, fire a magic packet first — the connect's
|
||||
// own timeout gives a woken host time to come up (harmless if it's already awake).
|
||||
knownHostStore.get(targetHost, targetPort)?.mac
|
||||
?.takeIf { it.isNotEmpty() && discovered.none { d -> d.host == targetHost && d.port == targetPort } }
|
||||
?.let { macs ->
|
||||
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
||||
}
|
||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||
scope.launch {
|
||||
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
|
||||
@@ -359,6 +386,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
savedHosts = knownHostStore.all()
|
||||
},
|
||||
onRename = { renameTarget = kh },
|
||||
// Explicit wake: offered only when the host is offline and we have a MAC to
|
||||
// target (a tap-to-connect already auto-wakes an offline saved host).
|
||||
onWake = if (kh.mac.isNotEmpty() &&
|
||||
discovered.none { it.host == kh.address && it.port == kh.port }
|
||||
) {
|
||||
{ scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address) } }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ fun HostCard(
|
||||
onConnect: () -> Unit,
|
||||
onForget: (() -> Unit)?,
|
||||
onRename: (() -> Unit)? = null,
|
||||
onWake: (() -> Unit)? = null,
|
||||
) {
|
||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
||||
@@ -107,7 +108,7 @@ fun HostCard(
|
||||
StatusPill(status)
|
||||
}
|
||||
|
||||
if (onForget != null || onRename != null) {
|
||||
if (onForget != null || onRename != null || onWake != null) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
IconButton(enabled = enabled, onClick = { menu = true }) {
|
||||
@@ -119,6 +120,15 @@ fun HostCard(
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
if (onWake != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Wake host") },
|
||||
onClick = {
|
||||
menu = false
|
||||
onWake()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (onRename != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
|
||||
@@ -86,7 +86,7 @@ object NativeBridge {
|
||||
|
||||
/**
|
||||
* The current resolved-host snapshot for [handle]: newline-joined records, each
|
||||
* `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||
* `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts / `0` handle. Poll ~1 Hz;
|
||||
* cheap (a lock + string build), safe to call on the main thread.
|
||||
*/
|
||||
external fun nativeDiscoveryPoll(handle: Long): String
|
||||
@@ -94,6 +94,15 @@ object NativeBridge {
|
||||
/** Stop the browse, shut the mDNS daemon down and join its thread. No-op on `0`. */
|
||||
external fun nativeDiscoveryStop(handle: Long)
|
||||
|
||||
/**
|
||||
* Send a Wake-on-LAN magic packet to wake a sleeping host. [macsCsv] is comma-separated MAC
|
||||
* addresses (`aa:bb:..,cc:dd:..`), learned from the host's mDNS `mac` TXT while it was online;
|
||||
* [lastIp] is the host's last-known IPv4 (or empty). Returns true if at least one datagram was
|
||||
* sent. No handle — callable without a live session. Do NOT call on the main thread (it does
|
||||
* blocking socket sends); run it on a background dispatcher.
|
||||
*/
|
||||
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
|
||||
|
||||
/**
|
||||
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
|
||||
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started.
|
||||
|
||||
+7
-3
@@ -17,15 +17,17 @@ data class DiscoveredHost(
|
||||
val port: Int,
|
||||
val fingerprint: String? = null, // TXT "fp" (host cert SHA-256, advisory — TOFU still verifies)
|
||||
val pairingRequired: Boolean = false,
|
||||
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
|
||||
)
|
||||
|
||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||
private const val FIELD_SEP = '\u001F'
|
||||
|
||||
/**
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair`), or null
|
||||
* if it's malformed. Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac`), or
|
||||
* null if it's malformed. `mac` (7th field) is optional — an older host omits it. Pure —
|
||||
* unit-tested without Android (see ParseRecordTest). The native side already applied the protocol
|
||||
* gate and address selection, so this is just field marshaling.
|
||||
*/
|
||||
fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
val f = record.split(FIELD_SEP)
|
||||
@@ -40,6 +42,8 @@ fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
port = port,
|
||||
fingerprint = f[4].ifBlank { null },
|
||||
pairingRequired = f[5] == "required",
|
||||
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
else emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ data class KnownHost(
|
||||
val name: String,
|
||||
val fpHex: String,
|
||||
val paired: Boolean,
|
||||
/**
|
||||
* Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was
|
||||
* online, so the client can wake it once it sleeps. Empty until first learned.
|
||||
*/
|
||||
val mac: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -42,9 +47,22 @@ class KnownHostStore(context: Context) {
|
||||
.put("name", host.name)
|
||||
.put("fp", host.fpHex.lowercase())
|
||||
.put("paired", host.paired)
|
||||
.put("mac", host.mac.joinToString(","))
|
||||
prefs.edit().putString(key(host.address, host.port), json.toString()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while online).
|
||||
* No-op when the host isn't saved, the list is empty, or it's unchanged — so it doesn't churn
|
||||
* prefs on every discovery tick.
|
||||
*/
|
||||
fun learnMac(address: String, port: Int, mac: List<String>) {
|
||||
if (mac.isEmpty()) return
|
||||
val h = get(address, port) ?: return
|
||||
if (h.mac == mac) return
|
||||
save(h.copy(mac = mac))
|
||||
}
|
||||
|
||||
/** Forget [address]:[port] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(address: String, port: Int) {
|
||||
prefs.edit().remove(key(address, port)).apply()
|
||||
@@ -68,6 +86,7 @@ class KnownHostStore(context: Context) {
|
||||
name = j.getString("name"),
|
||||
fpHex = j.getString("fp"),
|
||||
paired = j.optBoolean("paired", false),
|
||||
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const PROTO: &str = "punktfunk/1";
|
||||
/// Field separator inside one serialized record (ASCII Unit Separator — never in a field value).
|
||||
const FIELD_SEP: char = '\u{1f}';
|
||||
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair` (`␟` = [`FIELD_SEP`]).
|
||||
/// One resolved host, serialized to Kotlin as `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = [`FIELD_SEP`]).
|
||||
/// Records are newline-joined in a poll snapshot; [`Host::encode`] strips the framing bytes from
|
||||
/// every field so no value can break it.
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -42,6 +42,8 @@ struct Host {
|
||||
port: u16,
|
||||
fp: String,
|
||||
pair: String,
|
||||
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated), for later wake. Empty if absent.
|
||||
mac: String,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
@@ -54,13 +56,14 @@ impl Host {
|
||||
s.replace(['\n', '\r', FIELD_SEP], "")
|
||||
}
|
||||
format!(
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
"{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}",
|
||||
clean(&self.key),
|
||||
clean(&self.name),
|
||||
clean(&self.addr),
|
||||
self.port,
|
||||
clean(&self.fp),
|
||||
clean(&self.pair),
|
||||
clean(&self.mac),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -182,6 +185,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
port: info.get_port(),
|
||||
fp: val("fp"),
|
||||
pair: val("pair"),
|
||||
mac: val("mac"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,7 +206,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
@@ -263,16 +267,18 @@ mod tests {
|
||||
port: 9777,
|
||||
fp: "ab".repeat(32),
|
||||
pair: "required".into(),
|
||||
mac: "aa:bb:cc:dd:ee:ff".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields.len(), 6);
|
||||
assert_eq!(fields.len(), 7);
|
||||
assert_eq!(fields[0], "host-123");
|
||||
assert_eq!(fields[1], "home-worker-2");
|
||||
assert_eq!(fields[2], "192.168.1.70");
|
||||
assert_eq!(fields[3], "9777");
|
||||
assert_eq!(fields[4], "ab".repeat(32));
|
||||
assert_eq!(fields[5], "required");
|
||||
assert_eq!(fields[6], "aa:bb:cc:dd:ee:ff");
|
||||
assert!(
|
||||
!encoded.contains('\n'),
|
||||
"a record must never contain the record separator"
|
||||
@@ -282,7 +288,7 @@ mod tests {
|
||||
#[test]
|
||||
fn encode_strips_injected_separators_from_a_hostile_advert() {
|
||||
// A rogue advert could carry framing bytes in its instance label / TXT; encode must strip
|
||||
// them so the snapshot stays exactly one record of exactly six fields.
|
||||
// them so the snapshot stays exactly one record of exactly seven fields.
|
||||
let h = Host {
|
||||
key: "k\u{1f}injected".into(),
|
||||
name: "evil\nhost\r".into(),
|
||||
@@ -290,9 +296,14 @@ mod tests {
|
||||
port: 9777,
|
||||
fp: "ab\u{1f}cd".into(),
|
||||
pair: "required\n".into(),
|
||||
mac: "aa:bb\u{1f}cc".into(),
|
||||
};
|
||||
let encoded = h.encode();
|
||||
assert_eq!(encoded.matches(FIELD_SEP).count(), 5, "exactly six fields");
|
||||
assert_eq!(
|
||||
encoded.matches(FIELD_SEP).count(),
|
||||
6,
|
||||
"exactly seven fields"
|
||||
);
|
||||
assert!(!encoded.contains('\n') && !encoded.contains('\r'));
|
||||
let fields: Vec<&str> = encoded.split(FIELD_SEP).collect();
|
||||
assert_eq!(fields[0], "kinjected");
|
||||
|
||||
@@ -39,6 +39,9 @@ mod feedback;
|
||||
mod mic;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
// into the host workspace build too. Kotlin only ever calls it on device.
|
||||
mod wol;
|
||||
|
||||
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
|
||||
/// `punktfunk` tag. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! JNI seam for Wake-on-LAN: parse the stored MAC strings and hand them to the shared core sender
|
||||
//! (`punktfunk_core::wol`). Like [`crate::discovery`], this takes no session handle — a sleeping
|
||||
//! host has no ARP entry, so the broadcast the core sends is what wakes it, and Kotlin calls this
|
||||
//! just before connecting to an offline saved host.
|
||||
|
||||
use jni::objects::{JObject, JString};
|
||||
use jni::JNIEnv;
|
||||
|
||||
/// `NativeBridge.nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean` — send a Wake-on-LAN
|
||||
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
|
||||
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
|
||||
/// Returns true if at least one datagram went out.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
macs_csv: JString<'local>,
|
||||
last_ip: JString<'local>,
|
||||
) -> jni::sys::jboolean {
|
||||
let macs_csv: String = match env.get_string(&macs_csv) {
|
||||
Ok(s) => s.into(),
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let last_ip: String = env
|
||||
.get_string(&last_ip)
|
||||
.map(Into::<String>::into)
|
||||
.unwrap_or_default();
|
||||
let macs: Vec<[u8; 6]> = macs_csv
|
||||
.split(',')
|
||||
.filter_map(|s| punktfunk_core::wol::parse_mac(s.trim()))
|
||||
.collect();
|
||||
if macs.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let ip = last_ip.trim().parse::<std::net::Ipv4Addr>().ok();
|
||||
match punktfunk_core::wol::send_magic_packet(&macs, ip) {
|
||||
Ok(()) => 1,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,22 @@
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)io.unom.punktfunk</string>
|
||||
</array>
|
||||
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
|
||||
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
|
||||
broadcast/multicast addresses unless the app carries this managed entitlement — it must
|
||||
be requested from and approved by Apple for the App ID, then enabled in the provisioning
|
||||
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
|
||||
|
||||
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
|
||||
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
|
||||
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
|
||||
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
|
||||
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
|
||||
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
|
||||
file, no multicast entitlement needed). -->
|
||||
<!--
|
||||
<key>com.apple.developer.networking.multicast</key>
|
||||
<true/>
|
||||
-->
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -365,6 +365,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
@@ -399,6 +400,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Config/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
|
||||
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
|
||||
|
||||
@@ -408,6 +408,7 @@ struct ContentView: View {
|
||||
_ host: StoredHost, launchID: String? = nil,
|
||||
allowTofu: Bool, requestAccess: Bool = false
|
||||
) {
|
||||
prepareWake(for: host)
|
||||
model.connect(
|
||||
to: host,
|
||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||
@@ -426,6 +427,25 @@ struct ContentView: View {
|
||||
requestAccess: requestAccess)
|
||||
}
|
||||
|
||||
/// Learn-while-awake, wake-while-asleep — run just before every connect:
|
||||
/// • host currently advertising (awake) → refresh its stored Wake-on-LAN MAC(s) from the live
|
||||
/// advert, so a later wake has an up-to-date target;
|
||||
/// • host NOT advertising (likely asleep/off) and we have MAC(s) → fire a magic packet first.
|
||||
/// The connect that follows already retries/times out long enough for a woken host to come
|
||||
/// up; if it's genuinely off/unreachable the connect fails as before. Best-effort and
|
||||
/// non-blocking (the send runs off the main thread).
|
||||
private func prepareWake(for host: StoredHost) {
|
||||
if let live = discovery.hosts.first(where: { host.matches($0) }) {
|
||||
store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform
|
||||
} else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
|
||||
let macs = host.wakeMacs
|
||||
let ip = host.address
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The no-PIN delegated-approval flow: open an identified connect the host parks until the
|
||||
/// operator approves it in the console, showing the cancelable "Waiting for approval" prompt
|
||||
/// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned
|
||||
@@ -455,7 +475,9 @@ struct ContentView: View {
|
||||
/// inside `connect`.)
|
||||
private func connectDiscovered(_ d: DiscoveredHost) {
|
||||
guard !model.isBusy else { return }
|
||||
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
||||
let host = StoredHost(
|
||||
name: d.name, address: d.host, port: d.port,
|
||||
macAddresses: d.macAddresses.isEmpty ? nil : d.macAddresses)
|
||||
store.add(host)
|
||||
if d.allowsTofu {
|
||||
connect(host, allowTofu: true)
|
||||
|
||||
@@ -154,7 +154,14 @@ struct HomeView: View {
|
||||
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
||||
onForget: { store.forgetIdentity(host) },
|
||||
onRemove: { store.remove(host) },
|
||||
onBrowseLibrary: onBrowseLibrary)
|
||||
onBrowseLibrary: onBrowseLibrary,
|
||||
onWake: {
|
||||
let macs = host.wakeMacs
|
||||
let ip = host.address
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private var discoveredSection: some View {
|
||||
|
||||
@@ -86,6 +86,9 @@ struct HostCardView: View {
|
||||
let onRemove: () -> Void
|
||||
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
||||
var onBrowseLibrary: (() -> Void)? = nil
|
||||
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
|
||||
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
|
||||
var onWake: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
@@ -138,6 +141,9 @@ struct HostCardView: View {
|
||||
if let onBrowseLibrary {
|
||||
Button("Browse Library…", action: onBrowseLibrary)
|
||||
}
|
||||
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
|
||||
Button("Wake Host", systemImage: "power", action: onWake)
|
||||
}
|
||||
if host.pinnedSHA256 != nil {
|
||||
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
|
||||
// PIN (unless the host advertises pair=optional). Wording reflects that.
|
||||
|
||||
@@ -26,9 +26,16 @@ struct StoredHost: Identifiable, Codable, Hashable {
|
||||
/// decode: synthesized Decodable ignores property defaults but treats a missing Optional as
|
||||
/// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.)
|
||||
var mgmtPort: UInt16?
|
||||
/// Wake-on-LAN MAC address(es) of the host's wake-capable NIC(s), each `aa:bb:cc:dd:ee:ff`.
|
||||
/// Learned from the host's mDNS `mac` TXT record while it's awake and persisted here, so the
|
||||
/// client can send a magic packet to wake the host later (when it's asleep and no longer
|
||||
/// advertising). Optional (same forward-compat reason as `mgmtPort`); nil until first learned.
|
||||
var macAddresses: [String]?
|
||||
|
||||
var displayName: String { name.isEmpty ? address : name }
|
||||
var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort }
|
||||
/// Wake-capable, in a form the wake helper accepts (empty when none learned yet).
|
||||
var wakeMacs: [String] { macAddresses ?? [] }
|
||||
}
|
||||
|
||||
extension StoredHost {
|
||||
@@ -101,6 +108,16 @@ final class HostStore: ObservableObject {
|
||||
hosts[i].pinnedSHA256 = fingerprint
|
||||
}
|
||||
|
||||
/// Learn/refresh this host's Wake-on-LAN MAC(s) from its live advert (called while the host is
|
||||
/// awake, so the client can wake it once it sleeps). No-op when unchanged, so it doesn't churn
|
||||
/// UserDefaults on every discovery tick.
|
||||
func updateMacs(_ hostID: UUID, macs: [String]) {
|
||||
guard !macs.isEmpty,
|
||||
let i = hosts.firstIndex(where: { $0.id == hostID }),
|
||||
hosts[i].macAddresses != macs else { return }
|
||||
hosts[i].macAddresses = macs
|
||||
}
|
||||
|
||||
/// Drop the pinned identity (e.g. after a legitimate host reinstall). This does NOT downgrade
|
||||
/// to TOFU: the next connect re-pairs via the PIN ceremony, unless the host advertises
|
||||
/// `pair=optional` (the only case the connect path still offers the trust prompt).
|
||||
|
||||
@@ -31,6 +31,12 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
||||
/// reduced-security TOFU "Trust" path. A missing/unknown `pair` field is NOT optional:
|
||||
/// pairing is mandatory unless this is true (the policy authority is the host's advert).
|
||||
public let allowsTofu: Bool
|
||||
/// Wake-on-LAN MAC address(es) the host advertised (mDNS `mac` TXT, comma-separated
|
||||
/// `aa:bb:cc:dd:ee:ff`, routed NIC first). Empty when not advertised. A client persists these
|
||||
/// onto the saved host so it can wake it after it sleeps; advisory/unauthenticated (a wrong
|
||||
/// value only makes a wake fail — the magic packet is inert and the fingerprint still gates
|
||||
/// the connection).
|
||||
public let macAddresses: [String]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -111,10 +117,15 @@ public final class HostDiscovery: ObservableObject {
|
||||
var fp: String?
|
||||
var pair: String?
|
||||
var id: String?
|
||||
var macs: [String] = []
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = Self.entry(txt, "fp")
|
||||
pair = Self.entry(txt, "pair")
|
||||
id = Self.entry(txt, "id")
|
||||
macs = (Self.entry(txt, "mac") ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
let conn = NWConnection(to: result.endpoint, using: .udp)
|
||||
connections[key] = conn
|
||||
@@ -129,7 +140,7 @@ public final class HostDiscovery: ObservableObject {
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port.rawValue,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional")
|
||||
allowsTofu: pair == "optional", macAddresses: macs)
|
||||
self.publish()
|
||||
}
|
||||
conn.cancel()
|
||||
|
||||
@@ -67,6 +67,53 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
|
||||
return s.withCString { body($0) }
|
||||
}
|
||||
|
||||
public extension PunktfunkConnection {
|
||||
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
|
||||
/// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed
|
||||
/// `com.apple.developer.networking.multicast` entitlement, which is GATED pending Apple's
|
||||
/// approval (see `Config/Punktfunk.entitlements`) — until it's granted, sending a broadcast is
|
||||
/// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action.
|
||||
/// The MAC-learning path stays active on every platform, so flipping this on once the
|
||||
/// entitlement lands makes wake work immediately. ON APPROVAL: change `#if os(macOS)` below to
|
||||
/// `true` for iOS/tvOS too (and uncomment the entitlement).
|
||||
static var wakeOnLANAvailable: Bool {
|
||||
#if os(macOS)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
|
||||
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
|
||||
/// skipped. `lastKnownIP`, when set, is additionally unicast. The core broadcasts to every
|
||||
/// interface's subnet-directed broadcast + 255.255.255.255 on ports 9/7, repeated.
|
||||
///
|
||||
/// Returns true if at least one datagram went out. Does blocking sends — call OFF the main
|
||||
/// thread. On iOS/tvOS this requires the `com.apple.developer.networking.multicast` entitlement
|
||||
/// (broadcast is otherwise blocked by the OS); macOS needs only the existing network entitlements.
|
||||
@discardableResult
|
||||
static func wakeOnLAN(macs: [String], lastKnownIP: String? = nil) -> Bool {
|
||||
var bytes: [UInt8] = []
|
||||
var count = 0
|
||||
for mac in macs {
|
||||
let parts = mac.split(separator: ":")
|
||||
guard parts.count == 6 else { continue }
|
||||
let octets = parts.compactMap { UInt8($0, radix: 16) }
|
||||
guard octets.count == 6 else { continue }
|
||||
bytes.append(contentsOf: octets)
|
||||
count += 1
|
||||
}
|
||||
guard count > 0 else { return false }
|
||||
let rc: Int32 = bytes.withUnsafeBufferPointer { buf in
|
||||
withOptionalCString(lastKnownIP) { ip in
|
||||
punktfunk_wake_on_lan(buf.baseAddress, UInt(count), ip)
|
||||
}
|
||||
}
|
||||
return rc == statusOK
|
||||
}
|
||||
}
|
||||
|
||||
public final class PunktfunkConnection {
|
||||
private var handle: OpaquePointer?
|
||||
/// Set by close() before it contends for the plane locks: the pullers see it at their
|
||||
|
||||
@@ -489,6 +489,40 @@ class Plugin:
|
||||
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
|
||||
return {"ok": False, "error": reason}
|
||||
|
||||
async def wake(self, host: str, port: int = 9777) -> dict:
|
||||
"""Send a Wake-on-LAN magic packet to a saved host via the flatpak client's headless
|
||||
``--wake`` mode, so a sleeping host is up by the time the stream ``--connect`` runs.
|
||||
|
||||
The MAC comes from the flatpak client's OWN known-hosts store (learned from the host's
|
||||
mDNS ``mac`` TXT while it was online) — no MAC handling here — so this is a no-op if none
|
||||
has been learned yet. Fire it just before launching a stream; it's fast and best-effort.
|
||||
Returns ``{ok, error?}`` (``ok: False`` when no MAC is known / flatpak missing).
|
||||
"""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return {"ok": False, "error": "flatpak-not-found"}
|
||||
argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--wake", f"{host}:{port}"]
|
||||
decky.logger.info("wake: %s:%s", host, port)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": "wake timed out"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
decky.logger.exception("wake failed to launch")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
if proc.returncode == 0:
|
||||
return {"ok": True}
|
||||
reason = (stderr.decode(errors="replace").strip().splitlines() or
|
||||
["no MAC known for this host yet"])[-1]
|
||||
decky.logger.info("wake skipped (rc=%s): %s", proc.returncode, reason)
|
||||
return {"ok": False, "error": reason}
|
||||
|
||||
async def library(self, host: str, mgmt_port: int = 0, fp: str = "") -> dict:
|
||||
"""Fetch a paired host's game library via the flatpak client's headless
|
||||
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
|
||||
|
||||
@@ -122,6 +122,12 @@ export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>
|
||||
"set_settings",
|
||||
);
|
||||
export const killStream = callable<[], { ok: boolean }>("kill_stream");
|
||||
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
|
||||
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
|
||||
// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching.
|
||||
export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>(
|
||||
"wake",
|
||||
);
|
||||
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
|
||||
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
|
||||
export const updateClient = callable<
|
||||
|
||||
@@ -80,7 +80,7 @@ const QamPanel: FC = () => {
|
||||
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
|
||||
picker (fullscreen page → host row → games button). */}
|
||||
{pins.pins.length > 0 && (
|
||||
<PanelSection title="Games">
|
||||
<PanelSection title="Pinned Games">
|
||||
{pins.pins.map((pin) => {
|
||||
const { online } = resolvePinHost(pin, hosts);
|
||||
return (
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
|
||||
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad
|
||||
// library (`--browse`).
|
||||
import { DialogButton, Field, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { CSSProperties, FC, useEffect, useState } from "react";
|
||||
import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { FaThLarge, FaTv } from "react-icons/fa";
|
||||
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
|
||||
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
|
||||
import { isSafeLaunchId } from "./steam";
|
||||
import { PairModal } from "./pair";
|
||||
import { RowActions, actionButton } from "./ui";
|
||||
|
||||
/** Human store tag (mirrors the GTK client's `store_label`). */
|
||||
export function storeLabel(store: string): string {
|
||||
@@ -58,12 +59,6 @@ export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
|
||||
void startStream(host, { launchId: pin.game_id }, pin.title);
|
||||
}
|
||||
|
||||
const pickButton: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "5em",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
|
||||
function errorCopy(res: LibraryResult): string {
|
||||
switch (res.error) {
|
||||
@@ -143,8 +138,9 @@ export const GamePickerModal: FC<{
|
||||
description="Browse this host's games with the controller, full screen"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={pickButton}
|
||||
style={actionButton}
|
||||
onClick={() => {
|
||||
closeModal?.();
|
||||
void startBrowse(host);
|
||||
@@ -153,6 +149,7 @@ export const GamePickerModal: FC<{
|
||||
<FaTv style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{clientUpdatePending && (
|
||||
@@ -177,10 +174,10 @@ export const GamePickerModal: FC<{
|
||||
|
||||
{result !== null && !result.ok && (
|
||||
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<RowActions>
|
||||
{result.error === "not-paired" && (
|
||||
<DialogButton
|
||||
style={pickButton}
|
||||
style={actionButton}
|
||||
onClick={() =>
|
||||
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
|
||||
}
|
||||
@@ -188,10 +185,10 @@ export const GamePickerModal: FC<{
|
||||
Pair
|
||||
</DialogButton>
|
||||
)}
|
||||
<DialogButton style={pickButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
<DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
|
||||
Retry
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
@@ -217,10 +214,12 @@ export const GamePickerModal: FC<{
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
{pinned ? "Unpin" : "Pin"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
|
||||
+34
-42
@@ -10,6 +10,7 @@ import {
|
||||
showModal,
|
||||
staticClasses,
|
||||
} from "@decky/ui";
|
||||
import { RowActions, actionButton, iconButton } from "./ui";
|
||||
import { toaster } from "@decky/api";
|
||||
import { CSSProperties, FC, useState } from "react";
|
||||
import {
|
||||
@@ -58,27 +59,6 @@ const tabScroll: CSSProperties = {
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
// DialogButton stretches to 100% width in the gamepad UI — on a fullscreen row that means a
|
||||
// screen-wide button. Size action buttons to their content instead (right-aligned by the
|
||||
// Field's children container).
|
||||
const actionButton: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "6em",
|
||||
flexShrink: 0,
|
||||
};
|
||||
// Square icon-only button (details ⓘ, header back arrow) — needs an explicit height too, or
|
||||
// the zero padding collapses it to the icon's line height.
|
||||
const iconButton: CSSProperties = {
|
||||
width: "40px",
|
||||
minWidth: "40px",
|
||||
height: "40px",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
|
||||
// against the host's own log / web console before trusting it.
|
||||
@@ -144,7 +124,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
||||
}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={iconButton}
|
||||
onClick={() => showModal(<HostDetailsModal host={host} />)}
|
||||
@@ -153,13 +133,13 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
||||
</DialogButton>
|
||||
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
|
||||
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
|
||||
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}>
|
||||
<DialogButton style={actionButton} onClick={onGames}>
|
||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||
Games
|
||||
</DialogButton>
|
||||
{needsPair && (
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "5em" }}
|
||||
style={actionButton}
|
||||
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
|
||||
>
|
||||
Pair
|
||||
@@ -178,7 +158,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Stream
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
@@ -201,7 +181,8 @@ const HostsTab: FC<{
|
||||
childrenContainerWidth="max"
|
||||
bottomSeparator={hosts.length ? "standard" : "none"}
|
||||
>
|
||||
<DialogButton style={{ ...actionButton, minWidth: "8em" }} disabled={scanning} onClick={refresh}>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
|
||||
{scanning ? (
|
||||
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
|
||||
) : (
|
||||
@@ -209,6 +190,7 @@ const HostsTab: FC<{
|
||||
)}
|
||||
{scanning ? "Scanning…" : "Refresh"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
|
||||
{hosts.length === 0 && !scanning && (
|
||||
@@ -251,18 +233,18 @@ const HostsTab: FC<{
|
||||
}${pin.paired ? "" : " · pairing required"}`}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
|
||||
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||
Play
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "5em" }}
|
||||
style={actionButton}
|
||||
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
|
||||
>
|
||||
Remove
|
||||
</DialogButton>
|
||||
</Focusable>
|
||||
</RowActions>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
@@ -306,13 +288,15 @@ const AboutTab: FC<{
|
||||
}
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "11em" }}
|
||||
style={actionButton}
|
||||
disabled={checking}
|
||||
onClick={() => void checkForUpdatesNow(check)}
|
||||
>
|
||||
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{hasUpdate(update) && (
|
||||
<Field
|
||||
@@ -326,13 +310,12 @@ const AboutTab: FC<{
|
||||
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "9em" }}
|
||||
onClick={() => applyUpdate(update!, check)}
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
|
||||
<FaDownload style={{ marginRight: "0.4em" }} />
|
||||
Update
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
@@ -340,13 +323,15 @@ const AboutTab: FC<{
|
||||
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<DialogButton
|
||||
style={{ ...actionButton, minWidth: "8em" }}
|
||||
style={actionButton}
|
||||
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
|
||||
>
|
||||
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
|
||||
Open
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field
|
||||
focusable={false}
|
||||
@@ -358,9 +343,11 @@ const AboutTab: FC<{
|
||||
description="Force-stop the stream client if a session wedges"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}>
|
||||
<RowActions>
|
||||
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
|
||||
Force-stop
|
||||
</DialogButton>
|
||||
</RowActions>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
@@ -399,16 +386,21 @@ const PunktfunkPage: FC = () => {
|
||||
</div>
|
||||
</Focusable>
|
||||
|
||||
{/* overflow:hidden is load-bearing: Valve's Tabs slides the incoming panel in from the
|
||||
right on L1/R1, and with autoFocusContents it scrollIntoViews a control inside that
|
||||
still-offscreen panel. Without a clip here the scroll pans #GamepadUI itself — the whole
|
||||
Steam UI (top bar included) slides left until you click a tab. Valve's own Tabs always
|
||||
live in a clipped flex box; match that. */}
|
||||
{/* Two things fight each other on an L1/R1 tab switch:
|
||||
1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
|
||||
2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
|
||||
fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
|
||||
position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
|
||||
up and pans the whole page — the "screen jumps right, then animates back" glitch.
|
||||
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
|
||||
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
|
||||
focus is anywhere inside — including the tab strip); after a switch, focus stays on the
|
||||
strip and Down enters the content, which is how Steam's own tabbed pages behave.
|
||||
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<Tabs
|
||||
activeTab={tab}
|
||||
onShowTab={(id: string) => setTab(id)}
|
||||
autoFocusContents
|
||||
tabs={[
|
||||
{
|
||||
id: "hosts",
|
||||
|
||||
@@ -2,8 +2,20 @@
|
||||
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
|
||||
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
|
||||
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { CSSProperties, FC, useEffect, useState } from "react";
|
||||
import { getSettings, setSettings, StreamSettings } from "./backend";
|
||||
import { RowActions } from "./ui";
|
||||
|
||||
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
|
||||
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
|
||||
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
|
||||
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
|
||||
// edge. Matches the right-aligned, content-sized buttons everywhere else.
|
||||
const selectShell: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "10em",
|
||||
maxWidth: "24em",
|
||||
};
|
||||
|
||||
const RESOLUTIONS: [number, number, string][] = [
|
||||
[0, 0, "Native display"],
|
||||
@@ -61,6 +73,8 @@ export const SettingsSection: FC = () => {
|
||||
description="The host creates a virtual output at exactly this size"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
|
||||
selectedOption={resIdx}
|
||||
@@ -69,13 +83,19 @@ export const SettingsSection: FC = () => {
|
||||
patch({ width: w, height: h });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<Field label="Refresh rate" childrenContainerWidth="max">
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
|
||||
selectedOption={s.refresh_hz}
|
||||
onChange={(o) => patch({ refresh_hz: o.data as number })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<SliderField
|
||||
label="Bitrate"
|
||||
@@ -93,11 +113,15 @@ export const SettingsSection: FC = () => {
|
||||
description="Which virtual controller the host creates for your inputs"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
|
||||
selectedOption={s.gamepad}
|
||||
onChange={(o) => patch({ gamepad: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
|
||||
<Field
|
||||
@@ -110,11 +134,15 @@ export const SettingsSection: FC = () => {
|
||||
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
|
||||
childrenContainerWidth="max"
|
||||
>
|
||||
<RowActions>
|
||||
<div style={selectShell}>
|
||||
<Dropdown
|
||||
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
|
||||
selectedOption={s.compositor}
|
||||
onChange={(o) => patch({ compositor: o.data as string })}
|
||||
/>
|
||||
</div>
|
||||
</RowActions>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Stream microphone"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// and start it with RunGame. The wrapper then execs
|
||||
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
|
||||
|
||||
import { runnerInfo, shortcutArt } from "./backend";
|
||||
import { runnerInfo, shortcutArt, wake } from "./backend";
|
||||
|
||||
// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed
|
||||
// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the
|
||||
@@ -219,6 +219,11 @@ export async function launchStream(
|
||||
port: number,
|
||||
opts: LaunchOpts = {},
|
||||
): Promise<void> {
|
||||
// Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now
|
||||
// so it races with the shortcut setup (near-zero added latency), and awaited just before RunGame.
|
||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||
const { appId, runner } = await ensureShortcut();
|
||||
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
|
||||
// disables Steam Input manually — see the Settings instruction).
|
||||
@@ -240,6 +245,7 @@ export async function launchStream(
|
||||
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
|
||||
// script rides behind it as an argument and reads PF_* from the environment.
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
|
||||
await waking; // ensure the magic packet is out before the connect attempt
|
||||
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
|
||||
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
|
||||
// them and the label text — never hugging it.
|
||||
//
|
||||
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
|
||||
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
|
||||
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
|
||||
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
|
||||
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
|
||||
import { Focusable } from "@decky/ui";
|
||||
import { CSSProperties, FC, ReactNode } from "react";
|
||||
|
||||
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<Focusable
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5em",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Focusable>
|
||||
);
|
||||
|
||||
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
|
||||
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
|
||||
// reads at the same weight.
|
||||
export const actionButton: CSSProperties = {
|
||||
width: "fit-content",
|
||||
minWidth: "7em",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
|
||||
// padding collapses it to the icon's line height.
|
||||
export const iconButton: CSSProperties = {
|
||||
width: "40px",
|
||||
minWidth: "40px",
|
||||
height: "40px",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
@@ -31,6 +31,9 @@ pipewire = "0.9"
|
||||
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
|
||||
# need the hidapi driver).
|
||||
sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
# The VAAPI GL presenter (video_gl.rs): EGL dmabuf import into a GDK-shared context, dlopened
|
||||
# at runtime (`dynamic`) so GPU-less boxes and the software path never touch libEGL.
|
||||
khronos-egl = { version = "6", features = ["dynamic"] }
|
||||
|
||||
mdns-sd = "0.20"
|
||||
# Game-library fetch from the host's management API over mTLS + fingerprint pinning.
|
||||
|
||||
@@ -116,6 +116,23 @@ pub fn run() -> glib::ExitCode {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming every
|
||||
// physical pad Steam Input has virtualized — SDL then hides the real device so games
|
||||
// only see the virtual X360 pad. Right for games, wrong for us: capturing the Deck's
|
||||
// built-in controller (trackpads/paddles/gyro, 28DE:1205) needs SDL's HIDAPI driver
|
||||
// to enumerate the REAL device, and the built-in pad can never leave Steam Input
|
||||
// ("Steam Controller" is always-required), so this filter is the only off switch we
|
||||
// get. Clear it while still single-threaded (the gamepad worker starts with the UI);
|
||||
// we dedupe the virtual pad ourselves (`gamepad.rs` `active_id` skips steam_virtual).
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
}
|
||||
// Headless pairing path (no GTK window): `--pair <PIN> --connect host[:port] [--name N]`.
|
||||
// Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting.
|
||||
if let Some(pin) = crate::cli::arg_value("--pair") {
|
||||
@@ -125,6 +142,11 @@ pub fn run() -> glib::ExitCode {
|
||||
if let Some(target) = crate::cli::arg_value("--library") {
|
||||
return crate::cli::headless_library(&target);
|
||||
}
|
||||
// Headless Wake-on-LAN (no GTK window): `--wake host[:port]`. The Decky wrapper calls this
|
||||
// before the stream launch so a sleeping host is up by the time `--connect` runs.
|
||||
if crate::cli::arg_value("--wake").is_some() {
|
||||
return crate::cli::cli_wake();
|
||||
}
|
||||
let mut builder = adw::Application::builder().application_id(APP_ID);
|
||||
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps each
|
||||
// launch its own primary instance instead of forwarding to a still-registered name.
|
||||
|
||||
@@ -101,6 +101,14 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
|
||||
eprintln!("--connect: unparsable port in '{target}', using default 9777");
|
||||
9777
|
||||
});
|
||||
// Pull the wake MAC(s) from the store (learned from the host's mDNS `mac` TXT while it was
|
||||
// online) so a `--connect` to a known host can still be woken if we add that later.
|
||||
let mac = crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
.map(|h| h.mac.clone())
|
||||
.unwrap_or_default();
|
||||
Some(ConnectRequest {
|
||||
name: addr.clone(),
|
||||
addr,
|
||||
@@ -108,9 +116,39 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
||||
mac,
|
||||
})
|
||||
}
|
||||
|
||||
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit, without
|
||||
/// opening a window. The Decky wrapper calls this before launching the stream so a sleeping host
|
||||
/// is up by the time `--connect` runs. The MAC comes from the known-hosts store (learned from the
|
||||
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
||||
pub fn cli_wake() -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--wake") else {
|
||||
eprintln!("--wake requires host[:port]");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
let port = port.unwrap_or(9777);
|
||||
let mac = crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
.map(|h| h.mac.clone())
|
||||
.unwrap_or_default();
|
||||
if mac.is_empty() {
|
||||
eprintln!(
|
||||
"--wake: no MAC known for {addr}:{port} — connect once while the host is awake so its \
|
||||
advertised MAC is learned"
|
||||
);
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
crate::wol::wake(&mac, addr.parse().ok());
|
||||
println!("woke {addr}:{port} ({} MAC(s) targeted)", mac.len());
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// `--browse host[:port]` — open the gamepad library launcher for that host instead of
|
||||
/// connecting (the Decky wrapper's `PF_BROWSE`; native port, default 9777). The host must
|
||||
/// already be paired: the stored pin is what lets the launcher fetch the library and
|
||||
@@ -138,6 +176,7 @@ pub fn cli_browse_request() -> Option<(ConnectRequest, bool, u16)> {
|
||||
fp_hex: k.map(|k| k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
mac: k.map(|k| k.mac.clone()).unwrap_or_default(),
|
||||
},
|
||||
k.is_some_and(|k| k.paired),
|
||||
mgmt,
|
||||
@@ -210,6 +249,7 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
),
|
||||
pair_optional: true,
|
||||
launch: None,
|
||||
mac: Vec::new(),
|
||||
};
|
||||
let mock_advert =
|
||||
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
||||
@@ -221,6 +261,7 @@ pub fn run_shot(app: Rc<App>, scene: &str) {
|
||||
fp_hex: fp.to_string(),
|
||||
pair: "required".to_string(),
|
||||
mgmt_port: None,
|
||||
mac: Vec::new(),
|
||||
};
|
||||
|
||||
// What the self-capture renders: the main window, except for scenes that open their
|
||||
|
||||
@@ -22,6 +22,9 @@ pub struct DiscoveredHost {
|
||||
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
|
||||
/// library client then falls back to the well-known default.
|
||||
pub mgmt_port: Option<u16>,
|
||||
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the
|
||||
/// hosts page persists onto the matching saved host so it can wake it later. Empty if absent.
|
||||
pub mac: Vec<String>,
|
||||
}
|
||||
|
||||
/// One discovery update for the UI's advert map.
|
||||
@@ -81,6 +84,11 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
fp_hex: val("fp"),
|
||||
pair: val("pair"),
|
||||
mgmt_port: val("mgmt").parse().ok(),
|
||||
mac: val("mac")
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||
|
||||
+102
-10
@@ -551,6 +551,14 @@ struct Worker<'a> {
|
||||
/// switch / detach so a contact held at that moment doesn't stick. surface 0 = the legacy single
|
||||
/// touchpad, 1/2 = a Steam left/right pad.
|
||||
held_touches: std::collections::HashSet<(u8, u8)>,
|
||||
/// Per Steam-pad surface (index 0 = left/surface 1, 1 = right/surface 2): the last wire
|
||||
/// coordinates + whether a finger is on it. Pad CLICKS arrive as buttons with no position,
|
||||
/// so the click forward reuses the surface's live contact point.
|
||||
surface_last: [(i16, i16, bool); 2],
|
||||
/// Steam-pad clicks currently held (surface−1 indexed): keeps the click bit asserted
|
||||
/// through touch-motion frames (which would otherwise clear it host-side) and lets the
|
||||
/// flush lift a click held across detach/pad-switch.
|
||||
held_clicks: [bool; 2],
|
||||
last_accel: [i16; 3],
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
@@ -681,6 +689,24 @@ impl Worker<'_> {
|
||||
}
|
||||
*v = i32::MIN;
|
||||
}
|
||||
// Lift any Steam-pad click held at this moment — a click that survives a
|
||||
// detach/pad-switch would leave the host's pad pressed forever.
|
||||
for i in 0..2usize {
|
||||
if std::mem::take(&mut self.held_clicks[i]) {
|
||||
let (x, y, _) = self.surface_last[i];
|
||||
let _ = c.send_rich_input(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: (i as u8) + 1,
|
||||
finger: 0,
|
||||
touch: false,
|
||||
click: false,
|
||||
x,
|
||||
y,
|
||||
pressure: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.surface_last = [(0, 0, false); 2];
|
||||
// Lift any touchpad contact the host still believes is down (surface 0 = legacy pad).
|
||||
for (surface, finger) in self.held_touches.drain() {
|
||||
let rich = if surface == 0 {
|
||||
@@ -709,6 +735,8 @@ impl Worker<'_> {
|
||||
self.held_buttons.clear();
|
||||
self.last_axis = [i32::MIN; 6];
|
||||
self.held_touches.clear();
|
||||
self.held_clicks = [false; 2];
|
||||
self.surface_last = [(0, 0, false); 2];
|
||||
}
|
||||
// A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too.
|
||||
self.reset_chord();
|
||||
@@ -789,26 +817,29 @@ impl Worker<'_> {
|
||||
y: f32,
|
||||
active: bool,
|
||||
) {
|
||||
let Some(c) = self.attached.as_ref() else {
|
||||
let Some(c) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
let multi = self
|
||||
.open
|
||||
.as_ref()
|
||||
.filter(|(id, _)| *id == which)
|
||||
.map(|(_, p)| p.touchpads_count() >= 2)
|
||||
.unwrap_or(false);
|
||||
let multi = self.is_multi_touchpad(which);
|
||||
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
||||
let rich = if multi {
|
||||
let (wx, wy) = (
|
||||
(cx * 65535.0 - 32768.0) as i16,
|
||||
(cy * 65535.0 - 32768.0) as i16,
|
||||
);
|
||||
let i = (surface - 1).min(1) as usize;
|
||||
self.surface_last[i] = (wx, wy, active);
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: active,
|
||||
click: false,
|
||||
x: (cx * 65535.0 - 32768.0) as i16,
|
||||
y: (cy * 65535.0 - 32768.0) as i16,
|
||||
// The pad's physical click is a separate BUTTON event (see forward_click) —
|
||||
// carry the held state so a motion frame can't clear a click mid-press.
|
||||
click: self.held_clicks[i],
|
||||
x: wx,
|
||||
y: wy,
|
||||
pressure: 0,
|
||||
}
|
||||
} else {
|
||||
@@ -828,6 +859,57 @@ impl Worker<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The open pad has two touchpads (Steam Deck / Steam Controller) — the gate for the
|
||||
/// `TouchpadEx` surface encoding and the pad-click button re-route.
|
||||
fn is_multi_touchpad(&self, which: u32) -> bool {
|
||||
self.open
|
||||
.as_ref()
|
||||
.filter(|(id, _)| *id == which)
|
||||
.map(|(_, p)| p.touchpads_count() >= 2)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// SDL's Steam Deck mapping delivers the pad CLICKS as gamepad buttons — the generic
|
||||
/// `touchpad` button is the LEFT pad's click and `misc2` the RIGHT's (SDL_gamepad_db.h
|
||||
/// `touchpad:b17,misc2:b16`). They must NOT ride the button plane: it has no surface
|
||||
/// identity, and the host maps `BTN_TOUCHPAD` to the RIGHT pad (DualSense convention) —
|
||||
/// which is exactly "a left-pad click registers on the right pad". Only for the open
|
||||
/// multi-touchpad pad; a DualSense's single `touchpad` button stays a wire button.
|
||||
fn steam_click_surface(&self, which: u32, button: sdl3::gamepad::Button) -> Option<u8> {
|
||||
use sdl3::gamepad::Button;
|
||||
if !self.is_multi_touchpad(which) {
|
||||
return None;
|
||||
}
|
||||
match button {
|
||||
Button::Touchpad => Some(1),
|
||||
Button::Misc2 => Some(2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a Steam-pad click on the rich plane, bound to its surface. Click events carry
|
||||
/// no position, so reuse the surface's live contact point; a physical click implies
|
||||
/// contact, so `touch` stays asserted while the click is down even if the touch event
|
||||
/// hasn't arrived yet (event-order safety).
|
||||
fn forward_click(&mut self, surface: u8, down: bool) {
|
||||
let Some(c) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
let i = (surface - 1).min(1) as usize;
|
||||
self.held_clicks[i] = down;
|
||||
let (x, y, touching) = self.surface_last[i];
|
||||
let _ = c.send_rich_input(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger: 0,
|
||||
touch: touching || down,
|
||||
click: down,
|
||||
x,
|
||||
y,
|
||||
pressure: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Publish the pad list, active pad, and pin to the UI-facing mutexes.
|
||||
fn publish(&self) {
|
||||
let mut list: Vec<PadInfo> = self
|
||||
@@ -935,6 +1017,10 @@ impl Worker<'_> {
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonDown { which, button, .. } if active == Some(which) => {
|
||||
if let Some(surface) = self.steam_click_surface(which, button) {
|
||||
self.forward_click(surface, true);
|
||||
return;
|
||||
}
|
||||
let Some(c) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -945,6 +1031,10 @@ impl Worker<'_> {
|
||||
}
|
||||
}
|
||||
Event::ControllerButtonUp { which, button, .. } if active == Some(which) => {
|
||||
if let Some(surface) = self.steam_click_surface(which, button) {
|
||||
self.forward_click(surface, false);
|
||||
return;
|
||||
}
|
||||
let Some(c) = self.attached.clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -1158,6 +1248,8 @@ fn run(
|
||||
last_axis: [i32::MIN; 6],
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: std::collections::HashSet::new(),
|
||||
surface_last: [(0, 0, false); 2],
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
escape_tx: escape_tx.clone(),
|
||||
disconnect_tx: disconnect_tx.clone(),
|
||||
|
||||
@@ -106,6 +106,9 @@ pub fn start_session_with(
|
||||
}
|
||||
let mode = resolve_mode(&app);
|
||||
let s = app.settings.borrow();
|
||||
// The presenter raises this when hardware frames can't be displayed; the session pump
|
||||
// demotes the decoder to software (see `SessionParams::force_software`).
|
||||
let force_software = Arc::new(AtomicBool::new(false));
|
||||
let params = SessionParams {
|
||||
host: req.addr.clone(),
|
||||
port: req.port,
|
||||
@@ -125,6 +128,7 @@ pub fn start_session_with(
|
||||
pin,
|
||||
identity: app.identity.clone(),
|
||||
connect_timeout: opts.connect_timeout,
|
||||
force_software: force_software.clone(),
|
||||
};
|
||||
let inhibit = s.inhibit_shortcuts;
|
||||
let show_stats = s.show_stats;
|
||||
@@ -149,6 +153,7 @@ pub fn start_session_with(
|
||||
inhibit,
|
||||
show_stats,
|
||||
frames: Some(frames),
|
||||
force_software,
|
||||
waiting: opts.waiting,
|
||||
page: None,
|
||||
};
|
||||
@@ -198,6 +203,9 @@ struct SessionUi {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Decoded-frame receiver, handed to the stream page once on `Connected`.
|
||||
frames: Option<async_channel::Receiver<DecodedFrame>>,
|
||||
/// Shared with the session pump — the stream page's presenter raises it to demote
|
||||
/// the decoder to software when hardware frames can't be displayed.
|
||||
force_software: Arc<AtomicBool>,
|
||||
/// The "waiting for approval" dialog (request-access flow), dismissed on the first event.
|
||||
waiting: Option<adw::AlertDialog>,
|
||||
page: Option<crate::ui_stream::StreamPage>,
|
||||
@@ -259,6 +267,7 @@ impl SessionUi {
|
||||
window: self.app.window.clone(),
|
||||
connector,
|
||||
frames: self.frames.take().expect("Connected delivered once"),
|
||||
force_software: self.force_software.clone(),
|
||||
clock_offset_ns,
|
||||
escape_rx: self.app.gamepad.escape_events(),
|
||||
disconnect_rx: self.app.gamepad.disconnect_events(),
|
||||
@@ -280,6 +289,39 @@ impl SessionUi {
|
||||
if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream {
|
||||
self.app.window.fullscreen();
|
||||
}
|
||||
// A Deck streaming without its raw built-in controller is invisible degradation:
|
||||
// SDL sees only Steam's virtual X360 pad, so the right trackpad arrives at the
|
||||
// host as whatever Steam's template synthesizes (a right stick by default) and
|
||||
// the left trackpad, paddles and gyro not at all. The built-in pad can never
|
||||
// leave Steam Input ("Steam Controller" is always-required in the shortcut's
|
||||
// matrix — Disable Steam Input only affects other brands), so raw capture rides
|
||||
// the session-scoped Valve HIDAPI drivers + the cleared SDL device filter (see
|
||||
// `app::run`). The real 28DE:1205 identity enumerates shortly after attach —
|
||||
// check once that settles and say so, instead of streaming silently degraded.
|
||||
if crate::gamepad::is_steam_deck() {
|
||||
let app = self.app.clone();
|
||||
let stop = self.stop.clone();
|
||||
glib::timeout_add_seconds_local_once(4, move || {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return; // session already over
|
||||
}
|
||||
if app.gamepad.active().is_none_or(|pad| pad.steam_virtual) {
|
||||
tracing::warn!(
|
||||
"the Deck's raw built-in controller (28DE:1205) never enumerated \
|
||||
— only Steam's virtual pad is visible, so trackpads, paddles and \
|
||||
gyro can't be captured (sticks + buttons still work). Check the \
|
||||
startup log for SDL_GAMECONTROLLER_IGNORE_DEVICES and the \
|
||||
Settings controller list."
|
||||
);
|
||||
let toast = adw::Toast::new(
|
||||
"Steam is only exposing its virtual gamepad — trackpads, paddles \
|
||||
and gyro won't reach the game (sticks and buttons still work).",
|
||||
);
|
||||
toast.set_timeout(12);
|
||||
app.toasts.add_toast(toast);
|
||||
}
|
||||
});
|
||||
}
|
||||
self.page = Some(p);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ mod ui_stream;
|
||||
mod ui_trust;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod video;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod video_gl;
|
||||
|
||||
mod wol;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> gtk::glib::ExitCode {
|
||||
|
||||
@@ -43,6 +43,11 @@ pub struct SessionParams {
|
||||
/// connection until the operator clicks Approve in its console (so this must exceed the
|
||||
/// host's approval window — see `PENDING_APPROVAL_WAIT`).
|
||||
pub connect_timeout: Duration,
|
||||
/// Raised by the PRESENTER when hardware frames can't be displayed (GL converter init
|
||||
/// failed / dmabuf import rejected): the pump demotes the decoder to software and
|
||||
/// re-requests a keyframe. Decode itself succeeds in that state, so nothing else
|
||||
/// would recover — without this the stream stays black.
|
||||
pub force_software: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// The session pump's share of the unified stats window (design/stats-unification.md):
|
||||
@@ -238,6 +243,7 @@ fn pump(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let force_software = params.force_software.clone();
|
||||
// Audio is best-effort: a session without it still streams. Gamepads are the
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
@@ -331,6 +337,15 @@ fn pump(
|
||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
|
||||
}
|
||||
// The presenter's verdict: hardware frames can't be displayed (GL converter
|
||||
// init failed / dmabuf import rejected) — demote to software here, on the
|
||||
// decoder's own thread. Decode succeeds in that state, so the error-streak
|
||||
// demotion above never fires.
|
||||
if force_software.swap(false, Ordering::Relaxed) {
|
||||
if let Err(e) = decoder.force_software() {
|
||||
break Some(format!("software decoder rebuild: {e}"));
|
||||
}
|
||||
}
|
||||
// A decode error / VAAPI→software demotion asks for a fresh IDR: the infinite
|
||||
// GOP has no periodic keyframe, so a rebuilt/erroring decoder would stay
|
||||
// gray/frozen until an unrelated packet drop happened to request one. Route it
|
||||
|
||||
@@ -60,6 +60,11 @@ pub struct KnownHost {
|
||||
/// most-recent card with the accent bar. `default` so pre-existing stores load.
|
||||
#[serde(default)]
|
||||
pub last_used: Option<u64>,
|
||||
/// Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it
|
||||
/// was online, so we can wake it once it sleeps and stops advertising. `default` so
|
||||
/// pre-existing stores load; empty until first learned.
|
||||
#[serde(default)]
|
||||
pub mac: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
@@ -115,6 +120,10 @@ impl KnownHosts {
|
||||
if entry.last_used.is_some() {
|
||||
h.last_used = entry.last_used;
|
||||
}
|
||||
// Likewise a trust-decision upsert (which carries no MAC) must not wipe learned MACs.
|
||||
if !entry.mac.is_empty() {
|
||||
h.mac = entry.mac;
|
||||
}
|
||||
} else {
|
||||
self.hosts.push(entry);
|
||||
}
|
||||
@@ -132,10 +141,33 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
||||
fp_hex: fp_hex.to_string(),
|
||||
paired,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
});
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host
|
||||
/// is online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so
|
||||
/// the hosts page can call it on every discovery tick without churning the store.
|
||||
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||
if mac.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = known
|
||||
.hosts
|
||||
.iter_mut()
|
||||
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if h.mac == mac {
|
||||
return;
|
||||
}
|
||||
h.mac = mac.to_vec();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// Stamp "now" as this host's last successful connect (drives the hosts page's
|
||||
/// most-recent accent). No-op when the fingerprint isn't stored.
|
||||
pub fn touch_last_used(fp_hex: &str) {
|
||||
|
||||
@@ -29,6 +29,9 @@ pub struct ConnectRequest {
|
||||
/// `("steam:570", "Dota 2")`) — set by the library page's card activation; the id
|
||||
/// rides the Hello and the name titles the stream page. `None` = plain desktop session.
|
||||
pub launch: Option<(String, String)>,
|
||||
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert). Used to send a
|
||||
/// magic packet before connecting to an offline host. Empty when none is known.
|
||||
pub mac: Vec<String>,
|
||||
}
|
||||
|
||||
impl ConnectRequest {
|
||||
@@ -314,6 +317,14 @@ fn rebuild(state: &Rc<State>) {
|
||||
state.saved_flow.remove_all();
|
||||
for k in &known.hosts {
|
||||
let online = adverts.values().any(|a| matches(k, a));
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake it
|
||||
// once it sleeps and stops advertising (no-op / no disk write when unchanged).
|
||||
if let Some(a) = adverts
|
||||
.values()
|
||||
.find(|a| matches(k, a) && !a.mac.is_empty())
|
||||
{
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
let recent = most_recent.as_deref() == Some(k.fp_hex.as_str());
|
||||
state
|
||||
.saved_flow
|
||||
@@ -421,6 +432,7 @@ fn saved_card(
|
||||
// connect; TOFU eligibility is irrelevant.
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
mac: k.mac.clone(),
|
||||
};
|
||||
|
||||
// Presence pip + spelled-out state, then the trust pill.
|
||||
@@ -492,11 +504,24 @@ fn saved_card(
|
||||
Box::new(move || forget_dialog(&state, &fp, &name)),
|
||||
);
|
||||
}
|
||||
{
|
||||
// Explicit "just wake it" (the tap-to-connect already auto-wakes an offline host).
|
||||
let mac = k.mac.clone();
|
||||
let addr = k.addr.clone();
|
||||
add(
|
||||
"wake",
|
||||
Box::new(move || crate::wol::wake(&mac, addr.parse().ok())),
|
||||
);
|
||||
}
|
||||
overlay.insert_action_group("card", Some(&actions));
|
||||
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Pair with PIN…"), Some("card.pair"));
|
||||
menu.append(Some("Test network speed…"), Some("card.speed"));
|
||||
// Offer an explicit wake only when the host is offline and we actually have a MAC to target.
|
||||
if !online && !k.mac.is_empty() {
|
||||
menu.append(Some("Wake host"), Some("card.wake"));
|
||||
}
|
||||
// Experimental (Preferences gate, Apple parity): browse the host's game library. The
|
||||
// item is offered on every saved card — an unpaired host answers with the friendly
|
||||
// "not paired" error state rather than the entry hiding itself.
|
||||
@@ -521,7 +546,16 @@ fn saved_card(
|
||||
overlay.add_controller(right_click);
|
||||
|
||||
let on_connect = state.cbs.on_connect.clone();
|
||||
child.connect_activate(move |_| on_connect(req.clone()));
|
||||
// Auto-wake: if the host wasn't advertising when this card was built and we have a MAC, fire a
|
||||
// magic packet before connecting — the connect's own retry/timeout gives a woken host time to
|
||||
// come up. A host that's genuinely off/unreachable then fails the connect as before.
|
||||
let wake_first = !online && !req.mac.is_empty();
|
||||
child.connect_activate(move |_| {
|
||||
if wake_first {
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
}
|
||||
on_connect(req.clone());
|
||||
});
|
||||
child
|
||||
}
|
||||
|
||||
@@ -539,6 +573,7 @@ fn discovered_card(
|
||||
// required/empty means mandatory PIN.
|
||||
pair_optional: a.pair == "optional",
|
||||
launch: None,
|
||||
mac: a.mac.clone(),
|
||||
};
|
||||
|
||||
let status = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
@@ -674,6 +709,7 @@ fn add_host_dialog(state: &Rc<State>) {
|
||||
// Manual entry carries no advertised policy — never eligible for TOFU.
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
mac: Vec::new(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +111,10 @@ pub struct StreamPageArgs {
|
||||
pub window: adw::ApplicationWindow,
|
||||
pub connector: Arc<NativeClient>,
|
||||
pub frames: async_channel::Receiver<DecodedFrame>,
|
||||
/// Shared with the session pump: the presenter raises it when hardware frames can't
|
||||
/// be displayed (GL converter init failed / dmabuf import rejected) and the pump
|
||||
/// demotes the decoder to software.
|
||||
pub force_software: Arc<AtomicBool>,
|
||||
/// Host-clock offset from the session's clock handshake — added to the local wall
|
||||
/// clock to express paintable-set time in the host's capture clock (present latency).
|
||||
pub clock_offset_ns: i64,
|
||||
@@ -253,6 +257,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||
window,
|
||||
connector,
|
||||
frames,
|
||||
force_software,
|
||||
clock_offset_ns,
|
||||
escape_rx,
|
||||
disconnect_rx,
|
||||
@@ -291,6 +296,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||
spawn_frame_consumer(
|
||||
&w.picture,
|
||||
frames,
|
||||
force_software,
|
||||
clock_offset_ns,
|
||||
presented.clone(),
|
||||
hdr.clone(),
|
||||
@@ -584,9 +590,33 @@ impl ColorStateCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// How hardware (dmabuf) frames reach the screen.
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
enum HwPresent {
|
||||
/// Hand the NV12 dmabuf straight to `GdkDmabufTexture` — GTK (or the compositor via
|
||||
/// offload) imports + converts. The desktop default: subsurface/scan-out eligible.
|
||||
Direct,
|
||||
/// Convert in-process first (`video_gl`): own EGL import + own YUV→RGB shader → RGBA
|
||||
/// `GdkGLTexture`. The Steam Deck default — GTK's tiled-NV12 import is broken there
|
||||
/// (Mesa ≥ 25.1 tiled VCN export), and this is the Moonlight-proven route around it.
|
||||
Gl,
|
||||
}
|
||||
|
||||
impl HwPresent {
|
||||
fn pick() -> HwPresent {
|
||||
match std::env::var("PUNKTFUNK_PRESENT").ok().as_deref() {
|
||||
Some("direct") => HwPresent::Direct,
|
||||
Some("gl") => HwPresent::Gl,
|
||||
_ if crate::gamepad::is_steam_deck() => HwPresent::Gl,
|
||||
_ => HwPresent::Direct,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_frame_consumer(
|
||||
picture: >k::Picture,
|
||||
frames: async_channel::Receiver<DecodedFrame>,
|
||||
force_software: Arc<AtomicBool>,
|
||||
clock_offset_ns: i64,
|
||||
presented_stats: Rc<PresentedStats>,
|
||||
hdr: Rc<Cell<bool>>,
|
||||
@@ -599,6 +629,11 @@ fn spawn_frame_consumer(
|
||||
// (SDR↔HDR flip) just rebuilds once.
|
||||
let mut yuv_state = ColorStateCache::default();
|
||||
let mut rgb_state = ColorStateCache::default();
|
||||
let hw_present = HwPresent::pick();
|
||||
// Lazy (first dmabuf frame) so software-decode sessions never touch EGL. `Err` after
|
||||
// a failed init = don't retry every frame.
|
||||
let mut gl_conv: Option<Result<crate::video_gl::GlConverter, ()>> = None;
|
||||
let mut gl_fails = 0u32;
|
||||
glib::spawn_future_local(async move {
|
||||
// Window samples (µs): end-to-end capture→displayed (host-clock corrected) and
|
||||
// the client-local display stage decoded→displayed.
|
||||
@@ -646,6 +681,39 @@ fn spawn_frame_consumer(
|
||||
picture.set_paintable(Some(&tex));
|
||||
presented = true;
|
||||
}
|
||||
DecodedImage::Dmabuf(d) if hw_present == HwPresent::Gl => {
|
||||
// In-process conversion (see `HwPresent::Gl`). Init once; a failed
|
||||
// init or a streak of convert failures demotes the DECODER to
|
||||
// software via the shared flag — never fall back to the direct path
|
||||
// here, it's the known-broken one on this hardware.
|
||||
let conv = gl_conv.get_or_insert_with(|| {
|
||||
crate::video_gl::GlConverter::new(&picture).map_err(|e| {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"GL presenter unavailable — demoting to software decode");
|
||||
})
|
||||
});
|
||||
match conv {
|
||||
Ok(c) => {
|
||||
let color = d.color;
|
||||
match c.convert(d, rgb_state.get(color, true).as_ref()) {
|
||||
Ok(tex) => {
|
||||
gl_fails = 0;
|
||||
picture.set_paintable(Some(&tex));
|
||||
presented = true;
|
||||
}
|
||||
Err(e) => {
|
||||
gl_fails += 1;
|
||||
tracing::warn!(error = %format!("{e:#}"), fails = gl_fails,
|
||||
"GL convert failed");
|
||||
if gl_fails >= 3 {
|
||||
force_software.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(()) => force_software.store(true, Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
DecodedImage::Dmabuf(d) => {
|
||||
let mut b = gdk::DmabufTextureBuilder::new()
|
||||
.set_display(&picture.display())
|
||||
|
||||
@@ -187,6 +187,12 @@ impl Decoder {
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| pref.to_string());
|
||||
// Deck note: `auto` means VAAPI here too. GTK's tiled-NV12 dmabuf import is broken on
|
||||
// the Deck (Mesa ≥ 25.1 exports VCN surfaces TILED; artifacts/gray/washed-out), but the
|
||||
// presenter routes Deck frames through the in-process GL converter (`video_gl`) instead
|
||||
// of GdkDmabufTexture — and if THAT can't initialize, it demotes this decoder to
|
||||
// software mid-session via [`Decoder::force_software`]. The broken direct path is never
|
||||
// the fallback.
|
||||
if choice != "software" {
|
||||
match VaapiDecoder::new(codec_id) {
|
||||
Ok(v) => {
|
||||
@@ -220,6 +226,21 @@ impl Decoder {
|
||||
std::mem::take(&mut self.want_keyframe)
|
||||
}
|
||||
|
||||
/// Demote to software decode on the PRESENTER's verdict (dmabuf presentation impossible:
|
||||
/// GL converter init failed, texture import rejected). Decode itself succeeds in that
|
||||
/// state, so the error-streak demotion never fires — without this the stream would stay
|
||||
/// black forever. No-op when already software.
|
||||
pub fn force_software(&mut self) -> Result<()> {
|
||||
if matches!(self.backend, Backend::Software(_)) {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::warn!("presenter can't display hardware frames — demoting to software decode");
|
||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
||||
self.vaapi_fails = 0;
|
||||
self.want_keyframe = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 re-requests an IDR and retries the hardware
|
||||
@@ -456,6 +477,14 @@ impl VaapiDecoder {
|
||||
(*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
|
||||
|
||||
// The presenter holds mapped surfaces PAST receive_frame (the paintable's
|
||||
// current texture + the newest frame in flight each pin one until GDK's
|
||||
// release func) — surfaces libavcodec doesn't know are missing from its
|
||||
// fixed-size VAAPI pool. Without headroom the decoder can recycle a surface
|
||||
// the renderer is still sampling (intermittent block corruption) or fail
|
||||
// allocation under scheduling jitter.
|
||||
(*ctx).extra_hw_frames = 4;
|
||||
let r = ffi::avcodec_open2(ctx, codec, ptr::null_mut());
|
||||
if r < 0 {
|
||||
let mut ctx = ctx;
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
//! VAAPI dmabuf → RGBA GL texture converter — the Steam Deck's hardware-decode presenter.
|
||||
//!
|
||||
//! The direct path hands the decoder's NV12 dmabuf (fds + AMD tiled modifier) to
|
||||
//! `GdkDmabufTexture` and lets GTK import + color-convert it. On the Deck that renders
|
||||
//! corrupt/gray/washed-out: since Mesa 25.1 radeonsi exports VCN decode surfaces TILED, and
|
||||
//! GTK's tiled-NV12 import mishandles the layout (the Flatpak runtime's Mesa drives both
|
||||
//! sides). Moonlight-qt and mpv are clean on the same box because they never let a toolkit
|
||||
//! near the YUV: they import the dmabuf into their own EGL context and convert with their
|
||||
//! own shader. This module is that architecture for the GTK client:
|
||||
//!
|
||||
//! VAAPI frame → per-plane `EGLImage`s (R8 luma + GR88 chroma, modifier passed through)
|
||||
//! → our YUV→RGB shader (matrix + range from the stream's real CICP signaling)
|
||||
//! → an RGBA texture in a `GdkGLContext`-shared context → `GdkGLTexture` (fence-synced).
|
||||
//!
|
||||
//! GTK then composites a plain RGBA texture — no YUV format negotiation, no modifier
|
||||
//! handling, no compositor CSC. Same-Mesa export/import is the exact proven-working path.
|
||||
//! Everything runs on the GTK main thread (the converter is driven by the frame consumer);
|
||||
//! one 800p–4K NV12→RGB pass is sub-millisecond GPU work.
|
||||
//!
|
||||
//! Failure at any step (GLX-backed GDK context, missing EGL extensions, import rejection)
|
||||
//! is surfaced as an error — the caller falls back to software decode, never to the broken
|
||||
//! direct path.
|
||||
|
||||
use crate::video::{ColorDesc, DmabufFrame};
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use gtk::{gdk, prelude::*};
|
||||
use khronos_egl as egl;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------
|
||||
const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270;
|
||||
// eglCreateImageKHR takes 32-bit EGLint attribs (the core-1.5 eglCreateImage variant is the
|
||||
// one with pointer-sized EGLAttrib) — using the wrong width feeds the driver garbage pairs.
|
||||
const EGL_LINUX_DRM_FOURCC_EXT: i32 = 0x3271;
|
||||
const EGL_DMA_BUF_PLANE0_FD_EXT: i32 = 0x3272;
|
||||
const EGL_DMA_BUF_PLANE0_OFFSET_EXT: i32 = 0x3273;
|
||||
const EGL_DMA_BUF_PLANE0_PITCH_EXT: i32 = 0x3274;
|
||||
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: i32 = 0x3443;
|
||||
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: i32 = 0x3444;
|
||||
const EGL_WIDTH: i32 = 0x3057;
|
||||
const EGL_HEIGHT: i32 = 0x3056;
|
||||
const EGL_NONE: i32 = 0x3038;
|
||||
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
|
||||
|
||||
/// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when
|
||||
/// the Linux host grows 10-bit.
|
||||
const DRM_FORMAT_NV12: u32 = 0x3231_564e;
|
||||
const DRM_FORMAT_R8: u32 = 0x2020_3852;
|
||||
const DRM_FORMAT_GR88: u32 = 0x3838_5247;
|
||||
|
||||
// --- The slice of GL we use (loaded via eglGetProcAddress — Mesa/NVIDIA both implement
|
||||
// --- EGL_KHR_get_all_proc_addresses, so core functions resolve too) ----------------------
|
||||
const GL_TEXTURE_2D: u32 = 0x0DE1;
|
||||
const GL_TEXTURE0: u32 = 0x84C0;
|
||||
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
|
||||
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
|
||||
const GL_TEXTURE_WRAP_S: u32 = 0x2802;
|
||||
const GL_TEXTURE_WRAP_T: u32 = 0x2803;
|
||||
const GL_LINEAR: i32 = 0x2601;
|
||||
const GL_CLAMP_TO_EDGE: i32 = 0x812F;
|
||||
const GL_FRAMEBUFFER: u32 = 0x8D40;
|
||||
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
|
||||
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
|
||||
const GL_RGBA8: u32 = 0x8058;
|
||||
const GL_RGBA: u32 = 0x1908;
|
||||
const GL_UNSIGNED_BYTE: u32 = 0x1401;
|
||||
const GL_TRIANGLES: u32 = 0x0004;
|
||||
const GL_VERTEX_SHADER: u32 = 0x8B31;
|
||||
const GL_FRAGMENT_SHADER: u32 = 0x8B30;
|
||||
const GL_COMPILE_STATUS: u32 = 0x8B81;
|
||||
const GL_LINK_STATUS: u32 = 0x8B82;
|
||||
const GL_SYNC_GPU_COMMANDS_COMPLETE: u32 = 0x9117;
|
||||
|
||||
macro_rules! gl_fns {
|
||||
($($name:ident : fn($($arg:ty),*) $(-> $ret:ty)?;)*) => {
|
||||
#[allow(non_snake_case)]
|
||||
struct GlFns { $($name: unsafe extern "C" fn($($arg),*) $(-> $ret)?,)* }
|
||||
impl GlFns {
|
||||
#[allow(non_snake_case)]
|
||||
fn load(egl: &Egl) -> Result<GlFns> {
|
||||
$(
|
||||
// eglGetProcAddress returns a plain fn pointer; the signature is fixed
|
||||
// by the GL spec for each name.
|
||||
let $name = egl
|
||||
.get_proc_address(concat!("gl", stringify!($name)))
|
||||
.ok_or_else(|| anyhow!(concat!("gl", stringify!($name), " unresolvable")))?;
|
||||
)*
|
||||
// SAFETY: each pointer came from eglGetProcAddress for exactly that GL entry
|
||||
// point; the transmute only fixes the signature the spec defines for it.
|
||||
unsafe {
|
||||
Ok(GlFns { $($name: std::mem::transmute::<extern "system" fn(), unsafe extern "C" fn($($arg),*) $(-> $ret)?>($name),)* })
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
gl_fns! {
|
||||
GenTextures: fn(i32, *mut u32);
|
||||
DeleteTextures: fn(i32, *const u32);
|
||||
BindTexture: fn(u32, u32);
|
||||
TexParameteri: fn(u32, u32, i32);
|
||||
TexImage2D: fn(u32, i32, i32, i32, i32, i32, u32, u32, *const c_void);
|
||||
ActiveTexture: fn(u32);
|
||||
EGLImageTargetTexture2DOES: fn(u32, *const c_void);
|
||||
GenFramebuffers: fn(i32, *mut u32);
|
||||
DeleteFramebuffers: fn(i32, *const u32);
|
||||
BindFramebuffer: fn(u32, u32);
|
||||
FramebufferTexture2D: fn(u32, u32, u32, u32, i32);
|
||||
CheckFramebufferStatus: fn(u32) -> u32;
|
||||
Viewport: fn(i32, i32, i32, i32);
|
||||
CreateShader: fn(u32) -> u32;
|
||||
ShaderSource: fn(u32, i32, *const *const u8, *const i32);
|
||||
CompileShader: fn(u32);
|
||||
GetShaderiv: fn(u32, u32, *mut i32);
|
||||
GetShaderInfoLog: fn(u32, i32, *mut i32, *mut u8);
|
||||
DeleteShader: fn(u32);
|
||||
CreateProgram: fn() -> u32;
|
||||
AttachShader: fn(u32, u32);
|
||||
LinkProgram: fn(u32);
|
||||
GetProgramiv: fn(u32, u32, *mut i32);
|
||||
UseProgram: fn(u32);
|
||||
GetUniformLocation: fn(u32, *const u8) -> i32;
|
||||
Uniform1i: fn(i32, i32);
|
||||
Uniform3fv: fn(i32, i32, *const f32);
|
||||
UniformMatrix3fv: fn(i32, i32, u8, *const f32);
|
||||
GenVertexArrays: fn(i32, *mut u32);
|
||||
DeleteVertexArrays: fn(i32, *const u32);
|
||||
DeleteProgram: fn(u32);
|
||||
BindVertexArray: fn(u32);
|
||||
DrawArrays: fn(u32, i32, i32);
|
||||
FenceSync: fn(u32, u32) -> *const c_void;
|
||||
DeleteSync: fn(*const c_void);
|
||||
Flush: fn();
|
||||
GetError: fn() -> u32;
|
||||
}
|
||||
|
||||
type Egl = egl::DynamicInstance<egl::EGL1_4>;
|
||||
type EglCreateImageKhr = unsafe extern "C" fn(
|
||||
*mut c_void, // EGLDisplay
|
||||
*mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf)
|
||||
egl::Enum,
|
||||
*mut c_void, // EGLClientBuffer (null for dmabuf)
|
||||
*const i32, // EGLint attrib list (KHR variant — NOT pointer-sized EGLAttrib)
|
||||
) -> *const c_void;
|
||||
type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean;
|
||||
|
||||
/// The YUV→RGB conversion for a stream's CICP signaling: `rgb = mat * (yuv + off)`, with the
|
||||
/// limited/full-range expansion folded in. `mat` is column-major (GL convention). Pure —
|
||||
/// unit-tested against the reference white/black points.
|
||||
pub fn yuv_to_rgb(desc: ColorDesc) -> ([f32; 9], [f32; 3]) {
|
||||
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
||||
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
|
||||
let (kr, kb) = match desc.matrix {
|
||||
5 | 6 => (0.299, 0.114),
|
||||
9 | 10 => (0.2627, 0.0593),
|
||||
_ => (0.2126, 0.0722),
|
||||
};
|
||||
let kg = 1.0 - kr - kb;
|
||||
let (sy, oy, sc) = if desc.full_range {
|
||||
(1.0f32, 0.0f32, 1.0f32)
|
||||
} else {
|
||||
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
|
||||
};
|
||||
let (kr, kb, kg) = (kr as f32, kb as f32, kg as f32);
|
||||
// Column-major: columns are the Y, U, V contributions to (R, G, B).
|
||||
let mat = [
|
||||
sy,
|
||||
sy,
|
||||
sy, // Y column
|
||||
0.0,
|
||||
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||
2.0 * (1.0 - kb) * sc, // U column
|
||||
2.0 * (1.0 - kr) * sc,
|
||||
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||
0.0, // V column
|
||||
];
|
||||
(mat, [oy, -0.5, -0.5])
|
||||
}
|
||||
|
||||
/// An output texture GTK has released, waiting to be recycled (or its fence deleted). GL
|
||||
/// objects can only be touched with our context current, so releases park here and
|
||||
/// [`GlConverter::convert`] drains them.
|
||||
struct Retired {
|
||||
tex: u32,
|
||||
sync: usize, // GLsync as usize — the release closure must be Send
|
||||
size: (u32, u32),
|
||||
}
|
||||
|
||||
pub struct GlConverter {
|
||||
ctx: gdk::GLContext,
|
||||
egl: Egl,
|
||||
egl_display: *mut c_void,
|
||||
create_image: EglCreateImageKhr,
|
||||
destroy_image: EglDestroyImageKhr,
|
||||
gl: GlFns,
|
||||
program: u32,
|
||||
vao: u32,
|
||||
fbo: u32,
|
||||
u_mat: i32,
|
||||
u_off: i32,
|
||||
/// Uniforms match this signaling; a change (mid-stream SDR↔HDR) re-uploads them.
|
||||
uniforms_for: Option<ColorDesc>,
|
||||
/// Free output textures + fences returned by GTK's release funcs (shared with the
|
||||
/// `Send` release closures; drained/recycled at each convert).
|
||||
retired: Arc<Mutex<Vec<Retired>>>,
|
||||
}
|
||||
|
||||
impl GlConverter {
|
||||
/// Build against the widget's display. Must run on the GTK main thread; fails cleanly
|
||||
/// on a GLX-backed GDK context or missing EGL dmabuf-import extensions (the caller
|
||||
/// falls back to software decode).
|
||||
pub fn new(widget: &impl IsA<gtk::Widget>) -> Result<GlConverter> {
|
||||
let display = widget.display();
|
||||
let ctx = display.create_gl_context().context("create GdkGLContext")?;
|
||||
ctx.realize().context("realize GdkGLContext")?;
|
||||
ctx.make_current();
|
||||
|
||||
// SAFETY (whole block): the GdkGLContext is current on this thread, so EGL/GL
|
||||
// queries and object creation target it; pointers are only used while it lives.
|
||||
unsafe {
|
||||
let egl = Egl::load_required().context("dlopen libEGL")?;
|
||||
let egl_display = egl
|
||||
.get_current_display()
|
||||
.ok_or_else(|| anyhow!("GDK context is not EGL-backed (GLX?)"))?;
|
||||
let exts = egl
|
||||
.query_string(Some(egl_display), egl::EXTENSIONS)
|
||||
.context("EGL_EXTENSIONS")?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
for need in ["EGL_EXT_image_dma_buf_import", "EGL_KHR_image_base"] {
|
||||
if !exts.contains(need) {
|
||||
bail!("EGL lacks {need}");
|
||||
}
|
||||
}
|
||||
// Tiled surfaces carry an explicit modifier — without the _modifiers extension
|
||||
// the import would silently assume implied/linear and sample garbage.
|
||||
if !exts.contains("EGL_EXT_image_dma_buf_import_modifiers") {
|
||||
bail!("EGL lacks EGL_EXT_image_dma_buf_import_modifiers");
|
||||
}
|
||||
let create_image: EglCreateImageKhr =
|
||||
std::mem::transmute::<extern "system" fn(), EglCreateImageKhr>(
|
||||
egl.get_proc_address("eglCreateImageKHR")
|
||||
.ok_or_else(|| anyhow!("no eglCreateImageKHR"))?,
|
||||
);
|
||||
let destroy_image: EglDestroyImageKhr =
|
||||
std::mem::transmute::<extern "system" fn(), EglDestroyImageKhr>(
|
||||
egl.get_proc_address("eglDestroyImageKHR")
|
||||
.ok_or_else(|| anyhow!("no eglDestroyImageKHR"))?,
|
||||
);
|
||||
let gl = GlFns::load(&egl)?;
|
||||
|
||||
let es = ctx.api().contains(gdk::GLAPI::GLES);
|
||||
let program = build_program(&gl, es)?;
|
||||
(gl.UseProgram)(program);
|
||||
let u_mat = (gl.GetUniformLocation)(program, c"u_mat".as_ptr() as *const u8);
|
||||
let u_off = (gl.GetUniformLocation)(program, c"u_off".as_ptr() as *const u8);
|
||||
let u_y = (gl.GetUniformLocation)(program, c"u_y".as_ptr() as *const u8);
|
||||
let u_c = (gl.GetUniformLocation)(program, c"u_c".as_ptr() as *const u8);
|
||||
(gl.Uniform1i)(u_y, 0);
|
||||
(gl.Uniform1i)(u_c, 1);
|
||||
let mut vao = 0u32;
|
||||
(gl.GenVertexArrays)(1, &mut vao);
|
||||
let mut fbo = 0u32;
|
||||
(gl.GenFramebuffers)(1, &mut fbo);
|
||||
|
||||
tracing::info!(
|
||||
gles = es,
|
||||
"GL presenter ready — VAAPI dmabufs convert in-process (own EGL import + shader)"
|
||||
);
|
||||
Ok(GlConverter {
|
||||
ctx,
|
||||
egl,
|
||||
egl_display: egl_display.as_ptr(),
|
||||
create_image,
|
||||
destroy_image,
|
||||
gl,
|
||||
program,
|
||||
vao,
|
||||
fbo,
|
||||
u_mat,
|
||||
u_off,
|
||||
uniforms_for: None,
|
||||
retired: Arc::new(Mutex::new(Vec::new())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert one decoded frame into an RGBA `GdkTexture`. The source surface (guard) is
|
||||
/// held until GTK releases the output texture — the GPU read is long finished by then.
|
||||
/// `color_state` tags the output (full-range RGB, transfer left baked — same semantics
|
||||
/// as the software path's tagged `GdkMemoryTexture`); `None` = untagged sRGB.
|
||||
pub fn convert(
|
||||
&mut self,
|
||||
frame: DmabufFrame,
|
||||
color_state: Option<&gdk::ColorState>,
|
||||
) -> Result<gdk::Texture> {
|
||||
if frame.fourcc != DRM_FORMAT_NV12 {
|
||||
bail!("GL presenter handles NV12 only (got {:#x})", frame.fourcc);
|
||||
}
|
||||
if frame.planes.len() < 2 {
|
||||
bail!("NV12 needs 2 planes (got {})", frame.planes.len());
|
||||
}
|
||||
self.ctx.make_current();
|
||||
let gl = &self.gl;
|
||||
|
||||
// SAFETY (whole body): our context is current; every GL/EGL object created here is
|
||||
// either destroyed before return or owned by the pool/release machinery.
|
||||
unsafe {
|
||||
// Recycle what GTK released since last frame (GL objects need the context, so
|
||||
// the release closures only park entries — this is where they die/revive).
|
||||
let size = (frame.width, frame.height);
|
||||
let mut out_tex = 0u32;
|
||||
{
|
||||
let mut retired = self.retired.lock().unwrap();
|
||||
retired.retain_mut(|r| {
|
||||
if r.sync != 0 {
|
||||
(gl.DeleteSync)(r.sync as *const c_void);
|
||||
r.sync = 0;
|
||||
}
|
||||
if out_tex == 0 && r.size == size {
|
||||
out_tex = r.tex;
|
||||
false
|
||||
} else if r.size != size {
|
||||
(gl.DeleteTextures)(1, &r.tex); // stale size (mode change)
|
||||
false
|
||||
} else {
|
||||
true // spare same-size texture for a later frame
|
||||
}
|
||||
});
|
||||
}
|
||||
if out_tex == 0 {
|
||||
(gl.GenTextures)(1, &mut out_tex);
|
||||
(gl.BindTexture)(GL_TEXTURE_2D, out_tex);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
(gl.TexImage2D)(
|
||||
GL_TEXTURE_2D,
|
||||
0,
|
||||
GL_RGBA8 as i32,
|
||||
frame.width as i32,
|
||||
frame.height as i32,
|
||||
0,
|
||||
GL_RGBA,
|
||||
GL_UNSIGNED_BYTE,
|
||||
std::ptr::null(),
|
||||
);
|
||||
}
|
||||
|
||||
// Import both planes with the surface's modifier — exactly the layer-wise
|
||||
// import Moonlight/mpv drive on this hardware.
|
||||
let y = &frame.planes[0];
|
||||
let c = &frame.planes[1];
|
||||
let img_y =
|
||||
self.plane_image(frame.width, frame.height, DRM_FORMAT_R8, y, frame.modifier)?;
|
||||
let img_c = match self.plane_image(
|
||||
frame.width.div_ceil(2),
|
||||
frame.height.div_ceil(2),
|
||||
DRM_FORMAT_GR88,
|
||||
c,
|
||||
frame.modifier,
|
||||
) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
(self.destroy_image)(self.egl_display, img_y);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let mut planes = [0u32; 2];
|
||||
(gl.GenTextures)(2, planes.as_mut_ptr());
|
||||
for (tex, img) in planes.iter().zip([img_y, img_c]) {
|
||||
(gl.BindTexture)(GL_TEXTURE_2D, *tex);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
(gl.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
(gl.EGLImageTargetTexture2DOES)(GL_TEXTURE_2D, img);
|
||||
}
|
||||
|
||||
(gl.UseProgram)(self.program);
|
||||
if self.uniforms_for != Some(frame.color) {
|
||||
let (mat, off) = yuv_to_rgb(frame.color);
|
||||
(gl.UniformMatrix3fv)(self.u_mat, 1, 0, mat.as_ptr());
|
||||
(gl.Uniform3fv)(self.u_off, 1, off.as_ptr());
|
||||
self.uniforms_for = Some(frame.color);
|
||||
}
|
||||
(gl.BindFramebuffer)(GL_FRAMEBUFFER, self.fbo);
|
||||
(gl.FramebufferTexture2D)(
|
||||
GL_FRAMEBUFFER,
|
||||
GL_COLOR_ATTACHMENT0,
|
||||
GL_TEXTURE_2D,
|
||||
out_tex,
|
||||
0,
|
||||
);
|
||||
let status = (gl.CheckFramebufferStatus)(GL_FRAMEBUFFER);
|
||||
if status != GL_FRAMEBUFFER_COMPLETE {
|
||||
(gl.BindFramebuffer)(GL_FRAMEBUFFER, 0);
|
||||
(gl.DeleteTextures)(2, planes.as_ptr());
|
||||
(self.destroy_image)(self.egl_display, img_y);
|
||||
(self.destroy_image)(self.egl_display, img_c);
|
||||
(gl.DeleteTextures)(1, &out_tex);
|
||||
bail!("FBO incomplete ({status:#x})");
|
||||
}
|
||||
(gl.Viewport)(0, 0, frame.width as i32, frame.height as i32);
|
||||
(gl.BindVertexArray)(self.vao);
|
||||
(gl.ActiveTexture)(GL_TEXTURE0);
|
||||
(gl.BindTexture)(GL_TEXTURE_2D, planes[0]);
|
||||
(gl.ActiveTexture)(GL_TEXTURE0 + 1);
|
||||
(gl.BindTexture)(GL_TEXTURE_2D, planes[1]);
|
||||
(gl.DrawArrays)(GL_TRIANGLES, 0, 3);
|
||||
(gl.BindFramebuffer)(GL_FRAMEBUFFER, 0);
|
||||
|
||||
let sync = (gl.FenceSync)(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
|
||||
(gl.Flush)();
|
||||
// The draw is queued: plane textures + images can go now (the driver keeps the
|
||||
// underlying buffers alive until the queued commands execute).
|
||||
(gl.DeleteTextures)(2, planes.as_ptr());
|
||||
(self.destroy_image)(self.egl_display, img_y);
|
||||
(self.destroy_image)(self.egl_display, img_c);
|
||||
|
||||
let err = (gl.GetError)();
|
||||
if err != 0 {
|
||||
(gl.DeleteTextures)(1, &out_tex);
|
||||
bail!("GL error {err:#x} during convert");
|
||||
}
|
||||
|
||||
let mut b = gdk::GLTextureBuilder::new()
|
||||
.set_context(Some(&self.ctx))
|
||||
.set_id(out_tex)
|
||||
.set_width(frame.width as i32)
|
||||
.set_height(frame.height as i32)
|
||||
.set_format(gdk::MemoryFormat::R8g8b8a8)
|
||||
.set_sync(Some(sync));
|
||||
if let Some(state) = color_state {
|
||||
b = b.set_color_state(state);
|
||||
}
|
||||
let retired = self.retired.clone();
|
||||
let guard = frame.guard;
|
||||
let sync_bits = sync as usize; // GLsync as usize — the closure must be Send
|
||||
let texture = b.build_with_release_func(move || {
|
||||
drop(guard); // the decoder surface outlived every GPU read of it
|
||||
retired.lock().unwrap().push(Retired {
|
||||
tex: out_tex,
|
||||
sync: sync_bits,
|
||||
size,
|
||||
});
|
||||
});
|
||||
Ok(texture)
|
||||
}
|
||||
}
|
||||
|
||||
/// One single-plane `EGLImage` over a dmabuf plane (R8 luma / GR88 chroma), modifier
|
||||
/// passed explicitly.
|
||||
///
|
||||
/// # Safety
|
||||
/// `self.ctx` must be current; the fd stays owned by the caller (EGL dups internally).
|
||||
unsafe fn plane_image(
|
||||
&self,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fourcc: u32,
|
||||
plane: &crate::video::DmabufPlane,
|
||||
modifier: u64,
|
||||
) -> Result<*const c_void> {
|
||||
let mut attribs = vec![
|
||||
EGL_WIDTH,
|
||||
width as i32,
|
||||
EGL_HEIGHT,
|
||||
height as i32,
|
||||
EGL_LINUX_DRM_FOURCC_EXT,
|
||||
fourcc as i32,
|
||||
EGL_DMA_BUF_PLANE0_FD_EXT,
|
||||
plane.fd,
|
||||
EGL_DMA_BUF_PLANE0_OFFSET_EXT,
|
||||
plane.offset as i32,
|
||||
EGL_DMA_BUF_PLANE0_PITCH_EXT,
|
||||
plane.stride as i32,
|
||||
];
|
||||
if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 {
|
||||
attribs.extend_from_slice(&[
|
||||
EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT,
|
||||
(modifier & 0xffff_ffff) as u32 as i32,
|
||||
EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT,
|
||||
(modifier >> 32) as u32 as i32,
|
||||
]);
|
||||
}
|
||||
attribs.push(EGL_NONE);
|
||||
// SAFETY: attribs is a valid EGL_NONE-terminated list; display/context are live.
|
||||
let img = unsafe {
|
||||
(self.create_image)(
|
||||
self.egl_display,
|
||||
std::ptr::null_mut(), // EGL_NO_CONTEXT — dmabuf import
|
||||
EGL_LINUX_DMA_BUF_EXT,
|
||||
std::ptr::null_mut(),
|
||||
attribs.as_ptr(),
|
||||
)
|
||||
};
|
||||
if img.is_null() {
|
||||
bail!(
|
||||
"eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:?}",
|
||||
width,
|
||||
height,
|
||||
fourcc,
|
||||
modifier,
|
||||
self.egl.get_error()
|
||||
);
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GlConverter {
|
||||
/// Delete our objects from the shared context group (the context lives in GDK's share
|
||||
/// group — per-session leftovers would pile up across sessions). Textures GTK still
|
||||
/// holds at this moment release into `retired` afterwards, where nobody drains them:
|
||||
/// those names leak, but it's ≤ the pool depth once per session, not per frame.
|
||||
fn drop(&mut self) {
|
||||
self.ctx.make_current();
|
||||
let gl = &self.gl;
|
||||
// SAFETY: context current; only objects this converter created are deleted.
|
||||
unsafe {
|
||||
for r in self.retired.lock().unwrap().drain(..) {
|
||||
if r.sync != 0 {
|
||||
(gl.DeleteSync)(r.sync as *const c_void);
|
||||
}
|
||||
(gl.DeleteTextures)(1, &r.tex);
|
||||
}
|
||||
(gl.DeleteFramebuffers)(1, &self.fbo);
|
||||
(gl.DeleteVertexArrays)(1, &self.vao);
|
||||
(gl.DeleteProgram)(self.program);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile the fullscreen-triangle NV12→RGB program (GLSL 300 es / 330 core per the GDK
|
||||
/// context's API). `gl_VertexID` drives the geometry — no buffers at all.
|
||||
///
|
||||
/// # Safety
|
||||
/// A GL context must be current; `gl` must belong to it.
|
||||
unsafe fn build_program(gl: &GlFns, es: bool) -> Result<u32> {
|
||||
let header = if es {
|
||||
"#version 300 es\nprecision highp float;\n"
|
||||
} else {
|
||||
"#version 330 core\n"
|
||||
};
|
||||
let vs_src = format!(
|
||||
"{header}
|
||||
out vec2 v_uv;
|
||||
void main() {{
|
||||
vec2 p = vec2(float((gl_VertexID & 1) << 2) - 1.0, float((gl_VertexID & 2) << 1) - 1.0);
|
||||
v_uv = p * 0.5 + 0.5;
|
||||
gl_Position = vec4(p, 0.0, 1.0);
|
||||
}}"
|
||||
);
|
||||
let fs_src = format!(
|
||||
"{header}
|
||||
in vec2 v_uv;
|
||||
out vec4 frag;
|
||||
uniform sampler2D u_y;
|
||||
uniform sampler2D u_c;
|
||||
uniform mat3 u_mat;
|
||||
uniform vec3 u_off;
|
||||
void main() {{
|
||||
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg);
|
||||
frag = vec4(clamp(u_mat * (yuv + u_off), 0.0, 1.0), 1.0);
|
||||
}}"
|
||||
);
|
||||
// SAFETY: caller holds a current context; sources are valid UTF-8 with explicit lengths.
|
||||
unsafe {
|
||||
let compile = |kind: u32, src: &str| -> Result<u32> {
|
||||
let sh = (gl.CreateShader)(kind);
|
||||
let ptr = src.as_ptr();
|
||||
let len = src.len() as i32;
|
||||
(gl.ShaderSource)(sh, 1, &ptr, &len);
|
||||
(gl.CompileShader)(sh);
|
||||
let mut ok = 0i32;
|
||||
(gl.GetShaderiv)(sh, GL_COMPILE_STATUS, &mut ok);
|
||||
if ok == 0 {
|
||||
let mut log = vec![0u8; 1024];
|
||||
let mut n = 0i32;
|
||||
(gl.GetShaderInfoLog)(sh, 1024, &mut n, log.as_mut_ptr());
|
||||
(gl.DeleteShader)(sh);
|
||||
bail!(
|
||||
"shader compile: {}",
|
||||
String::from_utf8_lossy(&log[..n.max(0) as usize])
|
||||
);
|
||||
}
|
||||
Ok(sh)
|
||||
};
|
||||
let vs = compile(GL_VERTEX_SHADER, &vs_src)?;
|
||||
let fs = match compile(GL_FRAGMENT_SHADER, &fs_src) {
|
||||
Ok(fs) => fs,
|
||||
Err(e) => {
|
||||
(gl.DeleteShader)(vs);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let prog = (gl.CreateProgram)();
|
||||
(gl.AttachShader)(prog, vs);
|
||||
(gl.AttachShader)(prog, fs);
|
||||
(gl.LinkProgram)(prog);
|
||||
(gl.DeleteShader)(vs);
|
||||
(gl.DeleteShader)(fs);
|
||||
let mut ok = 0i32;
|
||||
(gl.GetProgramiv)(prog, GL_LINK_STATUS, &mut ok);
|
||||
if ok == 0 {
|
||||
bail!("program link failed");
|
||||
}
|
||||
Ok(prog)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
||||
ColorDesc {
|
||||
primaries: 1,
|
||||
transfer: 1,
|
||||
matrix,
|
||||
full_range,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(mat: &[f32; 9], off: &[f32; 3], yuv: [f32; 3]) -> [f32; 3] {
|
||||
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
|
||||
// Column-major: out[r] = Σ mat[col*3 + r] * v[col]
|
||||
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum())
|
||||
}
|
||||
|
||||
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0.
|
||||
#[test]
|
||||
fn bt709_limited_white_black() {
|
||||
let (mat, off) = yuv_to_rgb(desc(1, false));
|
||||
let white = apply(&mat, &off, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||
let black = apply(&mat, &off, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||
for (w, b) in white.iter().zip(black) {
|
||||
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
|
||||
assert!(b.abs() < 0.005, "black {black:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-range identity points: Y=1 → white, Y=0 → black, and a 601-vs-709 red spot
|
||||
/// check (pure V excursion produces R = 2(1−Kr)·0.5).
|
||||
#[test]
|
||||
fn full_range_and_red_excursion() {
|
||||
let (mat, off) = yuv_to_rgb(desc(5, true));
|
||||
let white = apply(&mat, &off, [1.0, 0.5, 0.5]);
|
||||
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
||||
let red = apply(&mat, &off, [0.0, 0.5, 1.0]);
|
||||
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
|
||||
// 709 differs from 601 in the same spot — guards the matrix-code dispatch.
|
||||
let (mat709, off709) = yuv_to_rgb(desc(1, true));
|
||||
let red709 = apply(&mat709, &off709, [0.0, 0.5, 1.0]);
|
||||
assert!(
|
||||
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
|
||||
"{red709:?}"
|
||||
);
|
||||
assert!((red[0] - red709[0]).abs() > 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Client-side Wake-on-LAN: parse stored MAC strings and hand them to the shared core sender
|
||||
//! (`punktfunk_core::wol`). A sleeping host has no ARP entry, so the broadcast the core sends is
|
||||
//! what actually wakes it; this is called just before connecting to an offline saved host, and
|
||||
//! from the explicit "Wake host" menu item / `--wake` CLI mode.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Fire a Wake-on-LAN magic packet at `macs` (each `aa:bb:cc:dd:ee:ff`), also unicasting
|
||||
/// `last_ip` when given. Best-effort — logs the outcome and never blocks the caller meaningfully
|
||||
/// (the core sends a short burst of datagrams and returns).
|
||||
pub fn wake(macs: &[String], last_ip: Option<Ipv4Addr>) {
|
||||
let parsed: Vec<[u8; 6]> = macs
|
||||
.iter()
|
||||
.filter_map(|s| punktfunk_core::wol::parse_mac(s))
|
||||
.collect();
|
||||
if parsed.is_empty() {
|
||||
tracing::warn!("wake requested but no valid MAC is known for this host");
|
||||
return;
|
||||
}
|
||||
match punktfunk_core::wol::send_magic_packet(&parsed, last_ip) {
|
||||
Ok(()) => tracing::info!(count = parsed.len(), "sent Wake-on-LAN magic packet"),
|
||||
Err(e) => tracing::warn!(error = %e, "Wake-on-LAN send failed"),
|
||||
}
|
||||
}
|
||||
@@ -412,7 +412,7 @@ async fn session(args: Args) -> Result<()> {
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&Hello {
|
||||
abi_version: punktfunk_core::ABI_VERSION,
|
||||
abi_version: punktfunk_core::WIRE_VERSION,
|
||||
mode: args.mode,
|
||||
compositor: args.compositor,
|
||||
gamepad: args.gamepad,
|
||||
|
||||
@@ -245,6 +245,7 @@ fn connect_with(
|
||||
port: target.port,
|
||||
fp_hex: trust::hex(&fingerprint),
|
||||
paired: persist_paired,
|
||||
mac: target.mac.clone(),
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use windows_reactor::*;
|
||||
/// Overflow-menu item labels — `on_item_clicked` reports the clicked item by its text.
|
||||
const MENU_CONNECT: &str = "Connect";
|
||||
const MENU_SPEED: &str = "Test network speed\u{2026}";
|
||||
const MENU_WAKE: &str = "Wake host";
|
||||
const MENU_RENAME: &str = "Rename\u{2026}";
|
||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||
|
||||
@@ -318,10 +319,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
port: k.port,
|
||||
fp_hex: Some(k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
mac: k.mac.clone(),
|
||||
};
|
||||
let online = hosts
|
||||
.iter()
|
||||
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port));
|
||||
// Learn this host's wake MAC(s) from its live advert while it's online, so we can wake
|
||||
// it once it sleeps (no-op / no disk write when unchanged).
|
||||
if let Some(a) = hosts.iter().find(|h| {
|
||||
(h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port))
|
||||
&& !h.mac.is_empty()
|
||||
}) {
|
||||
crate::trust::learn_mac(&k.fp_hex, &k.addr, k.port, &a.mac);
|
||||
}
|
||||
let can_wake = !online && !k.mac.is_empty();
|
||||
let menu = {
|
||||
let (svc, target) = (props.svc.clone(), target.clone());
|
||||
let (sf, sr) = (set_forget.clone(), set_rename.clone());
|
||||
@@ -331,17 +342,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.subtle()
|
||||
.tooltip("More options")
|
||||
.automation_name("More options")
|
||||
.menu_flyout(vec![
|
||||
menu_item(MENU_CONNECT),
|
||||
menu_item(MENU_SPEED),
|
||||
menu_item(MENU_RENAME),
|
||||
menu_separator(),
|
||||
menu_item(MENU_FORGET),
|
||||
])
|
||||
.menu_flyout({
|
||||
let mut items = vec![menu_item(MENU_CONNECT), menu_item(MENU_SPEED)];
|
||||
// Offer an explicit wake only when the host is offline and we have a MAC.
|
||||
if can_wake {
|
||||
items.push(menu_item(MENU_WAKE));
|
||||
}
|
||||
items.push(menu_item(MENU_RENAME));
|
||||
items.push(menu_separator());
|
||||
items.push(menu_item(MENU_FORGET));
|
||||
items
|
||||
})
|
||||
.on_item_clicked(move |item: String| match item.as_str() {
|
||||
MENU_CONNECT => {
|
||||
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
||||
}
|
||||
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
|
||||
MENU_SPEED => {
|
||||
*svc.ctx.shared.target.lock().unwrap() = target.clone();
|
||||
// New run: invalidate any still-in-flight probe, reset the screen.
|
||||
@@ -369,7 +385,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
if k.paired { Pill::Good } else { Pill::Info },
|
||||
),
|
||||
Some(menu),
|
||||
Some(Box::new(move || initiate(&ctx2, target.clone(), &ss, &st))),
|
||||
Some(Box::new(move || {
|
||||
// Auto-wake an offline saved host before connecting; the connect's own
|
||||
// retry/timeout gives a woken host time to come up.
|
||||
if can_wake {
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
}
|
||||
initiate(&ctx2, target.clone(), &ss, &st)
|
||||
})),
|
||||
));
|
||||
}
|
||||
body.push(tile_grid(tiles, cols));
|
||||
@@ -406,6 +429,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
port: h.port,
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
pair_optional: h.pair == "optional",
|
||||
mac: h.mac.clone(),
|
||||
};
|
||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
let (badge, kind) = if h.pair == "required" {
|
||||
@@ -486,6 +510,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
port,
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
mac: Vec::new(),
|
||||
},
|
||||
&ss,
|
||||
&st,
|
||||
|
||||
@@ -68,6 +68,9 @@ pub(crate) struct Target {
|
||||
pub(crate) port: u16,
|
||||
pub(crate) fp_hex: Option<String>,
|
||||
pub(crate) pair_optional: bool,
|
||||
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
|
||||
/// magic packet before connecting to an offline host. Empty when none is known.
|
||||
pub(crate) mac: Vec<String>,
|
||||
}
|
||||
|
||||
/// Stable app services handed to the page components as props. Each routed screen that uses
|
||||
|
||||
@@ -50,6 +50,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
port: target3.port,
|
||||
fp_hex: trust::hex(&fp),
|
||||
paired: true,
|
||||
mac: target3.mac.clone(),
|
||||
});
|
||||
let _ = k.save();
|
||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||
|
||||
@@ -15,6 +15,9 @@ pub struct DiscoveredHost {
|
||||
pub fp_hex: String,
|
||||
/// Pairing requirement: `"required"` or `"optional"`.
|
||||
pub pair: String,
|
||||
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the
|
||||
/// hosts page persists onto the matching saved host so it can wake it later. Empty if absent.
|
||||
pub mac: Vec<String>,
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||
@@ -63,6 +66,11 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
port: info.get_port(),
|
||||
fp_hex: val("fp"),
|
||||
pair: val("pair"),
|
||||
mac: val("mac")
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
};
|
||||
if tx.send_blocking(host).is_err() {
|
||||
break; // UI gone — stop browsing
|
||||
|
||||
@@ -43,6 +43,9 @@ mod trust;
|
||||
#[cfg(windows)]
|
||||
mod video;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod wol;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
// With #![windows_subsystem = "windows"] the process starts with no console, so the GUI/MSIX
|
||||
@@ -187,6 +190,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
port,
|
||||
fp_hex: trust::hex(&fp),
|
||||
paired: true,
|
||||
mac: Vec::new(),
|
||||
});
|
||||
let _ = k.save();
|
||||
tracing::info!(fp = %trust::hex(&fp), "paired");
|
||||
|
||||
@@ -57,6 +57,11 @@ pub struct KnownHost {
|
||||
pub fp_hex: String,
|
||||
/// True if trust came from the SPAKE2 PIN ceremony (vs. trust-on-first-use).
|
||||
pub paired: bool,
|
||||
/// Wake-on-LAN MAC(s) (`aa:bb:cc:dd:ee:ff`) learned from the host's mDNS `mac` TXT while it was
|
||||
/// online, so we can wake it once it sleeps. `default` so pre-existing stores load; empty until
|
||||
/// first learned.
|
||||
#[serde(default)]
|
||||
pub mac: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
@@ -106,12 +111,38 @@ impl KnownHosts {
|
||||
h.addr = entry.addr;
|
||||
h.port = entry.port;
|
||||
h.paired |= entry.paired;
|
||||
// A trust-decision upsert (which carries no MAC) must not wipe learned MACs.
|
||||
if !entry.mac.is_empty() {
|
||||
h.mac = entry.mac;
|
||||
}
|
||||
} else {
|
||||
self.hosts.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Learn/refresh a saved host's Wake-on-LAN MAC(s) from its live advert (called while the host is
|
||||
/// online, matched by fingerprint or address). No-op — and no disk write — when unchanged, so the
|
||||
/// hosts page can call it on every discovery tick without churning the store.
|
||||
pub fn learn_mac(fp_hex: &str, addr: &str, port: u16, mac: &[String]) {
|
||||
if mac.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = known
|
||||
.hosts
|
||||
.iter_mut()
|
||||
.find(|h| (!fp_hex.is_empty() && h.fp_hex == fp_hex) || (h.addr == addr && h.port == port))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if h.mac == mac {
|
||||
return;
|
||||
}
|
||||
h.mac = mac.to_vec();
|
||||
let _ = known.save();
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Client-side Wake-on-LAN: parse stored MAC strings and hand them to the shared core sender
|
||||
//! (`punktfunk_core::wol`). A sleeping host has no ARP entry, so the broadcast the core sends is
|
||||
//! what actually wakes it; this is called just before connecting to an offline saved host and
|
||||
//! from the explicit "Wake host" menu item.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Fire a Wake-on-LAN magic packet at `macs` (each `aa:bb:cc:dd:ee:ff`), also unicasting
|
||||
/// `last_ip` when given. Best-effort — logs the outcome and returns promptly (the core sends a
|
||||
/// short burst of datagrams).
|
||||
pub fn wake(macs: &[String], last_ip: Option<Ipv4Addr>) {
|
||||
let parsed: Vec<[u8; 6]> = macs
|
||||
.iter()
|
||||
.filter_map(|s| punktfunk_core::wol::parse_mac(s))
|
||||
.collect();
|
||||
if parsed.is_empty() {
|
||||
tracing::warn!("wake requested but no valid MAC is known for this host");
|
||||
return;
|
||||
}
|
||||
match punktfunk_core::wol::send_magic_packet(&parsed, last_ip) {
|
||||
Ok(()) => tracing::info!(count = parsed.len(), "sent Wake-on-LAN magic packet"),
|
||||
Err(e) => tracing::warn!(error = %e, "Wake-on-LAN send failed"),
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ thiserror = "2"
|
||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
||||
rand = "0.9"
|
||||
zeroize = "1"
|
||||
# Interface enumeration for Wake-on-LAN: computes each NIC's subnet-directed broadcast so a
|
||||
# magic packet reaches the host's L2 segment on multi-homed clients (VPN/docker/multiple LANs),
|
||||
# not just the default route. Tiny, cross-platform (getifaddrs / GetAdaptersAddresses), no cmake.
|
||||
if-addrs = "0.13"
|
||||
|
||||
quinn = { version = "0.11", optional = true }
|
||||
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
|
||||
|
||||
@@ -183,6 +183,60 @@ pub extern "C" fn punktfunk_abi_version() -> u32 {
|
||||
crate::ABI_VERSION
|
||||
}
|
||||
|
||||
/// Send a Wake-on-LAN magic packet to wake sleeping host NIC(s).
|
||||
///
|
||||
/// `macs` points to `mac_count` contiguous 6-byte MAC addresses (`mac_count * 6` bytes total) —
|
||||
/// a host may report several NICs; all are woken. `last_known_ip`, if non-NULL, is an IPv4
|
||||
/// dotted-quad string additionally targeted by unicast (pass NULL to skip). The packet is
|
||||
/// broadcast to every local interface's subnet-directed broadcast and to `255.255.255.255` on
|
||||
/// ports 9 and 7. This does NOT require an open connection and is not part of the QUIC surface.
|
||||
///
|
||||
/// Returns `Ok` if at least one datagram was sent. Call off the UI thread.
|
||||
///
|
||||
/// # Safety
|
||||
/// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL,
|
||||
/// must be a NUL-terminated string.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_wake_on_lan(
|
||||
macs: *const u8,
|
||||
mac_count: usize,
|
||||
last_known_ip: *const c_char,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
if macs.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
if mac_count == 0 {
|
||||
return PunktfunkStatus::InvalidArg;
|
||||
}
|
||||
let bytes = unsafe { std::slice::from_raw_parts(macs, mac_count * 6) };
|
||||
let mac_vec: Vec<crate::wol::Mac> = bytes
|
||||
.chunks_exact(6)
|
||||
.map(|c| {
|
||||
let mut m = [0u8; 6];
|
||||
m.copy_from_slice(c);
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
let ip = if last_known_ip.is_null() {
|
||||
None
|
||||
} else {
|
||||
match unsafe { CStr::from_ptr(last_known_ip) }
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<std::net::Ipv4Addr>().ok())
|
||||
{
|
||||
Some(ip) => Some(ip),
|
||||
None => return PunktfunkStatus::InvalidArg,
|
||||
}
|
||||
};
|
||||
match crate::wol::send_magic_packet(&mac_vec, ip) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
Err(_) => PunktfunkStatus::Io,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings).
|
||||
/// Returns NULL on error.
|
||||
///
|
||||
|
||||
@@ -876,7 +876,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&Hello {
|
||||
abi_version: crate::ABI_VERSION,
|
||||
abi_version: crate::WIRE_VERSION,
|
||||
mode,
|
||||
compositor,
|
||||
gamepad,
|
||||
|
||||
@@ -39,6 +39,7 @@ pub mod quic;
|
||||
pub mod session;
|
||||
pub mod stats;
|
||||
pub mod transport;
|
||||
pub mod wol;
|
||||
|
||||
pub use config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role};
|
||||
pub use error::{PunktfunkError, PunktfunkStatus, Result};
|
||||
@@ -50,4 +51,14 @@ pub use stats::Stats;
|
||||
///
|
||||
/// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
|
||||
/// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
||||
pub const ABI_VERSION: u32 = 2;
|
||||
/// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
|
||||
/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
|
||||
pub const ABI_VERSION: u32 = 3;
|
||||
|
||||
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
/// (functions a client links), which can grow without changing a single wire byte — v3's
|
||||
/// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
|
||||
/// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
|
||||
/// live). Bump this ONLY when the handshake/planes actually change incompatibly.
|
||||
pub const WIRE_VERSION: u32 = 2;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Wake-on-LAN: magic-packet builder + broadcast sender.
|
||||
//!
|
||||
//! Runtime-free by design — a magic packet is one fire-and-forget UDP datagram, so this needs
|
||||
//! neither the `quic` feature nor an async runtime and links into every client (including the
|
||||
//! QUIC-less builds). The Rust clients (linux/windows/android) call these `pub fn`s directly;
|
||||
//! Swift/iOS reach them through the `punktfunk_wake_on_lan` C-ABI wrapper in [`crate::abi`].
|
||||
//!
|
||||
//! Reliability (this is the whole point — a sleeping host has no ARP entry, so a plain unicast
|
||||
//! can't wake it, and `255.255.255.255` alone leaves only via the default route). For each
|
||||
//! known host MAC we send the 102-byte packet to:
|
||||
//! * every non-loopback IPv4 interface's **subnet-directed broadcast** (routes to that NIC's
|
||||
//! segment — this is what covers multi-homed clients on VPN/docker/multiple LANs), and
|
||||
//! * the **limited broadcast** `255.255.255.255`, and
|
||||
//! * optionally a **unicast** to the host's last-known IP (covers the brief window where the
|
||||
//! host is reachable but hasn't re-advertised, and NICs that wake on a directed unicast),
|
||||
//!
|
||||
//! on the two conventional WoL ports (9 and 7), repeated a few times to survive UDP loss.
|
||||
|
||||
use std::io;
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
||||
|
||||
/// A MAC address (EUI-48), the 6 bytes a magic packet targets.
|
||||
pub type Mac = [u8; 6];
|
||||
|
||||
/// Conventional Wake-on-LAN UDP ports. 9 (discard) is by far the most common; 7 (echo) is a
|
||||
/// historical alternative some NICs also listen on. Sending to both is free insurance.
|
||||
const WOL_PORTS: [u16; 2] = [9, 7];
|
||||
|
||||
/// Times each packet is re-sent per call. UDP is lossy and this is fire-and-forget; a small
|
||||
/// burst costs microseconds and materially improves the odds a waking NIC catches one. The
|
||||
/// caller's connect-retry loop provides the longer-spaced re-attempts.
|
||||
const BURST: usize = 3;
|
||||
|
||||
/// Parse a MAC string — `aa:bb:cc:dd:ee:ff` or `aa-bb-...`, case-insensitive — into 6 bytes.
|
||||
/// Returns `None` for anything that isn't exactly six hex octets. Shared by the Rust clients
|
||||
/// (linux/windows) so MAC parsing lives in one place; the Swift/Apple client parses its own.
|
||||
pub fn parse_mac(s: &str) -> Option<Mac> {
|
||||
let mut m = [0u8; 6];
|
||||
let mut n = 0;
|
||||
for part in s.split([':', '-']) {
|
||||
if n == 6 {
|
||||
return None; // too many octets
|
||||
}
|
||||
m[n] = u8::from_str_radix(part.trim(), 16).ok()?;
|
||||
n += 1;
|
||||
}
|
||||
(n == 6).then_some(m)
|
||||
}
|
||||
|
||||
/// The 102-byte magic packet for `mac`: 6×`0xFF` followed by the MAC repeated 16 times.
|
||||
pub fn build_magic_packet(mac: Mac) -> [u8; 102] {
|
||||
let mut pkt = [0xFFu8; 102];
|
||||
for i in 0..16 {
|
||||
let off = 6 + i * 6;
|
||||
pkt[off..off + 6].copy_from_slice(&mac);
|
||||
}
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Broadcast a wake for every MAC in `macs`. `last_known_ip`, when set, is additionally
|
||||
/// targeted by unicast.
|
||||
///
|
||||
/// Returns `Ok` if at least one datagram was sent, so a single unreachable target (e.g. a
|
||||
/// directed broadcast with no route) doesn't fail the whole wake. Errors only if no socket
|
||||
/// could be opened or nothing could be sent at all.
|
||||
pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::Result<()> {
|
||||
if macs.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"no MAC addresses",
|
||||
));
|
||||
}
|
||||
|
||||
// Build the target IP set: each interface's directed broadcast, the limited broadcast, and
|
||||
// the optional last-known unicast. Dedup so a single-NIC client doesn't send twice.
|
||||
let mut targets = broadcast_addrs();
|
||||
targets.push(Ipv4Addr::BROADCAST); // 255.255.255.255
|
||||
if let Some(ip) = last_known_ip {
|
||||
targets.push(ip);
|
||||
}
|
||||
targets.sort_unstable();
|
||||
targets.dedup();
|
||||
|
||||
// One broadcast-enabled socket bound to all interfaces. Directed broadcasts route to the
|
||||
// matching NIC via the routing table; the limited broadcast leaves via the default route.
|
||||
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
|
||||
sock.set_broadcast(true)?;
|
||||
|
||||
let mut sent_any = false;
|
||||
for _ in 0..BURST {
|
||||
for mac in macs {
|
||||
let pkt = build_magic_packet(*mac);
|
||||
for ip in &targets {
|
||||
for port in WOL_PORTS {
|
||||
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, port));
|
||||
if sock.send_to(&pkt, dst).is_ok() {
|
||||
sent_any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sent_any {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other("no magic packet could be sent"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Subnet-directed broadcast address of every non-loopback IPv4 interface (`ip | !netmask`,
|
||||
/// or the OS-provided broadcast when present). Best-effort: interface enumeration failing
|
||||
/// (permissions, exotic platform) yields an empty list, and the limited broadcast still fires.
|
||||
fn broadcast_addrs() -> Vec<Ipv4Addr> {
|
||||
let mut out = Vec::new();
|
||||
let ifaces = match if_addrs::get_if_addrs() {
|
||||
Ok(i) => i,
|
||||
Err(_) => return out,
|
||||
};
|
||||
for iface in ifaces {
|
||||
if iface.is_loopback() {
|
||||
continue;
|
||||
}
|
||||
if let if_addrs::IfAddr::V4(v4) = iface.addr {
|
||||
let bcast = v4
|
||||
.broadcast
|
||||
.unwrap_or_else(|| Ipv4Addr::from(u32::from(v4.ip) | !u32::from(v4.netmask)));
|
||||
// Skip a degenerate 0.0.0.0 (unconfigured) and the all-ones limited broadcast we
|
||||
// already add unconditionally.
|
||||
if !bcast.is_unspecified() && bcast != Ipv4Addr::BROADCAST {
|
||||
out.push(bcast);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn magic_packet_layout() {
|
||||
let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
|
||||
let pkt = build_magic_packet(mac);
|
||||
assert_eq!(pkt.len(), 102);
|
||||
// 6-byte 0xFF sync stream.
|
||||
assert_eq!(&pkt[0..6], &[0xFF; 6]);
|
||||
// MAC repeated exactly 16 times.
|
||||
for i in 0..16 {
|
||||
let off = 6 + i * 6;
|
||||
assert_eq!(&pkt[off..off + 6], &mac, "repetition {i} mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_macs_is_error() {
|
||||
assert!(send_magic_packet(&[], None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mac_forms() {
|
||||
assert_eq!(
|
||||
parse_mac("aa:bb:cc:dd:ee:ff"),
|
||||
Some([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])
|
||||
);
|
||||
assert_eq!(
|
||||
parse_mac("AA-BB-CC-DD-EE-FF"),
|
||||
Some([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])
|
||||
);
|
||||
assert_eq!(parse_mac("01:02:03:04:05:06"), Some([1, 2, 3, 4, 5, 6]));
|
||||
assert_eq!(parse_mac("aa:bb:cc:dd:ee"), None); // too few
|
||||
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff:00"), None); // too many
|
||||
assert_eq!(parse_mac("zz:bb:cc:dd:ee:ff"), None); // non-hex
|
||||
assert_eq!(parse_mac(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_does_not_panic_with_a_mac() {
|
||||
// Best-effort: binds a real socket and broadcasts on the loopback host. Must not panic
|
||||
// and, on any machine with a usable network stack, should report success.
|
||||
let _ = send_magic_packet(&[[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]], None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_addrs_never_contains_limited_or_unspecified() {
|
||||
for b in broadcast_addrs() {
|
||||
assert_ne!(b, Ipv4Addr::BROADCAST);
|
||||
assert!(!b.is_unspecified());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-log = "0.2"
|
||||
axum = "0.8"
|
||||
mdns-sd = "0.20"
|
||||
# Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record.
|
||||
# `mac_address` reads a NIC's hardware address; `if-addrs` maps the routed IP to its interface name.
|
||||
mac_address = "1"
|
||||
if-addrs = "0.13"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
rsa = "0.9"
|
||||
sha2 = { version = "0.10", features = ["oid"] }
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
|
||||
//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port.
|
||||
//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`).
|
||||
//! - `mac` — the host's wake-capable NIC MAC(s) (comma-separated, routed NIC first), which a client
|
||||
//! persists so it can Wake-on-LAN this host after it sleeps. Advisory/unauthenticated (a wrong
|
||||
//! MAC only makes a wake fail). Omitted when none can be read.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ServiceDaemon, ServiceInfo};
|
||||
@@ -63,6 +66,18 @@ pub fn advertise_native(
|
||||
if let Some(mgmt) = mgmt_port {
|
||||
props.insert("mgmt".into(), mgmt.to_string());
|
||||
}
|
||||
// `mac` — the host's wake-capable NIC MAC(s), comma-separated `aa:bb:cc:dd:ee:ff`, routed NIC
|
||||
// first. A client persists these while the host is awake so it can send a Wake-on-LAN magic
|
||||
// packet to wake it later (when it's asleep and no longer advertising). Unauthenticated like
|
||||
// the rest of the advert, but a wrong MAC only makes a wake fail — the magic packet is inert
|
||||
// and the cert fingerprint still gates the actual connection. Omitted when none can be read.
|
||||
let macs = crate::wol::wake_macs(ip);
|
||||
if !macs.is_empty() {
|
||||
props.insert("mac".into(), macs.join(","));
|
||||
}
|
||||
// Detect & warn (never modifies) if the routed NIC isn't armed to wake — the usual reason WoL
|
||||
// silently fails.
|
||||
crate::wol::warn_if_not_armed(ip);
|
||||
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
|
||||
.context("build native mDNS ServiceInfo")?;
|
||||
daemon
|
||||
|
||||
@@ -276,12 +276,43 @@ impl DeckTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot diagnostic: InputPlumber (shipped and enabled by default on Bazzite) hidraw-grabs
|
||||
/// controllers it decides to manage and re-emits them under a different identity — historically
|
||||
/// the Deck config re-emitted an Xbox Elite pad with the trackpads routed to a mouse target. If
|
||||
/// it grabs our virtual Deck, everything downstream of hid-steam looks wrong (trackpads surface
|
||||
/// as a stick/mouse, gyro vanishes) while punktfunk's own logs stay clean — so name the suspect
|
||||
/// up front. Best-effort process-name scan; no dependency on its D-Bus API.
|
||||
fn warn_if_inputplumber() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if !ONCE.swap(false, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let running = std::fs::read_dir("/proc")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.any(|e| {
|
||||
std::fs::read_to_string(e.path().join("comm")).is_ok_and(|c| c.trim() == "inputplumber")
|
||||
});
|
||||
if running {
|
||||
tracing::warn!(
|
||||
"InputPlumber is running on this host — if it manages the virtual Steam Deck pad, \
|
||||
games see InputPlumber's re-emitted device instead (trackpads may arrive as a \
|
||||
stick/mouse, gyro may vanish). Check `inputplumber devices` and exclude the \
|
||||
virtual pad from management if inputs look remapped."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the best Steam-Input-promotable Deck transport available, in preference order:
|
||||
/// **`raw_gadget` (SteamOS validated fast-path) → `usbip`/`vhci_hcd` (universal, Secure-Boot-clean)
|
||||
/// → UHID (universal, but `Interface: -1` so Steam Input won't promote it).** Each rung degrades to
|
||||
/// the next on failure, so a host lacking the gadget modules still gets a *promotable* Deck via
|
||||
/// usbip, and one lacking both still gets a working (if non-promoted) UHID pad.
|
||||
fn open_transport(idx: u8) -> Result<DeckTransport> {
|
||||
warn_if_inputplumber();
|
||||
use crate::inject::{steam_gadget, steam_usbip};
|
||||
// 1. raw_gadget — the validated SteamOS fast-path (default on there).
|
||||
if steam_gadget::gadget_preferred() {
|
||||
|
||||
@@ -22,6 +22,7 @@ mod audio;
|
||||
mod capture;
|
||||
mod config;
|
||||
mod discovery;
|
||||
mod wol;
|
||||
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
||||
// keeps the `crate::*` module names flat (every existing path is unchanged).
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -585,10 +585,10 @@ async fn serve_session(
|
||||
// the `handshake` future re-decodes for the real session — a few dozen bytes, negligible.
|
||||
let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||
anyhow::ensure!(
|
||||
gate_hello.abi_version == punktfunk_core::ABI_VERSION,
|
||||
"ABI mismatch: client {} host {}",
|
||||
gate_hello.abi_version == punktfunk_core::WIRE_VERSION,
|
||||
"wire version mismatch: client {} host {}",
|
||||
gate_hello.abi_version,
|
||||
punktfunk_core::ABI_VERSION
|
||||
punktfunk_core::WIRE_VERSION
|
||||
);
|
||||
let fp = endpoint::peer_fingerprint(&conn);
|
||||
let known = fp
|
||||
@@ -654,10 +654,10 @@ async fn serve_session(
|
||||
let handshake = async {
|
||||
let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||
anyhow::ensure!(
|
||||
hello.abi_version == punktfunk_core::ABI_VERSION,
|
||||
"ABI mismatch: client {} host {}",
|
||||
hello.abi_version == punktfunk_core::WIRE_VERSION,
|
||||
"wire version mismatch: client {} host {}",
|
||||
hello.abi_version,
|
||||
punktfunk_core::ABI_VERSION
|
||||
punktfunk_core::WIRE_VERSION
|
||||
);
|
||||
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
|
||||
// before this future, so a client reaching here is paired (or the host is `--open`).
|
||||
@@ -805,7 +805,7 @@ async fn serve_session(
|
||||
let mut key = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
let welcome = Welcome {
|
||||
abi_version: punktfunk_core::ABI_VERSION,
|
||||
abi_version: punktfunk_core::WIRE_VERSION,
|
||||
udp_port,
|
||||
mode: hello.mode,
|
||||
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
|
||||
@@ -1911,6 +1911,13 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
/// two Decks — confirmed conflict-prone on a Deck-as-host (the physical `28DE:1205` + Steam's
|
||||
/// `28DE:11FF` XInput output pad are both live). HID device dirs are named `BUS:VID:PID.INST`
|
||||
/// (uppercase); a UHID virtual device resolves through `/devices/virtual/…`, a real one does not.
|
||||
///
|
||||
/// Punktfunk's OWN virtual Decks must never count: the usbip/gadget transports present a real USB
|
||||
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
|
||||
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
|
||||
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
|
||||
/// recognizable by the `PFDK…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
|
||||
/// vhci path as belt and braces.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn physical_steam_controller_present() -> bool {
|
||||
let Ok(entries) = std::fs::read_dir("/sys/bus/hid/devices") else {
|
||||
@@ -1920,8 +1927,16 @@ fn physical_steam_controller_present() -> bool {
|
||||
if !e.file_name().to_string_lossy().contains(":28DE:") {
|
||||
return false;
|
||||
}
|
||||
if std::fs::read_to_string(e.path().join("uevent"))
|
||||
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=PFDK")))
|
||||
{
|
||||
return false; // one of our own virtual Decks
|
||||
}
|
||||
match std::fs::read_link(e.path()) {
|
||||
Ok(target) => !target.to_string_lossy().contains("/virtual/"),
|
||||
Ok(target) => {
|
||||
let t = target.to_string_lossy();
|
||||
!t.contains("/virtual/") && !t.contains("vhci_hcd")
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Host-side Wake-on-LAN support.
|
||||
//!
|
||||
//! Two jobs, both best-effort (a failure here never affects streaming):
|
||||
//! 1. [`wake_macs`] — report the host's wake-capable NIC MAC(s) so a client can persist them
|
||||
//! (from the mDNS `mac` TXT record, [`crate::discovery`]) and wake this host later, once it's
|
||||
//! asleep and no longer advertising.
|
||||
//! 2. [`warn_if_not_armed`] — *detect & warn only* whether the NIC is actually armed to wake on a
|
||||
//! magic packet. We never change NIC settings (that's the user's call); we just surface the
|
||||
//! single most common reason WoL silently fails.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// Upper bound on advertised MACs — keeps the mDNS TXT record small. A host has at most a couple
|
||||
/// of wake-capable NICs; the routed one is always first.
|
||||
const MAX_MACS: usize = 4;
|
||||
|
||||
/// MAC(s) of the host's wake-capable NIC(s), lowercase `aa:bb:cc:dd:ee:ff`, with the NIC that
|
||||
/// bears `primary_ip` (the address clients reach us on) FIRST, then other non-loopback NICs as
|
||||
/// fallbacks. Best-effort — an empty list just means clients can't auto-wake (they fall back to
|
||||
/// manual MAC entry). Deduped; all-zero MACs skipped; capped at [`MAX_MACS`].
|
||||
pub fn wake_macs(primary_ip: IpAddr) -> Vec<String> {
|
||||
let ifaces = if_addrs::get_if_addrs().unwrap_or_default();
|
||||
|
||||
// Interface names in priority order: the one holding `primary_ip` first, then every other
|
||||
// non-loopback interface that has an IP, de-duplicated by name (an iface has one MAC but may
|
||||
// appear once per address).
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
if let Some(primary) = ifaces.iter().find(|i| i.ip() == primary_ip) {
|
||||
names.push(primary.name.clone());
|
||||
}
|
||||
for i in &ifaces {
|
||||
if i.is_loopback() {
|
||||
continue;
|
||||
}
|
||||
if !names.contains(&i.name) {
|
||||
names.push(i.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for name in names {
|
||||
let Ok(Some(mac)) = mac_address::mac_address_by_name(&name) else {
|
||||
continue;
|
||||
};
|
||||
let b = mac.bytes();
|
||||
if b == [0u8; 6] {
|
||||
continue; // unset / virtual
|
||||
}
|
||||
let s = format!(
|
||||
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
|
||||
b[0], b[1], b[2], b[3], b[4], b[5]
|
||||
);
|
||||
if !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
if out.len() >= MAX_MACS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Log whether the host NIC bearing `primary_ip` is armed to wake on a magic packet. Detect &
|
||||
/// warn only — never modifies settings. Linux-only (reads `ethtool <iface>`); a no-op elsewhere
|
||||
/// and silent when it can't tell (no `ethtool`, insufficient privilege).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
let ifaces = if_addrs::get_if_addrs().unwrap_or_default();
|
||||
let Some(iface) = ifaces
|
||||
.iter()
|
||||
.find(|i| i.ip() == primary_ip)
|
||||
.map(|i| i.name.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match ethtool_wol_has_magic(&iface) {
|
||||
Some(true) => {
|
||||
tracing::info!(iface = %iface, "Wake-on-LAN armed (magic packet) on host NIC")
|
||||
}
|
||||
Some(false) => tracing::warn!(
|
||||
iface = %iface,
|
||||
"Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep. \
|
||||
Enable it with: sudo ethtool -s {iface} wol g (and turn on 'Wake on LAN'/'Wake on \
|
||||
PCIe' in BIOS). Wired Ethernet is required; Wi-Fi wake is unreliable.",
|
||||
),
|
||||
None => {} // couldn't determine — stay quiet rather than cry wolf
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn warn_if_not_armed(_primary_ip: IpAddr) {}
|
||||
|
||||
/// Parse `ethtool <iface>` for the *current* Wake-on setting and report whether it includes `g`
|
||||
/// (wake on MagicPacket). Returns `None` if ethtool is missing/failed or the field is absent.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
|
||||
let out = std::process::Command::new("ethtool")
|
||||
.arg(iface)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
// The current setting is "Wake-on: <flags>"; skip the "Supports Wake-on: ..." capability
|
||||
// line. `g` = MagicPacket, `d` = disabled.
|
||||
if let Some(flags) = t.strip_prefix("Wake-on:") {
|
||||
return Some(flags.trim().contains('g'));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -136,8 +136,14 @@ reason "admin/SYSTEM = total" stays on the residual list below.
|
||||
boundary against admin. The host↔driver channel has no mutual authentication beyond the `GET_INFO`
|
||||
version handshake + the `verify_is_wudfhost` image check.
|
||||
* **`WDA_EXCLUDEFROMCAPTURE` windows are visible.** IDD-push taps the *present* side, not the
|
||||
*capture* side, so windows that exclude themselves from capture still appear in the stream — true
|
||||
of every virtual-display streaming stack. Untested on our lab box; treat as expected behavior.
|
||||
*capture* side, so windows that exclude themselves from capture still appear in the stream. This is
|
||||
the same exposure a person looking at the physical screen has (the flag hides a window from capture
|
||||
APIs, not from the display), so it fits inside the "a client sees what someone at the screen sees"
|
||||
model rather than exceeding it; what it exceeds is an ordinary screen-*capture* tool (OBS/WGC/DDA),
|
||||
which honors the flag. **Measured, not assumed (2026-07-04, .173):** a full-screen test window was
|
||||
streamed through three 8 s phases — no flag / `WDA_EXCLUDEFROMCAPTURE` set (affinity readback `0x11`,
|
||||
confirmed active) / flag cleared — and the window was pixel-identically visible in the decoded
|
||||
punktfunk/1 stream in all three. The flag made no difference to the stream.
|
||||
* **DRM/HDCP:** protected content is blanked by DWM at composition, and HDCP is a monitor↔GPU
|
||||
handshake an indirect display cannot satisfy — neither is bypassed by this path.
|
||||
* IDD-push is currently the **sole Windows capture path** (DDA and the WGC relay were removed). An
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: Arch Linux
|
||||
description: Install a punktfunk host on Arch (and Arch-derived distros) from the signed pacman binary repo.
|
||||
---
|
||||
|
||||
Set up a punktfunk host on **Arch Linux** (or an Arch-derived distro like CachyOS/EndeavourOS). The
|
||||
host installs from a **signed pacman binary repo**, so it updates with `pacman -Syu` like the rest
|
||||
of your system — no building required. Host encode is **NVENC on NVIDIA** and **VAAPI on
|
||||
AMD/Intel** (`PUNKTFUNK_ENCODER=auto` picks per GPU).
|
||||
|
||||
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||
|
||||
> Prefer to build it yourself? A split `PKGBUILD` (host + client + optional web console) is in the
|
||||
> repo at `packaging/arch/` — see the [appendix](#appendix--build-from-source-pkgbuild). The binary
|
||||
> repo below is the supported path.
|
||||
|
||||
## 1. GPU prerequisites
|
||||
|
||||
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils` (provides NVENC + the EGL/CUDA zero-copy path).
|
||||
Arch's stock `ffmpeg` already has NVENC built in — no RPM-Fusion-style swap like Fedora needs.
|
||||
- **AMD / Intel:** the Mesa stack (`mesa`, `libva-mesa-driver` for AMD, `intel-media-driver` for
|
||||
Intel) provides the VAAPI encoder — usually already installed on a desktop.
|
||||
|
||||
## 2. Add the signed repo
|
||||
|
||||
The registry **signs its database and every package**, so first trust its key once (after this,
|
||||
packages install signature-verified):
|
||||
|
||||
```sh
|
||||
# Trust the registry signing key.
|
||||
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
|
||||
| sudo pacman-key --add -
|
||||
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
|
||||
|
||||
# Add the repo (append to /etc/pacman.conf). No SigLevel line needed — pacman's default
|
||||
# verifies signed packages against the key you just trusted.
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
|
||||
|
||||
[punktfunk]
|
||||
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
|
||||
EOF
|
||||
```
|
||||
|
||||
> **Stable vs canary.** `[punktfunk]` is the **stable** channel — it moves only when a `vX.Y.Z`
|
||||
> release is cut. For the latest `main` build, use `[punktfunk-canary]` instead (same `Server` line,
|
||||
> just the repo name). Enable exactly one. See [Release Channels](/docs/channels).
|
||||
|
||||
## 3. Install the host
|
||||
|
||||
```sh
|
||||
sudo pacman -Sy punktfunk-host # the streaming host
|
||||
sudo pacman -S punktfunk-web # optional: the browser management console (pairing + status)
|
||||
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
|
||||
```
|
||||
|
||||
`punktfunk-client` (the GTK4 couch/Deck client) is in the same repo if this box is also a client.
|
||||
The host package ships the systemd **user** units, the udev rule, the UDP socket-buffer sysctl
|
||||
tuning, and example configs. Updates later are just `sudo pacman -Syu`.
|
||||
|
||||
## 4. Configure and run
|
||||
|
||||
The host runs as a systemd **`--user`** service — it needs your session's PipeWire and D-Bus.
|
||||
Copy a starting config, enable the service, and enable linger so it starts at boot without a login:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/punktfunk
|
||||
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env # then edit
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now punktfunk-host
|
||||
sudo loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
Which compositor the host captures depends on your desktop — it drives a per-client virtual output
|
||||
via KWin (Plasma), Mutter (GNOME), or wlroots (Sway), or spawns a headless **gamescope** session
|
||||
per connect. For a headless appliance, the package also ships `punktfunk-kde-session.service`
|
||||
(a dedicated `kwin --virtual` session, same as the [Fedora KDE](/docs/fedora-kde#3-kwin-streaming-session)
|
||||
guide — `cp /usr/share/punktfunk/host.env.kde ~/.config/punktfunk/host.env` and enable it alongside
|
||||
the host). See [Configuration](/docs/configuration) for every knob and
|
||||
[Running as a Service](/docs/running-as-a-service) for the service model.
|
||||
|
||||
Check it came up:
|
||||
|
||||
```sh
|
||||
systemctl --user status punktfunk-host # active
|
||||
journalctl --user -u punktfunk-host -f # watch a client connect
|
||||
```
|
||||
|
||||
### Web console
|
||||
|
||||
The console (status, paired devices, arm pairing) ships as `punktfunk-web` — enable it, then open
|
||||
`http://<host-ip>:47992`:
|
||||
|
||||
```sh
|
||||
systemctl --user enable --now punktfunk-web
|
||||
```
|
||||
|
||||
#### Console login password
|
||||
|
||||
On first start `punktfunk-web-init` generates a random login password and saves it to
|
||||
`~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it back at any time:
|
||||
|
||||
```sh
|
||||
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
|
||||
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
|
||||
```
|
||||
|
||||
To set your own, edit that file and `systemctl --user restart punktfunk-web`. Forgot it? See
|
||||
[Forgot your Password?](/docs/forgot-password).
|
||||
|
||||
## 5. Connect a client
|
||||
|
||||
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
|
||||
the **PIN pairing** — arm it from the host's web console, which displays a 4-digit PIN to type into
|
||||
the client. (Pairing is required by default; pass `serve --open` only if you deliberately want to
|
||||
disable it.) See [Clients](/docs/clients) and [Pairing](/docs/pairing).
|
||||
|
||||
## Appendix — build from source (PKGBUILD)
|
||||
|
||||
To build instead of using the binary repo, use the split `PKGBUILD` in `packaging/arch/` (produces
|
||||
`punktfunk-host` + `punktfunk-client`; set `PF_WITH_WEB=1` to also build `punktfunk-web`, which needs
|
||||
`bun`):
|
||||
|
||||
```sh
|
||||
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk/packaging/arch
|
||||
# Build the working tree (no git fetch):
|
||||
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
|
||||
sudo pacman -U punktfunk-host-*.pkg.tar.zst
|
||||
```
|
||||
|
||||
NVENC/EGL come from the NVIDIA driver (`nvidia-utils`); on a GPU-less builder, symlink the CUDA
|
||||
stub into the link path first (the `PKGBUILD` header documents this). Full details, the
|
||||
Fedora→Arch dependency map, and the SteamOS systemd-sysext path are in
|
||||
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md).
|
||||
@@ -24,36 +24,43 @@ mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam
|
||||
|
||||
## Install
|
||||
|
||||
The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora
|
||||
Atomic box layers and updates it with `rpm-ostree`. Add the repo, then layer the host plus the web
|
||||
console and reboot:
|
||||
The host installs as a **systemd system extension (sysext)** — no `rpm-ostree` layering. The
|
||||
Bazzite docs treat layering as a last resort (layered packages slow every OS update and can block
|
||||
upgrades until removed); a sysext never enters an rpm-ostree transaction: it overlays `/usr`
|
||||
read-only from `/var/lib/extensions/`, survives OS updates, installs and updates **without a
|
||||
reboot**, and is removable in one command. This is the same mechanism the Fedora Atomic
|
||||
maintainers ship via the [fedora-sysexts](https://fedora-sysexts.github.io/) project.
|
||||
|
||||
```sh
|
||||
# Add the repo. Packages are GPG-signed (gpgcheck=1, the packages@unom.io key) AND the repo
|
||||
# metadata is Gitea-signed (repo_gpgcheck=1); gpgkey lists both keys so dnf imports each.
|
||||
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=1
|
||||
repo_gpgcheck=1
|
||||
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
|
||||
https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk
|
||||
REPO
|
||||
|
||||
# Layer the host + the web console, then reboot into the new deployment.
|
||||
# (punktfunk Recommends punktfunk-web; list it explicitly so it's pulled regardless of weak-dep
|
||||
# settings — the Gitea registry carries punktfunk-web, which COPR can't build.)
|
||||
rpm-ostree install punktfunk punktfunk-web
|
||||
systemctl reboot
|
||||
# One-time bootstrap (afterwards the updater is on PATH as `punktfunk-sysext`):
|
||||
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
|
||||
sudo bash punktfunk-sysext.sh install # add `--channel canary` for rolling builds
|
||||
```
|
||||
|
||||
`rpm-ostree upgrade` then tracks new builds automatically (Bazzite's auto-update timer does this
|
||||
for you). For a fully baked appliance image there's also a **bootc** Containerfile that installs
|
||||
the same RPMs from this registry — see `packaging/bootc/` and `packaging/rpm/README.md` in the repo.
|
||||
Building from source works too (Bazzite is Fedora Atomic underneath, and its FFmpeg builds the host
|
||||
fine — same steps as [Fedora KDE](/docs/fedora-kde)), but the registry is the supported path.
|
||||
That downloads the newest image (host + tray + web console, SHA-256-verified over HTTPS from
|
||||
punktfunk's package registry), merges it, and applies the udev/sysctl setup on the spot — the
|
||||
host is usable immediately, no reboot. From then on:
|
||||
|
||||
```sh
|
||||
sudo punktfunk-sysext update # fetch + merge the newest build
|
||||
sudo punktfunk-sysext status # channel, installed vs latest version
|
||||
sudo punktfunk-sysext remove # unmerge and delete — the box is back to stock
|
||||
```
|
||||
|
||||
Two things to know:
|
||||
|
||||
- **After a Bazzite major rebase** (Fedora 43 → 44) the old image **refuses to load** rather than
|
||||
run against mismatched system libraries — run `sudo punktfunk-sysext update` once and it fetches
|
||||
the image built for the new base.
|
||||
- **Already layering punktfunk?** Install the sysext (it shadows the layered copy immediately),
|
||||
then drop the layer so it stops slowing your updates:
|
||||
`sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
|
||||
|
||||
For a fully baked appliance image there's also a **bootc** Containerfile that installs the RPMs
|
||||
from the registry at image-build time — see `packaging/bootc/` in the repo. Plain `rpm-ostree`
|
||||
layering from the [RPM registry](https://git.unom.io/unom/-/packages) keeps working too (see
|
||||
`packaging/bazzite/README.md`), but the sysext is the supported default. Building from source
|
||||
also works (Bazzite is Fedora Atomic underneath — same steps as [Fedora KDE](/docs/fedora-kde)).
|
||||
|
||||
## Allow controller input
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ track per machine; switching is a one-line change.
|
||||
|---|---|---|
|
||||
| **apt** (host/client) | `deb [signed-by=…] https://git.unom.io/api/packages/unom/debian canary main` | `… debian stable main` |
|
||||
| **rpm** (host) | baseurl `…/rpm/bazzite-canary` (or `fedora-44-canary`) | `…/rpm/bazzite` (or `fedora-44`) |
|
||||
| **sysext** (Bazzite host) | `sudo punktfunk-sysext install --channel canary` | `… install` / default (feeds `…/punktfunk-sysext/f43[-canary]`) |
|
||||
| **pacman** (Arch host/client) | `[punktfunk-canary]` repo section | `[punktfunk]` (`Server = …/api/packages/unom/arch/$repo/$arch`) |
|
||||
| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` |
|
||||
| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` |
|
||||
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
|
||||
|
||||
@@ -47,7 +47,7 @@ It ships as a real package, not just a source build — full steps in
|
||||
`flatpak update`; this is also what the [Decky plugin](/docs/steam-deck) launches.
|
||||
- **Ubuntu / Debian** — `apt install punktfunk-client` from the punktfunk apt registry.
|
||||
- **Fedora / Bazzite** — `rpm-ostree install punktfunk-client` from the Gitea RPM registry.
|
||||
- **Arch / SteamOS** — the `punktfunk-client` split package from the `PKGBUILD`.
|
||||
- **Arch** — `sudo pacman -Sy punktfunk-client` from the signed binary repo (see [Arch Linux](/docs/arch)).
|
||||
|
||||
Launch it, pick your host from the list, and stream. For scripting you can skip the host list and
|
||||
connect straight away:
|
||||
|
||||
@@ -48,7 +48,7 @@ see the linked guide — then it tracks updates with your normal `apt upgrade` /
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu / Debian** | `sudo apt install punktfunk-client` | [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
|
||||
| **Fedora / Bazzite** | `rpm-ostree install punktfunk-client` | [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
|
||||
| **Arch / SteamOS** | `punktfunk-client` from the `PKGBUILD` | [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
|
||||
| **Arch** | `sudo pacman -Sy punktfunk-client` (signed binary repo) | [Arch Linux](/docs/arch) |
|
||||
|
||||
Then launch it, pick your host from the list, and stream. For scripting, skip the picker:
|
||||
|
||||
|
||||
@@ -17,13 +17,14 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
|
||||
| Distro | Package manager | One-command happy path | Guide |
|
||||
|--------|-----------------|------------------------|-------|
|
||||
| **Ubuntu / Debian** | apt | `sudo apt install punktfunk-host` | [Ubuntu — GNOME](/docs/ubuntu-gnome) · [Ubuntu — KDE](/docs/ubuntu-kde) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
|
||||
| **Fedora / Bazzite** | rpm-ostree | `rpm-ostree install punktfunk punktfunk-web` | [Fedora — KDE](/docs/fedora-kde) · [Bazzite](/docs/bazzite) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
|
||||
| **Arch** | PKGBUILD | `makepkg -si` | [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
|
||||
| **Bazzite / Fedora Atomic** | systemd-sysext | `sudo bash punktfunk-sysext.sh install` (no layering, no reboot) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) |
|
||||
| **Fedora (dnf)** | dnf / rpm-ostree | `dnf install punktfunk punktfunk-web` | [Fedora — KDE](/docs/fedora-kde) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
|
||||
| **Arch** | pacman | `pacman -Sy punktfunk-host` (binary repo) | [Arch Linux](/docs/arch) · [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
|
||||
| **SteamOS (host)** | on-device script | `bash scripts/steamdeck/install.sh` | [SteamOS (Host)](/docs/steamos-host) |
|
||||
|
||||
Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a
|
||||
one-time step covered in the linked guide; after that, normal `apt upgrade` / `rpm-ostree upgrade`
|
||||
tracks new builds automatically.
|
||||
one-time step covered in the linked guide; after that, normal `apt upgrade` / `dnf upgrade` /
|
||||
`pacman -Syu` (or `sudo punktfunk-sysext update` on Bazzite) tracks new builds.
|
||||
|
||||
> **Stable vs canary.** The repos in the per-distro guides are the **stable** channel — it only
|
||||
> moves when a `vX.Y.Z` release is cut. For the latest `main` build (fast, possibly broken), point
|
||||
@@ -59,7 +60,8 @@ fallback without one. More detail — including the CLI `punktfunk-host service
|
||||
|
||||
- **`punktfunk-host`** — the streaming host. Install this on your Linux gaming machine.
|
||||
- **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the
|
||||
host; on RPM list it explicitly (`rpm-ostree install punktfunk punktfunk-web`).
|
||||
host; on RPM list it explicitly (`dnf install punktfunk punktfunk-web`) — the Bazzite sysext
|
||||
image already includes it.
|
||||
- **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via
|
||||
apt / RPM / Arch / Flatpak). On a Steam Deck, this is the package you want.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"ubuntu-gnome",
|
||||
"ubuntu-kde",
|
||||
"fedora-kde",
|
||||
"arch",
|
||||
"bazzite",
|
||||
"steamos-host",
|
||||
"windows-host",
|
||||
|
||||
@@ -128,6 +128,16 @@ virtual display is a real monitor: any process already running in your desktop s
|
||||
through the ordinary OS screen-capture APIs, exactly as it could capture a physical monitor. That floor
|
||||
is the same for every virtual-display streaming stack.
|
||||
|
||||
One nuance specific to how the Windows host captures: because it reads the composed desktop image (what
|
||||
the monitor shows) rather than going through Windows' screen-capture APIs, a window that hides itself
|
||||
from *recording* tools with `WDA_EXCLUDEFROMCAPTURE` still appears in the stream — just as it appears to
|
||||
anyone looking at the physical screen. Conversely, DRM-protected video (Netflix and the like) is blanked
|
||||
by Windows for any capture path, so it shows as black rather than the protected frames. Neither weakens
|
||||
Windows' protections: the first is exactly what a person at the screen already sees, and the second is
|
||||
Windows enforcing its own rule. The consistent way to think about it is the one from the top of this
|
||||
page — **a connected client sees and does what a person sitting at that machine could**, no more (and,
|
||||
for DRM content, slightly less).
|
||||
|
||||
**Recommendation:** run the Windows host on a **dedicated or gaming PC**, not on a machine that also
|
||||
holds your most sensitive material (work laptop, financial records, the box with your password vault).
|
||||
A gaming rig you stream from is a great fit; your primary secrets machine is not.
|
||||
|
||||
@@ -17,7 +17,17 @@
|
||||
//
|
||||
// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
|
||||
// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
||||
#define ABI_VERSION 2
|
||||
// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
|
||||
// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
|
||||
#define ABI_VERSION 3
|
||||
|
||||
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
|
||||
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
|
||||
// (functions a client links), which can grow without changing a single wire byte — v3's
|
||||
// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
|
||||
// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
|
||||
// live). Bump this ONLY when the handshake/planes actually change incompatibly.
|
||||
#define WIRE_VERSION 2
|
||||
|
||||
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
||||
#define PUNKTFUNK_HIDOUT_LED 1
|
||||
@@ -804,6 +814,23 @@ extern "C" {
|
||||
// Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core.
|
||||
uint32_t punktfunk_abi_version(void);
|
||||
|
||||
// Send a Wake-on-LAN magic packet to wake sleeping host NIC(s).
|
||||
//
|
||||
// `macs` points to `mac_count` contiguous 6-byte MAC addresses (`mac_count * 6` bytes total) —
|
||||
// a host may report several NICs; all are woken. `last_known_ip`, if non-NULL, is an IPv4
|
||||
// dotted-quad string additionally targeted by unicast (pass NULL to skip). The packet is
|
||||
// broadcast to every local interface's subnet-directed broadcast and to `255.255.255.255` on
|
||||
// ports 9 and 7. This does NOT require an open connection and is not part of the QUIC surface.
|
||||
//
|
||||
// Returns `Ok` if at least one datagram was sent. Call off the UI thread.
|
||||
//
|
||||
// # Safety
|
||||
// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL,
|
||||
// must be a NUL-terminated string.
|
||||
PunktfunkStatus punktfunk_wake_on_lan(const uint8_t *macs,
|
||||
uintptr_t mac_count,
|
||||
const char *last_known_ip);
|
||||
|
||||
// Create a session over a real UDP transport (`local`/`peer` are `host:port` strings).
|
||||
// Returns NULL on error.
|
||||
//
|
||||
|
||||
+25
-8
@@ -17,13 +17,15 @@ packaging/
|
||||
rpm/punktfunk.spec # the RPM (builds punktfunk-host from source with cargo)
|
||||
bazzite/host.env # gamescope-default config for a Bazzite appliance
|
||||
bazzite/README.md # step-by-step Bazzite setup guide
|
||||
bazzite/*sysext*.sh # the no-layering path: build/install/publish the systemd-sysext
|
||||
bootc/Containerfile # bake punktfunk into a Bazzite-based atomic image
|
||||
copr/ # COPR build-from-SCM settings
|
||||
```
|
||||
|
||||
The other packaging targets have their own READMEs: [`debian/`](debian/README.md) (apt),
|
||||
[`arch/`](arch/README.md) (PKGBUILD + sysext), [`flatpak/`](flatpak/README.md) (the client),
|
||||
[`windows/`](windows/README.md) (host installer + drivers), plus `kde/` and `linux/` helpers.
|
||||
[`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext),
|
||||
[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
|
||||
drivers), plus `kde/` and `linux/` helpers.
|
||||
|
||||
## What's needed beyond base Fedora
|
||||
|
||||
@@ -38,7 +40,22 @@ 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**
|
||||
(`packaging/bazzite/host.env`), which the host spawns headless per session — no desktop login.
|
||||
|
||||
## Option A — Gitea RPM registry (recommended; per-host, `rpm-ostree`)
|
||||
## Option A — systemd-sysext (recommended; no layering, no reboot)
|
||||
|
||||
On Bazzite / Fedora Atomic the recommended install is the **systemd-sysext** image — rpm-ostree
|
||||
layering is a last resort per the Bazzite docs (it slows every OS update and can block upgrades),
|
||||
while a sysext overlays `/usr` at runtime, survives OS updates, and updates in one command with
|
||||
no reboot. CI wraps the same RPMs below into the image, so content and channels are identical.
|
||||
|
||||
```sh
|
||||
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
|
||||
sudo bash punktfunk-sysext.sh install # then: sudo punktfunk-sysext update | status | remove
|
||||
```
|
||||
|
||||
Full walkthrough (incl. the F43→F44 rebase behavior and migration off layering):
|
||||
[`bazzite/README.md`](bazzite/README.md).
|
||||
|
||||
## Option B — Gitea RPM registry (per-host, `rpm-ostree` layering)
|
||||
|
||||
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
|
||||
@@ -60,7 +77,7 @@ rpm-ostree install punktfunk && systemctl reboot
|
||||
# updates: rpm-ostree upgrade && systemctl reboot
|
||||
```
|
||||
|
||||
## Option B — COPR (per-host, `rpm-ostree install`)
|
||||
## Option C — COPR (per-host, `rpm-ostree install`)
|
||||
|
||||
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
|
||||
@@ -78,7 +95,7 @@ rpm-ostree install punktfunk && systemctl reboot
|
||||
systemctl reboot
|
||||
```
|
||||
|
||||
## Option C — bootc (image-based, atomic)
|
||||
## Option D — bootc (image-based, atomic)
|
||||
|
||||
Layer punktfunk into a Bazzite image once, then rebase any number of hosts onto it — no
|
||||
per-host drift. See `bootc/Containerfile`:
|
||||
@@ -89,7 +106,7 @@ podman push ghcr.io/<you>/bazzite-punktfunk
|
||||
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
|
||||
```
|
||||
|
||||
## First-run setup (either option)
|
||||
## First-run setup (all options)
|
||||
|
||||
```sh
|
||||
ujust add-user-to-input-group # virtual gamepads need /dev/uinput (then re-login).
|
||||
@@ -109,8 +126,8 @@ web console at `https://<host-ip>:47992` or directly.
|
||||
|
||||
> ⚠️ **COPR caveat:** COPR's mock chroot has no `bun`, so a COPR build produces only
|
||||
> `punktfunk` + `punktfunk-client` — **not** `punktfunk-web`. For the console on a COPR/bootc host,
|
||||
> install from the **Gitea RPM registry** (Option A — it carries `punktfunk-web`), which is also why
|
||||
> `bootc/Containerfile` installs from there rather than COPR.
|
||||
> install from the **Gitea RPM registry** (Option B — it carries `punktfunk-web`; the sysext image
|
||||
> includes it too), which is also why `bootc/Containerfile` installs from there rather than COPR.
|
||||
|
||||
## Why not Flatpak (for the HOST)?
|
||||
|
||||
|
||||
+21
-7
@@ -10,20 +10,28 @@
|
||||
# - In-tree / CI: PF_SRCDIR=$(git rev-parse --show-toplevel) makepkg --holdver
|
||||
# (builds the working tree instead of the tagged source — see build()).
|
||||
#
|
||||
# IMPORTANT: host encode is NVENC-only (crates/punktfunk-host/src/encode/linux.rs) — functional on
|
||||
# NVIDIA hosts; an AMD Deck-as-HOST needs a VAAPI backend first. The CLIENT decodes via VAAPI
|
||||
# (AMD/Intel, incl. the Deck) with a software fallback, so it works everywhere. See README.md.
|
||||
# Host encode: NVENC on NVIDIA (nvidia-utils), VAAPI on AMD/Intel (mesa) — PUNKTFUNK_ENCODER=auto
|
||||
# picks per GPU. The CLIENT decodes via VAAPI (AMD/Intel, incl. the Deck) with a software
|
||||
# fallback, so it works everywhere. See README.md.
|
||||
pkgbase=punktfunk
|
||||
# punktfunk-web (the browser console) is OPT-IN: building it needs `bun` (AUR-only as bun-bin on
|
||||
# stock Arch/SteamOS), so a default makepkg builds only host+client with no JS tooling — mirroring
|
||||
# the RPM spec's `%bcond_with web` (off by default). Set PF_WITH_WEB=1 to also build punktfunk-web
|
||||
# (appended to pkgname + bun to makedepends below).
|
||||
pkgname=('punktfunk-host' 'punktfunk-client')
|
||||
pkgver=0.2.0
|
||||
pkgrel=1
|
||||
# CI (.gitea/workflows/arch.yml) drives the version: stable tags -> X.Y.Z-1, main pushes ->
|
||||
# X.Y.Z-0.<run#> in the separate punktfunk-canary repo (mirrors the RPM's 0.ciN release; pkgrel
|
||||
# allows only digits+dots, so the run number carries the monotonic ordering).
|
||||
pkgver="${PF_PKGVER:-0.7.0}"
|
||||
pkgrel="${PF_PKGREL:-1}"
|
||||
arch=('x86_64')
|
||||
url="https://git.unom.io/unom/punktfunk"
|
||||
license=('MIT OR Apache-2.0')
|
||||
# !lto: makepkg's `lto` option injects -flto=auto into CFLAGS; aws-lc-sys (rustls' crypto)
|
||||
# compiles its C with those flags and GCC LTO bitcode objects are unreadable by rust's lld
|
||||
# linker -> "undefined symbol: aws_lc_*" at link (reproduced 2026-07-04, Arch + rust 1.90).
|
||||
# !debug: skip the -debug split package (debuginfo bloat, not shipped).
|
||||
options=('!lto' '!debug')
|
||||
|
||||
# All build deps for both crates (Arch runtime packages ship their own headers, so these cover
|
||||
# build + link). aws-lc/ring need clang+cmake; nasm is for asm.
|
||||
@@ -36,10 +44,16 @@ if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
|
||||
fi
|
||||
|
||||
# AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root and
|
||||
# build() uses it instead; see the README.
|
||||
# AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root —
|
||||
# build() uses it instead AND the fetch is skipped entirely (a canary pkgver has no tag to
|
||||
# clone, and CI already has the checkout).
|
||||
if [ -z "${PF_SRCDIR:-}" ]; then
|
||||
source=("git+https://git.unom.io/unom/punktfunk.git#tag=v${pkgver}")
|
||||
sha256sums=('SKIP')
|
||||
else
|
||||
source=()
|
||||
sha256sums=()
|
||||
fi
|
||||
|
||||
_repo() { printf '%s' "${PF_SRCDIR:-$srcdir/punktfunk}"; }
|
||||
|
||||
|
||||
@@ -23,7 +23,45 @@ default `makepkg` builds only host+client with no JS tooling — mirroring the R
|
||||
> Arch + NVIDIA **and** AMD/Intel (incl. the Steam Deck — see the on-device path above). The client
|
||||
> decodes via VAAPI on AMD/Intel with a software fallback.
|
||||
|
||||
## Arch Linux (mutable)
|
||||
## Install from the binary repo (recommended)
|
||||
|
||||
CI (`.gitea/workflows/arch.yml`) builds this PKGBUILD in an `archlinux:base-devel` container on
|
||||
every push and publishes the packages to the **Gitea Arch package registry** — a plain pacman
|
||||
repo, so an Arch box installs and updates punktfunk with `pacman -Syu` like everything else.
|
||||
Two repos mirror the deb/rpm channels: `punktfunk` (release tags) and `punktfunk-canary`
|
||||
(rolling main-branch builds, versioned `X.Y.Z-0.<run#>` so a later release always outranks
|
||||
them). Enable exactly one.
|
||||
|
||||
The registry **signs the repo database and every package**, so first import its key into
|
||||
pacman's keyring (a one-time step — after this, packages install signature-verified):
|
||||
|
||||
```sh
|
||||
# 1. Trust the registry signing key.
|
||||
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
|
||||
| sudo pacman-key --add -
|
||||
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
|
||||
|
||||
# 2. Add the repo (pick ONE channel — punktfunk for releases, punktfunk-canary for main builds).
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
|
||||
|
||||
[punktfunk]
|
||||
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
|
||||
EOF
|
||||
|
||||
# 3. Sync + install.
|
||||
sudo pacman -Sy punktfunk-host # gaming rig
|
||||
sudo pacman -Sy punktfunk-client # couch/Deck side
|
||||
sudo pacman -Sy punktfunk-web # optional browser management console
|
||||
```
|
||||
|
||||
(No `SigLevel` line needed — pacman's default `Required DatabaseOptional` verifies the signed
|
||||
packages against the key you just trusted. Arch is rolling, so the packages are built against
|
||||
current Arch sonames — keep the box itself updated too.)
|
||||
|
||||
Then the same first-run steps as a source build (printed by the install scriptlet): `input`
|
||||
group, `host.env`, `systemctl --user enable --now punktfunk-host` — see the next section.
|
||||
|
||||
## Build from source — Arch Linux (mutable)
|
||||
|
||||
```sh
|
||||
cd packaging/arch
|
||||
|
||||
+75
-41
@@ -12,34 +12,91 @@ flagged explicitly. For the higher-level packaging rationale ("why not Flatpak",
|
||||
> NVENC, from RPM Fusion **nonfree**), `opus`, and `libei`.
|
||||
> Source: `packaging/README.md`, `packaging/rpm/punktfunk.spec`.
|
||||
|
||||
> ⚠️ **Read this first — the COPR is operator-run, not yet published.**
|
||||
> Both install paths below pull the punktfunk RPM from a COPR project named
|
||||
> `enricobuehler/punktfunk`. That COPR is a configuration the maintainer has to **create and
|
||||
> build** (see `packaging/copr/README.md` — it documents how to set it up, not a live repo URL you
|
||||
> can assume exists). If `rpm-ostree install punktfunk` 404s, the COPR hasn't been published yet,
|
||||
> and your only path is to **build the RPM yourself** (see the appendix). The guide flags every
|
||||
> command that depends on the COPR being live.
|
||||
> ⚠️ **COPR note (Path C only).** The legacy layering path's commands reference a COPR project
|
||||
> named `enricobuehler/punktfunk` that is operator-run and may not be published (see
|
||||
> `packaging/copr/README.md`); layer from the **Gitea RPM registry** instead (`../rpm/README.md`,
|
||||
> the repo file `https://git.unom.io/api/packages/unom/rpm/bazzite.repo`) — it's what CI
|
||||
> actually publishes to. Paths A (sysext) and B (bootc) don't involve the COPR at all.
|
||||
|
||||
---
|
||||
|
||||
## 1. Choose an install path
|
||||
|
||||
There are two supported paths on Bazzite, driven by different files in `packaging/`:
|
||||
There are three paths on Bazzite, driven by different files in `packaging/`:
|
||||
|
||||
| Path | Driven by | What it does | Best for |
|
||||
|---|---|---|---|
|
||||
| **A — rpm-ostree layering** | `packaging/copr/README.md` + `packaging/rpm/punktfunk.spec` | Layers the `punktfunk` RPM onto your existing Bazzite deployment with `rpm-ostree install` | One host, quick iteration |
|
||||
| **A — systemd-sysext** ✅ recommended | `packaging/bazzite/punktfunk-sysext.sh` + `build-sysext.sh` (published by `.gitea/workflows/rpm.yml`) | Overlays the host onto `/usr` as a system extension — no layering, no reboot, one-command updates | Everyone; the default |
|
||||
| **B — bootc / OCI image** | `packaging/bootc/Containerfile` | Bakes punktfunk into a `FROM bazzite-nvidia` image once; you `bootc switch` any number of hosts onto it | Fleets, reproducible appliances, no per-host drift |
|
||||
| **C — rpm-ostree layering** (legacy) | `packaging/rpm/` + the Gitea RPM registry | Layers the `punktfunk` RPM onto your deployment with `rpm-ostree install` | Only if you specifically want the RPM database to own the files |
|
||||
|
||||
**Trade-off:** Path A is a per-host package layer — simple, but each host accumulates its own
|
||||
layered-package state. Path B builds one image (RPM Fusion + the Gitea RPM repo + the host and
|
||||
**web console** + udev rule pre-installed) that you push to a registry and rebase hosts onto
|
||||
atomically — no per-host `rpm-ostree install` drift, at the cost of running a `podman build`/`push`
|
||||
pipeline. Both require the **same first-run setup** (sections 3–6); note Path B installs from the
|
||||
**Gitea RPM registry** (which carries `punktfunk-web`), whereas Path A's COPR builds host+client
|
||||
only — for the web console on Path A, layer from the Gitea registry instead (`../rpm/README.md`).
|
||||
**Why A over C:** the Bazzite docs treat layering as a last resort — every layered package makes
|
||||
every OS update slower and can **block upgrades entirely** until removed. A sysext never enters an
|
||||
rpm-ostree transaction: it merges/unmerges at runtime, survives OS updates, and updating punktfunk
|
||||
is one command with **no reboot** (layering needs one per update). It's the mechanism the Fedora
|
||||
Atomic maintainers ship via [fedora-sysexts](https://fedora-sysexts.github.io/). All paths require
|
||||
the **same first-run setup** (sections 3–6).
|
||||
|
||||
### Path A — rpm-ostree layering from the COPR
|
||||
### Path A — systemd-sysext (recommended)
|
||||
|
||||
Run on the Bazzite host:
|
||||
|
||||
```sh
|
||||
# One-time bootstrap; afterwards the tool is on PATH as `punktfunk-sysext` (it ships inside
|
||||
# the image). `--channel canary` for rolling main-branch builds instead of releases.
|
||||
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
|
||||
sudo bash punktfunk-sysext.sh install
|
||||
```
|
||||
|
||||
This downloads the newest image for your Fedora base (host + tray + **web console**,
|
||||
SHA-256-verified from the feed `…/packages/unom/generic/punktfunk-sysext/f<ver>[-canary]/`),
|
||||
installs it as `/var/lib/extensions/punktfunk.raw`, merges it, and immediately applies what the
|
||||
RPM scriptlets would have (udev reload, sysctl) plus the two `/etc` files a sysext can't carry
|
||||
(the gamescope-session drop-in and the tray autostart entry, staged under
|
||||
`/usr/share/punktfunk/etc/`). No reboot at any point. Day-2:
|
||||
|
||||
```sh
|
||||
sudo punktfunk-sysext update # fetch + merge the newest build (then restart the user service)
|
||||
sudo punktfunk-sysext status # merged?, installed vs latest, channel/feed
|
||||
sudo punktfunk-sysext remove # unmerge + delete; ~/.config/punktfunk is left alone
|
||||
```
|
||||
|
||||
Details worth knowing:
|
||||
|
||||
- The image embeds `ID=fedora` + `VERSION_ID` (matched through Bazzite's `ID_LIKE`), so after a
|
||||
**major Bazzite rebase** (F43 → F44) the old image is **refused** instead of merging
|
||||
soname-broken binaries — `punktfunk-sysext update` then fetches the image built for the new
|
||||
base (feeds exist per Fedora major, from the same CI matrix as the RPM groups).
|
||||
- SELinux labels are baked into the image at build time (squashfs pseudo-xattrs computed from
|
||||
the targeted policy) — without them udev couldn't read the gamepad rule under enforcing.
|
||||
Validated live on Bazzite 43.
|
||||
- **Migrating from layering (path C):** install the sysext (it shadows the layered copy at
|
||||
once), then `sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
|
||||
|
||||
### Path B — bootc image (`FROM bazzite-nvidia`)
|
||||
|
||||
The image is built **off-host** (on any machine with `podman`) from
|
||||
`packaging/bootc/Containerfile`, which bases on `ghcr.io/ublue-os/bazzite-nvidia:stable`
|
||||
(override with `--build-arg BASE_IMAGE=…`), enables RPM Fusion free + nonfree, adds the Gitea RPM
|
||||
repo (`--build-arg PUNKTFUNK_RPM_GROUP=…`, default `bazzite`), and installs the host **and the web
|
||||
console** (`punktfunk punktfunk-web`). It uses the Gitea registry rather than the COPR specifically
|
||||
because the registry carries `punktfunk-web` (COPR's mock chroot can't build it — no `bun`).
|
||||
|
||||
```sh
|
||||
# Build + push (run from the repo root, on your builder machine):
|
||||
podman build -t ghcr.io/<you>/bazzite-punktfunk -f packaging/bootc/Containerfile .
|
||||
podman push ghcr.io/<you>/bazzite-punktfunk
|
||||
|
||||
# On each target Bazzite host:
|
||||
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
|
||||
```
|
||||
|
||||
> ⚠️ The image installs from the **Gitea RPM registry** (group `bazzite`), so **Path B depends on
|
||||
> that registry being populated** — CI (`.gitea/workflows/rpm.yml`) publishes `punktfunk` +
|
||||
> `punktfunk-web` on every push to `main`. Packages are unsigned with GPG-signed metadata
|
||||
> (`repo_gpgcheck=1`), matching `packaging/rpm/README.md`.
|
||||
|
||||
### Path C — rpm-ostree layering (legacy)
|
||||
|
||||
Run on the Bazzite host. (Commands verbatim from `packaging/README.md`.)
|
||||
|
||||
@@ -62,7 +119,7 @@ systemctl reboot
|
||||
> The **reboot is mandatory** — `rpm-ostree install` stages a new deployment that only takes
|
||||
> effect on the next boot. This is normal atomic-distro behavior, not a punktfunk quirk.
|
||||
|
||||
#### Updating a Path-A host — `rpm-ostree upgrade` is NOT enough
|
||||
#### Updating a Path-C host — `rpm-ostree upgrade` is NOT enough
|
||||
|
||||
> ⚠️ **`rpm-ostree upgrade` will not update punktfunk on its own.** `upgrade` bumps the **base
|
||||
> image** and only re-resolves *layered* packages **when the base changes**. A Bazzite base can
|
||||
@@ -94,29 +151,6 @@ sudo bash packaging/bazzite/update-punktfunk.sh --reboot # stage + reboot now
|
||||
> `punktfunk.repo`, canary's `<next-minor>.0-0.ciN` **outranks** the stable `X.Y.Z-1` and the box
|
||||
> silently tracks canary. Enable exactly one channel — set `enabled=0` in the other repo file.
|
||||
|
||||
### Path B — bootc image (`FROM bazzite-nvidia`)
|
||||
|
||||
The image is built **off-host** (on any machine with `podman`) from
|
||||
`packaging/bootc/Containerfile`, which bases on `ghcr.io/ublue-os/bazzite-nvidia:stable`
|
||||
(override with `--build-arg BASE_IMAGE=…`), enables RPM Fusion free + nonfree, adds the Gitea RPM
|
||||
repo (`--build-arg PUNKTFUNK_RPM_GROUP=…`, default `bazzite`), and installs the host **and the web
|
||||
console** (`punktfunk punktfunk-web`). It uses the Gitea registry rather than the COPR specifically
|
||||
because the registry carries `punktfunk-web` (COPR's mock chroot can't build it — no `bun`).
|
||||
|
||||
```sh
|
||||
# Build + push (run from the repo root, on your builder machine):
|
||||
podman build -t ghcr.io/<you>/bazzite-punktfunk -f packaging/bootc/Containerfile .
|
||||
podman push ghcr.io/<you>/bazzite-punktfunk
|
||||
|
||||
# On each target Bazzite host:
|
||||
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
|
||||
```
|
||||
|
||||
> ⚠️ The image installs from the **Gitea RPM registry** (group `bazzite`), so **Path B depends on
|
||||
> that registry being populated** — CI (`.gitea/workflows/rpm.yml`) publishes `punktfunk` +
|
||||
> `punktfunk-web` on every push to `main`. Packages are unsigned with GPG-signed metadata
|
||||
> (`repo_gpgcheck=1`), matching `packaging/rpm/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prerequisites — what Bazzite gives you vs. what you must still do
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the punktfunk systemd-sysext image for Bazzite / Fedora Atomic from the built RPMs —
|
||||
# the no-layering install path (rpm-ostree layering slows every update and can block upgrades;
|
||||
# a sysext never enters an rpm-ostree transaction). The .raw overlays /usr read-only from
|
||||
# /var/lib/extensions/, survives OS updates, and is toggled/updated without a reboot.
|
||||
#
|
||||
# Counterpart to ../arch/build-sysext.sh (which wraps a pacman package for SteamOS). This one
|
||||
# wraps the Fedora RPMs (punktfunk + punktfunk-web) and additionally:
|
||||
# * relocates the RPMs' /etc payload to /usr/share/punktfunk/etc/ (a sysext carries ONLY /usr;
|
||||
# punktfunk-sysext(8) copies these into the real /etc on install),
|
||||
# * bakes SELinux labels in as squashfs pseudo-xattrs, computed with matchpathcon from the
|
||||
# build container's targeted policy. Without them every file is unlabeled_t at runtime:
|
||||
# fine for the user session + systemd --user units (unconfined), but system daemons are
|
||||
# DENIED — udev couldn't read 60-punktfunk.rules and systemd-sysctl couldn't read the
|
||||
# sysctl drop-in (validated live on Bazzite 43, SELinux enforcing, 2026-07-04),
|
||||
# * pins compatibility via ID=fedora + VERSION_ID: merges on Bazzite/Silverblue/Aurora of the
|
||||
# SAME Fedora major (ID_LIKE matching, systemd >= 256) and is REFUSED after a major rebase
|
||||
# instead of running soname-broken binaries (`punktfunk-sysext update` then re-resolves),
|
||||
# * embeds the punktfunk-sysext helper so an installed box can update itself.
|
||||
#
|
||||
# Build in the matching Fedora container (ci/fedora*-rpm.Dockerfile) — matchpathcon needs the
|
||||
# Fedora targeted policy (libselinux-utils + selinux-policy-targeted), and the RPMs are
|
||||
# soname-coupled to their base anyway. Needs: rpm2cpio, cpio, mksquashfs (>= 4.6), matchpathcon.
|
||||
#
|
||||
# Usage:
|
||||
# bash build-sysext.sh --version-id 43 --out dist/punktfunk-0.7.1-1-x86-64.raw \
|
||||
# dist/punktfunk-0.7.1-1.fc43.x86_64.rpm dist/punktfunk-web-0.7.1-1.fc43.noarch.rpm
|
||||
#
|
||||
# The installed image MUST be named punktfunk.raw (the embedded extension-release marker is
|
||||
# extension-release.punktfunk; systemd-sysext requires marker == image name) — the feed carries
|
||||
# versioned filenames and punktfunk-sysext installs to the fixed name.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION_ID="" OUT="" RPMS=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version-id) VERSION_ID="${2:?}"; shift 2 ;;
|
||||
--out) OUT="${2:?}"; shift 2 ;;
|
||||
*) RPMS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$VERSION_ID" ] || { echo "missing --version-id <fedora major, e.g. 43>" >&2; exit 1; }
|
||||
[ -n "$OUT" ] || { echo "missing --out <image.raw>" >&2; exit 1; }
|
||||
[ "${#RPMS[@]}" -gt 0 ] || { echo "no RPMs given" >&2; exit 1; }
|
||||
for tool in rpm2cpio cpio mksquashfs matchpathcon; do
|
||||
command -v "$tool" >/dev/null || { echo "missing tool: $tool" >&2; exit 1; }
|
||||
done
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
# SYSEXT_VERSION_ID from the punktfunk RPM (V-R without the dist tag): what
|
||||
# `punktfunk-sysext status` reports as the installed version.
|
||||
PF_VR=""
|
||||
SEEN_NAMES=" "
|
||||
for rpm in "${RPMS[@]}"; do
|
||||
[ -f "$rpm" ] || { echo "no such RPM: $rpm" >&2; exit 1; }
|
||||
name="$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)"
|
||||
# Two RPMs of the same NAME (e.g. a stale noarch next to the current x86_64 from a sloppy
|
||||
# download glob) silently shadow each other's files — refuse instead of building a chimera.
|
||||
case "$SEEN_NAMES" in *" $name "*) echo "duplicate RPM name '$name' in inputs — pass exactly one RPM per package" >&2; exit 1 ;; esac
|
||||
SEEN_NAMES="$SEEN_NAMES$name "
|
||||
if [ "$name" = punktfunk ]; then
|
||||
PF_VR="$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)"
|
||||
PF_VR="${PF_VR%.fc*}"
|
||||
fi
|
||||
rpm2cpio "$rpm" | ( cd "$STAGE" && cpio -idmu --quiet )
|
||||
done
|
||||
[ -n "$PF_VR" ] || { echo "the punktfunk (host) RPM must be among the inputs" >&2; exit 1; }
|
||||
|
||||
# A sysext carries only /usr. Relocate the RPMs' /etc payload (gamescope-session drop-in, tray
|
||||
# autostart entry) under /usr/share/punktfunk/etc/ — punktfunk-sysext copies it into /etc.
|
||||
if [ -d "$STAGE/etc" ]; then
|
||||
mkdir -p "$STAGE/usr/share/punktfunk/etc"
|
||||
cp -a "$STAGE/etc/." "$STAGE/usr/share/punktfunk/etc/"
|
||||
rm -rf "${STAGE:?}/etc"
|
||||
fi
|
||||
rm -rf "${STAGE:?}/var" # rpm ghosts etc. — nothing outside /usr may remain
|
||||
|
||||
# Self-update: the helper rides inside the image.
|
||||
install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext"
|
||||
|
||||
# Compatibility marker. ID=fedora matches Bazzite & friends through os-release ID_LIKE;
|
||||
# VERSION_ID makes a major-rebased host refuse the old ABI instead of merging it.
|
||||
install -d "$STAGE/usr/lib/extension-release.d"
|
||||
cat > "$STAGE/usr/lib/extension-release.d/extension-release.punktfunk" <<EOF
|
||||
ID=fedora
|
||||
VERSION_ID=$VERSION_ID
|
||||
ARCHITECTURE=x86-64
|
||||
SYSEXT_ID=punktfunk
|
||||
SYSEXT_VERSION_ID=$PF_VR
|
||||
EXTENSION_RELOAD_MANAGER=1
|
||||
EOF
|
||||
|
||||
# SELinux labels as pseudo-xattrs (see header). matchpathcon resolves each target path against
|
||||
# the targeted policy's file_contexts; <<none>> means "no specific entry" — skip those (the
|
||||
# handful of matches all resolve to real contexts for our payload).
|
||||
PSEUDO="$STAGE.pseudo"
|
||||
( cd "$STAGE" && find . -mindepth 1 \( -type f -o -type d \) -printf '/%P\n' ) | sort \
|
||||
| while IFS= read -r path; do
|
||||
ctx="$(matchpathcon -n "$path" 2>/dev/null || true)"
|
||||
case "$ctx" in ''|'<<none>>') continue ;; esac
|
||||
printf '%s x security.selinux=%s\n' "$path" "$ctx"
|
||||
done > "$PSEUDO"
|
||||
[ -s "$PSEUDO" ] || { echo "matchpathcon produced no labels — refusing to build an unlabeled image" >&2; exit 1; }
|
||||
|
||||
rm -f "$OUT"; mkdir -p "$(dirname "$OUT")"
|
||||
# -xattrs-exclude drops any security.selinux the staging fs already had (would collide with the
|
||||
# pseudo defs when building on an SELinux host); -all-root because cpio extracted as the CI uid.
|
||||
mksquashfs "$STAGE" "$OUT" -all-root -noappend -quiet \
|
||||
-xattrs-exclude '^security.selinux' -pf "$PSEUDO"
|
||||
rm -f "$PSEUDO"
|
||||
echo "built $OUT (punktfunk $PF_VR, fedora $VERSION_ID, $(du -h "$OUT" | cut -f1))"
|
||||
echo " install on the box: punktfunk-sysext install (or --from-file $OUT)"
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish a punktfunk sysext image into its feed on the Gitea generic package registry —
|
||||
# called by .gitea/workflows/rpm.yml after the RPM publish. A feed is one fixed URL
|
||||
# (…/punktfunk-sysext/<feed>/) holding versioned .raw files plus a SHA256SUMS manifest;
|
||||
# punktfunk-sysext(8) on the boxes reads SHA256SUMS to find + verify the newest image
|
||||
# (the layout is also exactly what systemd-sysupdate's url-file source expects, so a
|
||||
# .transfer feed can be added later without re-publishing anything).
|
||||
#
|
||||
# Usage: TOKEN=… [KEEP=6] bash publish-sysext-feed.sh <feed> <image.raw>
|
||||
# <feed> e.g. f43, f43-canary, f44 (Fedora major x channel)
|
||||
# KEEP newest images to keep in the feed; 0/unset-for-stable = keep all
|
||||
# Env: REGISTRY (git.unom.io), OWNER (unom), TOKEN (write:package PAT), CURL_USER (login name)
|
||||
set -euo pipefail
|
||||
|
||||
FEED="${1:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
|
||||
RAW="${2:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
|
||||
[ -f "$RAW" ] || { echo "no such image: $RAW" >&2; exit 1; }
|
||||
REGISTRY="${REGISTRY:-git.unom.io}"
|
||||
OWNER="${OWNER:-unom}"
|
||||
KEEP="${KEEP:-0}"
|
||||
AUTH=(--user "${CURL_USER:-enricobuehler}:${TOKEN:?TOKEN (write:package PAT) required}")
|
||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/punktfunk-sysext/$FEED"
|
||||
|
||||
FNAME="$(basename "$RAW")"
|
||||
SHA="$(sha256sum "$RAW" | cut -d' ' -f1)"
|
||||
|
||||
# Merge into the existing manifest: drop any prior line for this filename, append ours.
|
||||
SUMS="$(mktemp)"; trap 'rm -f "$SUMS"' EXIT
|
||||
curl -fsS "${AUTH[@]}" "$BASE/SHA256SUMS" 2>/dev/null | grep -v " $FNAME\$" > "$SUMS" || true
|
||||
printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS"
|
||||
|
||||
# Prune: keep only the newest $KEEP images (by version sort) in manifest + registry.
|
||||
PRUNE=()
|
||||
if [ "$KEEP" -gt 0 ]; then
|
||||
mapfile -t PRUNE < <(awk '{print $2}' "$SUMS" | sort -V | head -n -"$KEEP")
|
||||
for f in "${PRUNE[@]:-}"; do
|
||||
[ -n "$f" ] && sed -i "\| $f\$|d" "$SUMS"
|
||||
done
|
||||
fi
|
||||
|
||||
# Upload order keeps consumers consistent: image first, then the manifest referencing it,
|
||||
# then prune deletions (already absent from the manifest). Delete-before-put makes workflow
|
||||
# re-runs idempotent (the registry 409s on duplicate filenames; first-publish 404s are fine).
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$FNAME" || true
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$RAW" "$BASE/$FNAME"
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
|
||||
for f in "${PRUNE[@]:-}"; do
|
||||
[ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; }
|
||||
done
|
||||
echo "published $FNAME -> $BASE ($(wc -l <"$SUMS") image(s) in the feed)"
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env bash
|
||||
# punktfunk-sysext — install/update the punktfunk host on Bazzite / Fedora Atomic as a
|
||||
# systemd-sysext, the no-layering path (rpm-ostree layering is a last resort per the Bazzite
|
||||
# docs: it slows every update and can block upgrades; a sysext never enters an rpm-ostree
|
||||
# transaction, needs no reboot, and is trivially removable).
|
||||
#
|
||||
# The image overlays /usr from /var/lib/extensions/punktfunk.raw with the host, tray and web
|
||||
# console + their udev/sysctl/systemd-user payload; the RPMs' two /etc files (gamescope
|
||||
# session drop-in, tray autostart) ride inside at /usr/share/punktfunk/etc/ and are copied
|
||||
# into the real /etc here (a sysext can only carry /usr).
|
||||
#
|
||||
# Bootstrap (the script also ships inside the image as /usr/bin/punktfunk-sysext):
|
||||
# curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
|
||||
# sudo bash punktfunk-sysext.sh install # or: install --channel canary
|
||||
# Thereafter:
|
||||
# sudo punktfunk-sysext update | status | remove
|
||||
#
|
||||
# Feed: the Gitea generic package registry, one feed per Fedora major x channel
|
||||
# (…/punktfunk-sysext/f43/, f43-canary, f44, …), each a SHA256SUMS + versioned .raw files —
|
||||
# published by .gitea/workflows/rpm.yml from the same RPMs the (legacy) layering path uses.
|
||||
# The image pins ID=fedora + VERSION_ID, so after a major OS rebase the old image is refused
|
||||
# (not merged broken) and `punktfunk-sysext update` re-resolves against the new release.
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="${PUNKTFUNK_SYSEXT_REGISTRY:-https://git.unom.io/api/packages/unom/generic/punktfunk-sysext}"
|
||||
CONF=/etc/punktfunk-sysext.conf
|
||||
EXT_DIR=/var/lib/extensions
|
||||
IMG="$EXT_DIR/punktfunk.raw"
|
||||
SIDECAR="$EXT_DIR/.punktfunk.version"
|
||||
MARKER=/usr/lib/extension-release.d/extension-release.punktfunk
|
||||
ETC_SRC=/usr/share/punktfunk/etc
|
||||
|
||||
usage() {
|
||||
sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p'
|
||||
echo "usage: punktfunk-sysext install [--channel stable|canary] [--from-file X.raw]"
|
||||
echo " punktfunk-sysext update [--from-file X.raw] | status | remove"
|
||||
exit "${1:-0}"
|
||||
}
|
||||
need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; }; }
|
||||
|
||||
os_version_id() { . /etc/os-release; echo "${VERSION_ID%%.*}"; }
|
||||
channel() { # shellcheck disable=SC1090
|
||||
[ -f "$CONF" ] && . "$CONF"; echo "${CHANNEL:-stable}"; }
|
||||
feed_url() {
|
||||
local suffix=""
|
||||
[ "$(channel)" = canary ] && suffix="-canary"
|
||||
echo "$REGISTRY/f$(os_version_id)$suffix"
|
||||
}
|
||||
|
||||
# latest -> "VERSION FILENAME SHA256" from the feed's SHA256SUMS (highest by version sort).
|
||||
latest() {
|
||||
local feed; feed="$(feed_url)"
|
||||
curl -fsSL "$feed/SHA256SUMS" \
|
||||
| awk '$2 ~ /^punktfunk-.*-x86-64\.raw$/ { v=$2; sub(/^punktfunk-/,"",v); sub(/-x86-64\.raw$/,"",v); print v, $2, $1 }' \
|
||||
| sort -V | tail -n1
|
||||
}
|
||||
|
||||
installed_version() {
|
||||
if [ -f "$MARKER" ]; then
|
||||
sed -n 's/^SYSEXT_VERSION_ID=//p' "$MARKER"
|
||||
elif [ -f "$SIDECAR" ]; then
|
||||
cat "$SIDECAR"
|
||||
fi
|
||||
}
|
||||
merged() { [ -f "$MARKER" ]; }
|
||||
|
||||
post_merge() {
|
||||
if ! merged; then
|
||||
echo "!! image installed but NOT merged — 'systemd-sysext status' / 'journalctl -u systemd-sysext'" >&2
|
||||
echo "!! (an OS release the image doesn't match? 'punktfunk-sysext update' fetches the right one)" >&2
|
||||
return 1
|
||||
fi
|
||||
# What the RPM scriptlets would have done: pick up the uinput/uhid rule + the UDP buffer
|
||||
# sysctl now, no reboot (both also auto-apply at boot once merged — the files live in /usr/lib).
|
||||
udevadm control --reload 2>/dev/null || :
|
||||
udevadm trigger --subsystem-match=misc 2>/dev/null || :
|
||||
for f in /usr/lib/sysctl.d/99-punktfunk-net.conf /usr/lib/sysctl.d/99-punktfunk-client-net.conf; do
|
||||
[ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || :
|
||||
done
|
||||
# The /etc payload a sysext can't carry. The gamescope-session drop-in is %config(noreplace):
|
||||
# only seed it, never clobber a local edit. The tray autostart entry is not user config.
|
||||
if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \
|
||||
&& [ ! -e /etc/gamescope-session-plus/sessions.d/steam ]; then
|
||||
install -Dm0644 "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \
|
||||
/etc/gamescope-session-plus/sessions.d/steam
|
||||
fi
|
||||
if [ -f "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" ]; then
|
||||
install -Dm0644 "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" \
|
||||
/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
|
||||
fi
|
||||
}
|
||||
|
||||
# do_install VERSION FILENAME SHA256 | do_install --from-file X.raw
|
||||
do_install() {
|
||||
need_root
|
||||
mkdir -p "$EXT_DIR"
|
||||
local tmp="$EXT_DIR/.punktfunk.raw.new" ver
|
||||
if [ "$1" = --from-file ]; then
|
||||
ver="(local: $(basename "$2"))"
|
||||
cp -f "$2" "$tmp"
|
||||
else
|
||||
ver="$1"
|
||||
echo "downloading punktfunk $ver ($(channel), fedora $(os_version_id))…"
|
||||
curl -fL --progress-bar -o "$tmp" "$(feed_url)/$2"
|
||||
echo "$3 $tmp" | sha256sum -c --quiet
|
||||
fi
|
||||
mv -f "$tmp" "$IMG" # marker inside is extension-release.punktfunk — name must match
|
||||
echo "$ver" > "$SIDECAR"
|
||||
systemctl enable --now systemd-sysext.service >/dev/null 2>&1 || :
|
||||
systemd-sysext refresh
|
||||
post_merge
|
||||
echo "punktfunk $ver merged into /usr."
|
||||
}
|
||||
|
||||
layering_hint() {
|
||||
if command -v rpm-ostree >/dev/null 2>&1 \
|
||||
&& rpm-ostree status 2>/dev/null | grep -q 'LayeredPackages:.*punktfunk'; then
|
||||
cat >&2 <<'EOF'
|
||||
!! punktfunk is ALSO layered via rpm-ostree. The sysext now shadows it, but remove the
|
||||
!! layer so it stops slowing/blocking OS updates (the reason this sysext exists):
|
||||
!! sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
need_root
|
||||
local from_file=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--channel) printf 'CHANNEL=%s\n' "${2:?}" > "$CONF"; shift 2 ;;
|
||||
--from-file) from_file="${2:?}"; shift 2 ;;
|
||||
*) usage 1 ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$from_file" ]; then
|
||||
do_install --from-file "$from_file"
|
||||
else
|
||||
local l; l="$(latest)"
|
||||
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
|
||||
# shellcheck disable=SC2086
|
||||
do_install $l
|
||||
fi
|
||||
layering_hint
|
||||
cat <<'EOF'
|
||||
|
||||
First-run (once):
|
||||
ujust add-user-to-input-group # virtual gamepads; then log out + back in
|
||||
mkdir -p ~/.config/punktfunk
|
||||
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
|
||||
systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host
|
||||
Updates: sudo punktfunk-sysext update
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
need_root
|
||||
if [ "${1:-}" = --from-file ]; then do_install --from-file "${2:?}"; return; fi
|
||||
local cur l ver
|
||||
cur="$(installed_version)"
|
||||
l="$(latest)"
|
||||
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
|
||||
ver="${l%% *}"
|
||||
if [ "$ver" = "$cur" ] && merged; then
|
||||
echo "already on $cur (channel $(channel)) — nothing to do."
|
||||
return
|
||||
fi
|
||||
echo "updating: ${cur:-<none>} -> $ver"
|
||||
# shellcheck disable=SC2086
|
||||
do_install $l
|
||||
echo "restart the host to pick up the new binary: systemctl --user restart punktfunk-host"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
echo "channel: $(channel)"
|
||||
echo "feed: $(feed_url)"
|
||||
echo "image: $([ -f "$IMG" ] && du -h "$IMG" | cut -f1 || echo '(not installed)')"
|
||||
echo "merged: $(merged && echo yes || echo no)"
|
||||
echo "installed: $(installed_version || true)"
|
||||
echo "latest: $(latest 2>/dev/null | cut -d' ' -f1 || true)"
|
||||
}
|
||||
|
||||
cmd_remove() {
|
||||
need_root
|
||||
# /etc cleanup needs the /usr payload for the unmodified-compare — do it BEFORE unmerging.
|
||||
if merged; then
|
||||
if cmp -s "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \
|
||||
/etc/gamescope-session-plus/sessions.d/steam 2>/dev/null; then
|
||||
rm -f /etc/gamescope-session-plus/sessions.d/steam
|
||||
fi
|
||||
fi
|
||||
rm -f /etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
|
||||
rm -f "$IMG" "$SIDECAR" "$CONF"
|
||||
systemd-sysext refresh 2>/dev/null || :
|
||||
echo "punktfunk sysext removed (user config in ~/.config/punktfunk is untouched)."
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
install) shift; cmd_install "$@" ;;
|
||||
update) shift; cmd_update "$@" ;;
|
||||
status) shift; cmd_status ;;
|
||||
remove) shift; cmd_remove ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -23,6 +23,14 @@ if [[ $EUID -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The sysext path (packaging/bazzite/punktfunk-sysext.sh) supersedes layering entirely — if the
|
||||
# box runs the sysext, it shadows any layered copy and THIS script won't change what executes.
|
||||
if [[ -f /var/lib/extensions/punktfunk.raw ]]; then
|
||||
echo "NOTE: the punktfunk sysext is installed — update with 'punktfunk-sysext update' instead." >&2
|
||||
echo " (a layered punktfunk is shadowed by the sysext; consider removing the layer:" >&2
|
||||
echo " rpm-ostree uninstall punktfunk punktfunk-web)" >&2
|
||||
fi
|
||||
|
||||
# Which punktfunk packages are actually layered right now (host, web, or both).
|
||||
mapfile -t layered < <(rpm-ostree status --json 2>/dev/null \
|
||||
| grep -oE '"punktfunk(-web)?"' | tr -d '"' | sort -u)
|
||||
|
||||
@@ -68,12 +68,11 @@ finish-args:
|
||||
# PulseAudio shim — so it needs the real `pipewire-0` socket in the sandbox. With only
|
||||
# --socket=pulseaudio the sandbox has just `pulse/native`, no `pipewire-0`, and playback +
|
||||
# mic both die with "pw connect (is PipeWire running in this session?)" (observed live on the
|
||||
# Deck in Gaming Mode). --socket=pipewire is the canonical grant; --filesystem=xdg-run/
|
||||
# pipewire-0 binds the same socket portably (validated on-Deck: it makes pipewire-0 appear in
|
||||
# the sandbox where --socket=pipewire's CLI validation was flaky). Neither needs the
|
||||
# camera/portal dance (that's only for camera nodes). --socket=pulseaudio stays as a fallback
|
||||
# for any pulse-only path. ---
|
||||
- --socket=pipewire
|
||||
# Deck in Gaming Mode). We bind the native socket via --filesystem=xdg-run/pipewire-0 (NOT
|
||||
# --socket=pipewire: this flatpak-builder toolchain rejects it as an "Unknown socket type",
|
||||
# and the Deck's flatpak 1.16 override CLI does too — the filesystem bind is the portable
|
||||
# form, validated on-Deck to make pipewire-0 appear + the client register its audio node).
|
||||
# --socket=pulseaudio stays as a fallback for any pulse-only path. ---
|
||||
- --filesystem=xdg-run/pipewire-0
|
||||
- --socket=pulseaudio
|
||||
# --- network: QUIC control + UDP data plane + mDNS discovery (_punktfunk._udp) ---
|
||||
|
||||
Reference in New Issue
Block a user