Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Opus 4.8 5f9a06d51f fix(packaging): install punktfunk-host on Ubuntu 24.04 LTS via a noble builder that bundles FFmpeg 8
The host .deb was built on the Ubuntu 26.04 rust-ci image, so dpkg-shlibdeps
baked in `Depends: libavcodec62` (FFmpeg 8) and a glibc-2.41 floor — making it
uninstallable on Ubuntu 24.04 LTS (FFmpeg 6.1 / libavcodec60, glibc 2.39; apt
reports the deps as "too recent"). The source floor (ffmpeg-next 8, libavcodec
>=61 APIs) means a straight 24.04 rebuild would fail too.

Build the host on Ubuntu 24.04 instead — lowering the glibc floor to 2.39 so one
binary runs on 24.04 -> 26.04 — and bundle a from-source LGPL FFmpeg 8 into the
package so it no longer depends on the distro libav*. Everything else the host
links is soname-compatible on 24.04 (opus is vendored via cmake; NVENC/libcuda
are dlopen-only, never link-time), and the only FFmpeg encoders used are
*_nvenc / *_vaapi (software H.264 fallback is the BSD-2 openh264 crate, not
FFmpeg libx264), so an LGPL build keeps the bundle license-clean.

- ci/rust-ci-noble.Dockerfile (new): ubuntu:24.04 builder; nv-codec-headers +
  FFmpeg 8 (--enable-nvenc --enable-vaapi, shared) -> /opt/ffmpeg; PKG_CONFIG_PATH.
- packaging/debian/build-deb.sh: BUNDLE_FFMPEG=1 copies libav*/libsw*/libpostproc
  into /usr/lib/punktfunk-host, patchelf-sets the rpath ($ORIGIN per-lib + binary
  --force-rpath), feeds them to dpkg-shlibdeps (captures libva2/libdrm2), and drops
  the libav* sonames from Depends. Normal (non-bundle) path unchanged.
- .gitea/workflows/deb.yml: split into build-publish (client/web/scripting on the
  26.04 image) and build-publish-host (noble image, BUNDLE_FFMPEG=1); parallel,
  identical version step, same apt distribution -> one universal host .deb.
- .gitea/workflows/docker.yml: build+push punktfunk-rust-ci-noble.
- packaging/debian/README.md: document the 24.04 LTS path + bundled local build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 11:08:55 +02:00
1457 changed files with 47112 additions and 371245 deletions
+3 -6
View File
@@ -1,12 +1,9 @@
# The root build context is used by web/Dockerfile (which needs web/ and
# api/openapi.json) and by ci/rust-ci-arm64cross.Dockerfile (which needs the toolchain
# pin). Allowlist those; keep everything else (target/, .git, crates) out of the
# context upload.
# Root build context is used only by web/Dockerfile, which needs web/ and
# api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
# out of the context upload.
*
!web
!api/openapi.json
!rust-toolchain.toml
!ci/pf-host-cc
web/node_modules
web/.output
web/dist
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env bash
# Assert that a builder image's :latest is the SAME manifest as its content key, and
# re-point it when it isn't.
#
# This is what we do instead of pinning consumers by @sha256: digest
# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The
# content key is a hash of the ci/ tree, so "which image should :latest be?" has an
# answer derivable from the commit alone. Checking it on every run turns :latest from a
# tag someone remembered to move into a function of the tree.
#
# Two different things make them diverge and neither is distinguishable from here:
#
# - Someone overwrote :latest out of band. Post-fix that needs the push credential,
# but it is exactly the H-6 attack and it must not pass silently.
# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and
# nothing re-points :latest — it stays on the newer build forever while every
# consumer pulls a builder that does not match the tree it is building. That bug
# predates this script.
#
# Both are repaired identically, so: repair, and shout. Failing the build instead would
# turn a legitimate revert into a red main with no way forward.
#
# Reads go to the anonymous port, the single write to the authenticated one.
set -euo pipefail
IMAGE="${1:?usage: reconcile-latest.sh <image> <content-key>}"
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
: "${CI_REGISTRY:?CI_REGISTRY not set}"
: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}"
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}"
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
# Digest of a tag, or empty if the tag does not exist. Never fails the script itself —
# "missing" is a state this has to reason about, not an error to abort on.
digest_of() {
curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true
}
key_digest=$(digest_of "$KEY")
latest_digest=$(digest_of latest)
if [ -z "$key_digest" ]; then
echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
exit 1
fi
if [ "$key_digest" = "$latest_digest" ]; then
echo "$IMAGE:latest == :$KEY ($key_digest)"
exit 0
fi
echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
echo " was: ${latest_digest:-<no :latest tag>}"
echo " wanted: $key_digest (:$KEY)"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \
--data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest"
now=$(digest_of latest)
[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; }
echo "$IMAGE:latest re-pointed to $key_digest"
-95
View File
@@ -1,95 +0,0 @@
# Move a versionCode that is ALREADY on Google Play between tracks — no rebuild.
#
# Why this is separate from android.yml: promotion must not rebuild. A rebuild produces a fresh
# versionCode (github.run_number) from possibly-newer sources, so it ships something nobody tested;
# promoting assigns the byte-identical artifact the testers already ran. Bolting this onto
# android.yml would mean an `if:` on all ten of its build steps.
#
# What it is for:
# * promote a tested build up a track (alpha -> production)
# * roll production back by re-pointing it at an older versionCode (to_track=production,
# version_code=<the good one>, from_track blank)
# * halt a rollout (status=halted)
#
# Defaults are deliberately the safe ones: dry_run starts TRUE, so a mis-typed versionCode
# validates and deletes the edit instead of publishing. Flip it to false only when the dry run
# printed what you meant.
name: android-promote
# Two concurrent promotions would race on the same Play edit; the loser fails with a stale-edit
# error. One at a time, and never cancel one mid-flight — a half-applied track change is worse
# than a queued one.
concurrency:
group: android-promote
cancel-in-progress: false
on:
workflow_dispatch:
inputs:
version_code:
description: 'versionCode already on Play (e.g. 10816)'
required: true
to_track:
description: 'destination track'
required: true
default: 'production'
from_track:
description: 'track to verify it is on, then clear (blank = touch nothing else)'
required: false
default: 'alpha'
notes_tag:
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
required: false
default: ''
status:
description: 'completed (100%) | inProgress (needs user_fraction) | halted | draft'
required: true
default: 'completed'
user_fraction:
description: 'staged rollout fraction for inProgress, e.g. 0.2 (blank otherwise)'
required: false
default: ''
dry_run:
description: 'validate only, publish nothing'
required: true
default: 'true'
jobs:
promote:
runs-on: ubuntu-24.04
# Same image as android.yml purely for python3 + openssl (play-upload.py's only deps); it is
# already warm on the runner. Nothing here builds.
container:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Promote
env:
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
VERSION_CODE: ${{ inputs.version_code }}
TO_TRACK: ${{ inputs.to_track }}
FROM_TRACK: ${{ inputs.from_track }}
NOTES_TAG: ${{ inputs.notes_tag }}
STATUS: ${{ inputs.status }}
USER_FRACTION: ${{ inputs.user_fraction }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -- --package io.unom.punktfunk \
--promote "$VERSION_CODE" \
--track "$TO_TRACK" --status "$STATUS"
# Explicit `if`, not `[ ] && …`: under `sh -e` a false AND-OR list that ends up LAST in
# the script aborts the step, and these get reordered.
if [ -n "$FROM_TRACK" ]; then set -- "$@" --promote-from "$FROM_TRACK"; fi
if [ -n "$USER_FRACTION" ]; then set -- "$@" --user-fraction "$USER_FRACTION"; fi
if [ -n "$NOTES_TAG" ]; then
NOTES="docs/releases/whatsnew/${NOTES_TAG}.txt"
# Fail loudly rather than silently publishing with the PREVIOUS release's text still
# showing on the store listing.
[ -f "$NOTES" ] || { echo "ERROR: no such notes file: $NOTES"; exit 1; }
set -- "$@" --release-notes-file "$NOTES"
fi
if [ "$DRY_RUN" = "true" ]; then set -- "$@" --no-commit; fi
echo "promoting versionCode=$VERSION_CODE -> $TO_TRACK (dry_run=$DRY_RUN)"
python3 clients/android/ci/play-upload.py "$@"
+17 -19
View File
@@ -4,14 +4,6 @@
# `screenshots` job, gated to STABLE RELEASE tags only. Standalone + best-effort: a failure here
# reds nothing else. PNGs land as a 30-day artifact; not committed or published.
name: android-screenshots
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
@@ -22,27 +14,33 @@ jobs:
screenshots:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
# JDK 21 + SDK baked (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 1721).
# The tests are pure JVM (no NDK), but sharing android.yml's image means one image to
# keep warm instead of a per-run setup-java + sdkmanager download pair.
container:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: JDK 21 (AGP 9.2 + Robolectric's SDK-36 android-all jar both want 1721)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Android SDK
# SHA-pinned for parity with android.yml (third-party action). v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP
# if the platform channel lacks it (same note as android.yml).
- name: platform-tools + platform 36 + build-tools
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0"
- name: Cache (gradle)
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
# gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the
# Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise
# restore a key that can never hold the new one. Namespace shared with android.yml —
# it is the same content; two keys just stored it twice in the central cache.
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts') }}
restore-keys: android-screenshots-
# Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps
# the cargo-ndk native build out of the graph — the tests never load libpunktfunk_android.so.
+46 -176
View File
@@ -2,187 +2,72 @@
# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
#
# Runs in the punktfunk-android-ci builder image (ci/android-ci.Dockerfile, content-keyed on
# the LAN registry): JDK 21, the Android SDK/NDK/CMake pins, cargo-ndk and sccache are all
# baked, so the multi-GB per-run Google downloads this job used to make are gone. Emulator
# instrumentation tests are deferred until a KVM-capable runner exists (they self-skip
# otherwise, like apple.yml's RemoteFirstLightTests).
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
# distribution in-job). If android-actions/setup-android is not mirrored on this Gitea instance,
# replace that step with a manual cmdline-tools download, or bake an `android-ci` image like
# ci/rust-ci.Dockerfile. Emulator instrumentation tests are deferred until a KVM-capable runner
# exists (they self-skip otherwise, like apple.yml's RemoteFirstLightTests).
name: android
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'clients/android/**'
# The builder image is part of what this artifact is built from — an image
# change must exercise its consumer.
- 'ci/android-ci.Dockerfile'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/android.yml'
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
# reach `alpha` and someone had to promote it by hand in the Console.
# Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed
# track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main
# push is canary (Play `internal`).
tags: ['v*']
pull_request:
paths:
- 'crates/**'
- 'clients/android/**'
# The builder image is part of what this artifact is built from — an image
# change must exercise its consumer.
- 'ci/android-ci.Dockerfile'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/android.yml'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). The NDK clang targets get their own key universes automatically (keys embed
# compiler hash + target), so the three ABI builds share the bucket with everything else.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
android:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
# FIRST, because it costs a second and everything after it costs ten minutes.
#
# A release tag MUST carry its own Play "What's new". If the file is absent Play does not
# show nothing — it carries the PREVIOUS release's text onto this version, so production
# users read notes for a build they are not getting. That is the same defect the v0.22.3
# notes shipped (see docs/releases/README.md), and it is invisible until someone reads the
# store listing. Failing here also means a missing file cannot leave a half-published
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
#
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
# testers costs nothing.
- name: Play release notes gate (tags only)
if: startsWith(github.ref, 'refs/tags/v')
- name: JDK 21 (AGP 9.2 runs on JDK 1721, not the host default)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Rust toolchain + Android targets (self-healing on a fresh runner)
run: |
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
if [ ! -f "$NOTES" ]; then
echo "ERROR: $NOTES does not exist."
echo "A production release needs its own Play 'What's new' (<=500 chars, written for"
echo "phone/TV users). Without it Play reuses the previous release's text."
echo "See docs/releases/README.md; copy docs/releases/whatsnew/TEMPLATE.txt."
exit 1
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal
fi
# A verbatim copy of another release's file is the same bug wearing a hat: the store
# listing still describes the wrong build. Cheap to check, and only ever trips on an
# actual copy-paste that was never edited.
for other in docs/releases/whatsnew/*.txt; do
if [ "$other" != "$NOTES" ] && [ "$other" != "docs/releases/whatsnew/TEMPLATE.txt" ]; then
if cmp -s "$NOTES" "$other"; then
echo "ERROR: $NOTES is byte-identical to $other."
echo "Write notes describing THIS release, not the one before it."
exit 1
fi
fi
done
# Length is checked here as well as in play-upload.py. Not redundant: the uploader is
# the last line of defence (and the only one android-promote.yml gets), but it runs at
# step 9 — this catches an unedited TEMPLATE copy at step 1 instead of after the build.
# Must count CHARACTERS, not bytes: Play's cap is 500 chars and `•` is 3 bytes in UTF-8,
# so `wc -c` would reject a file that is comfortably legal.
python3 - "$NOTES" <<'PY'
import sys
path = sys.argv[1]
text = open(path, encoding="utf-8").read().strip()
if not text:
sys.exit(f"ERROR: {path} is empty.")
if len(text) > 500:
sys.exit(f"ERROR: {path} is {len(text)} chars; Play allows 500. Trim it.")
print(f"Play release notes OK: {path} ({len(text)}/500 chars)")
PY
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
# Everything below the checkout used to be four download steps (JDK, SDK,
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
# into the image now. This guard only re-asserts the Android targets so a
# rust-toolchain.toml pin bump keeps working against an older image (:latest lags
# one image rebuild, same bootstrap note as ci.yml's dep steps).
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
- name: Android SDK
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
# Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
# CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
# anymore — sccache covers recompilation without shipping multi-GB tars per run.
- name: Cache (cargo registry)
uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
# auto-download it if needed during the build.
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
- name: Cache (gradle)
- name: Caches (cargo + gradle)
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.gradle/caches
~/.gradle/wrapper
# gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the
# Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise
# restore a key that can never hold the new one. Namespace shared with
# android-screenshots.yml — same content, one copy in the central store.
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
target
key: android-${{ hashFiles('Cargo.lock', 'clients/android/**/*.gradle.kts') }}
restore-keys: android-
# Clippy for the ANDROID target. Like the kit tests below, this was running NOWHERE: ci.yml
# lints `--workspace` on the host, where `clients/android/native` and every
# `#[cfg(target_os = "android")]` module elsewhere compile out, and this workflow only ever
# built. Discovered in 2026-08 with five lints already resident — code no gate had ever read.
#
# Placed BEFORE assembleDebug deliberately: a lint failure should cost the ~10 s the lint
# takes, not the full three-ABI build first. It shares sccache and the target dir with the
# build that follows, so the compile is not paid twice.
#
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
# both pointer widths are load-bearing and why the environment must not be duplicated here.
- name: Clippy (Android target, deny warnings)
working-directory: clients/android
run: ./gradlew :kit:cargoNdkClippy --stacktrace
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
# built module) and it is the only automated cover those behaviours have.
- name: kit unit tests
working-directory: clients/android
run: ./gradlew :kit:testDebugUnitTest --stacktrace
- name: cargo-ndk
run: command -v cargo-ndk >/dev/null || cargo install cargo-ndk
- name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android
@@ -197,20 +82,11 @@ jobs:
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
case "$GITHUB_REF" in
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
esac
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
# On a tag the gate step above already proved this exists, so the else branch is only
# ever the canary path. See docs/releases/README.md.
NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt"
if [ -f "$NOTES" ]; then
echo "PLAY_NOTES=$NOTES" >> "$GITHUB_ENV"
else
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
fi
echo "android version $VN -> Play track '$TRACK'"
- name: Build Release (signed AAB + universal APK)
@@ -283,21 +159,15 @@ jobs:
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
#
# A tag therefore ships to real users with no further click. Two things keep that honest:
# the tag is only pushed once every platform is green, and Play reviews each production
# release before it reaches anyone. To ramp instead of going straight to 100%, this is
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
# Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual
# promotion to production in the Play console.
- name: Upload to Google Play
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
run: |
echo "uploading to Play track '$PLAY_TRACK'"
set -- --package io.unom.punktfunk \
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
--track "$PLAY_TRACK" --status completed
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
python3 clients/android/ci/play-upload.py "$@"
python3 clients/android/ci/play-upload.py \
--package io.unom.punktfunk \
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
--track "$PLAY_TRACK" --status completed
-87
View File
@@ -1,87 +0,0 @@
# Announce a stable release to the Discord #releases channel.
#
# This is the deliberate "go" step for a release. Release notes live in the repo at
# docs/releases/<tag>.md and are seeded into the Gitea release body at creation by the build
# workflows (scripts/ci/gitea-release.sh), so the release is never noteless. Once every
# platform's CI is green for a tag, dispatch this workflow with that tag: it re-asserts the notes
# file over the live release and posts a formatted embed to #releases.
#
# Manual on purpose — pressing "go" is the quality gate that says "all platforms built, notes are
# final, tell the community." It is NOT wired to the tag push, so a half-built or failed release
# is never announced. Stable-only: a -rc/pre-release tag is refused unless allow_prerelease=true.
#
# Requires the repo secret DISCORD_RELEASE_WEBHOOK (the #releases channel webhook URL); GITEA auth
# reuses REGISTRY_TOKEN like the other release workflows.
name: announce
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to announce (e.g. v0.18.0)"
required: true
allow_prerelease:
description: "Announce even if the tag is a pre-release (-rc)"
required: false
default: "false"
jobs:
announce:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# Publish the SIGNED stable update manifest — the moment every host's update check learns
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
# announce, not on the tag: the manual "fleet is green, go" gate doubles as the gate for the
# fleet-wide "update available". Fails the announce loudly if the key is missing (fail-closed)
# or the installer's live bytes don't match their .sha256 sidecar. Pre-release tags are
# ALWAYS skipped — an -rc must never enter the stable feed, even with allow_prerelease.
- name: Publish the stable update manifest
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
# is a raw textual substitution performed BEFORE the shell sees the line, so a
# workflow_dispatch input containing shell syntax executes as this step — and this is the
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="$INPUT_TAG"
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
case "$TAG" in
v[0-9]*) ;;
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
esac
case "$TAG" in
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
esac
VER="${TAG#v}"
URL="https://git.unom.io/unom/punktfunk/releases/download/${TAG}/punktfunk-host-setup-${VER}.exe"
# Re-download and re-hash the real bytes; the sidecar is a cross-check, never the truth.
curl -fsSL "$URL" -o /tmp/installer.exe
curl -fsSL "$URL.sha256" -o /tmp/installer.sha256
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
grep -qi "$SHA" /tmp/installer.sha256 || {
echo "ERROR: installer sha256 $SHA does not match the release's .sha256 sidecar" >&2
exit 1
}
CHANNEL=stable VERSION="$VER" REQUIRE_KEY=1 \
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
NOTES_URL="https://git.unom.io/unom/punktfunk/releases/tag/${TAG}" \
bash scripts/ci/publish-update-manifest.sh
- name: Post release announcement to Discord
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
# Same reasoning as the publish step above: the input is data in the environment, never
# text spliced into the command line.
INPUT_TAG: ${{ inputs.tag }}
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
-86
View File
@@ -8,57 +8,13 @@
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
# from the build/test job and best-effort, so a capture gap never reds the core signal.
name: apple
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
pull_request:
paths:
- 'crates/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
# side stays on DerivedData (sccache doesn't cache swiftc).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
# runner's persistent target/ disposable instead of precious.
CARGO_INCREMENTAL: "0"
jobs:
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
@@ -85,18 +41,6 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
# system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs).
@@ -155,18 +99,6 @@ jobs:
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
@@ -183,20 +115,6 @@ jobs:
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
- name: Pin + prune DerivedData (same disease release.yml already cures)
# screenshots.sh builds into a throwaway mktemp DerivedData per invocation — two
# fresh ~1 GB trees per run, zero reuse. Pin one stable root (PF_SHOT_DERIVED_DATA,
# honored by the script) so repeat runs are incremental, and GC anything a week old
# in the default DerivedData root that no pin owns.
run: |
DD="$HOME/ci/derived-data/screenshots"
mkdir -p "$DD"
echo "PF_SHOT_DERIVED_DATA=$DD" >> "$GITHUB_ENV"
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
@@ -210,10 +128,6 @@ jobs:
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
echo "Produced:"; ls -la screenshots || true
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
if: always()
run: xcrun simctl shutdown all || true
- name: Upload screenshots (zip artifact)
if: always()
# v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+
+16 -321
View File
@@ -14,151 +14,47 @@
# 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
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'clients/linux/**'
- 'clients/session/**'
- 'clients/shared/**'
- 'clients/cli/**'
- 'web/**'
- 'sdk/**'
- 'packaging/arch/**'
- 'packaging/gamescope/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/arch.yml'
# 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*']
# REBUILDING A PUBLISHED RELEASE, because on a rolling distro the ground moves under one.
# Arch went FFmpeg 8 -> 9 (every libav soname +1) four minutes before v0.25.0 was tagged, so
# the release's punktfunk-host was linked in a builder image that still had 8 and shipped
# `libavcodec.so=62-64`. No up-to-date Arch box can satisfy that — and pacman prepares the
# whole transaction at once, so it did not merely block our package, it blocked those users'
# entire `pacman -Syu`. The repair is a rebuild of the SAME upstream version at a HIGHER
# pkgrel; nothing else reaches a box that already has the broken build recorded in its db.
# The workflow file at the tag can never carry inputs added after it was tagged, so dispatch
# this from `main`: it checks the tag's SOURCE out, publishes to the STABLE repo, and
# replaces the release-page assets. Same lever for any future "the distro moved" rebuild.
workflow_dispatch:
inputs:
release_tag:
description: 'Rebuild this published release (e.g. v0.25.0) into the stable `punktfunk` repo. Empty = ordinary canary build of the dispatched ref.'
required: false
default: ''
pkgrel:
description: 'pkgrel for that rebuild — MUST be above the published one (2, 3, …); a same-pkgrel republish is invisible to pacman. Ignored without release_tag.'
required: false
default: '2'
env:
REGISTRY: git.unom.io
OWNER: unom
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io). NOTE: makepkg runs
# behind `sudo -u builder env ...`, which strips ambient env — the makepkg step
# re-exports these explicitly.
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
CARGO_INCREMENTAL: "0"
jobs:
build-publish:
runs-on: ubuntu-24.04
container:
# Everything the two pacman steps below used to download (~1 GB/run) is baked in,
# plus bun, sccache and node (ci/arch-ci.Dockerfile). The steps stay as --needed
# no-op guards for the one push where :latest lags an image-content change.
image: 192.168.1.58:5010/punktfunk-arch-ci:latest
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: Build + runtime-dev deps (no-op guard — baked into arch-ci)
# No -Syu: the image's snapshot IS the build environment (see the Dockerfile's
# rolling-release note); with everything installed this resolves locally and
# does nothing. It only matters on the push that adds a dep before the image
# rebuild lands — same bootstrap note as ci.yml's GTK4 step.
- name: Install build + runtime-dev deps
run: |
pacman -S --noconfirm --needed \
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages"
mesa libglvnd unzip libarchive
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so bootstrap
# the official binary.
command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
}
bun --version
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
# packaging/arch/PKGBUILD binds punktfunk-host to the exact libav sonames it linked
# (`libavcodec.so=63-64` …), so a builder one FFmpeg major behind Arch emits a package
# that NOBODY can install — and takes the user's whole `pacman -Syu` down with it, since
# pacman prepares the transaction as a unit. That is exactly how v0.25.0 shipped: PR #108
# re-keyed this image for FFmpeg 9, the release tag fired four minutes later, and the job
# still got the FFmpeg-8 `:latest`. The image is a cache and is allowed to lag — but never
# on this one axis. So heal it in-job and shout, instead of building a dead package.
# (Runs BEFORE checkout: a stale image should be repaired before anything depends on it.)
- name: FFmpeg soname parity with today's Arch (heals a stale builder image)
run: |
export LC_ALL=C # `Provides` is a localized field name
# Piped (never a TTY here) pacman prints each field on ONE line, unwrapped.
sonames() { sed -n 's/^Provides *: *//p' | tr ' ' '\n' | grep -E '^lib(av|sw)[a-z]*\.so=' | sort | tr '\n' ' '; }
# A SEPARATE --dbpath: this refreshes only a throwaway view of the repos, so the
# container's own db never enters the partial-upgrade state a bare `pacman -Sy` leaves.
mkdir -p /tmp/pf-archsync
if ! pacman -Sy --dbpath /tmp/pf-archsync --logfile /dev/null >/dev/null 2>&1; then
echo "::warning::could not refresh the Arch db — skipping the FFmpeg parity check"
exit 0
fi
HAVE="$(pacman -Qi ffmpeg | sonames)"
WANT="$(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sonames)"
echo "builder ffmpeg $(pacman -Q ffmpeg | cut -d' ' -f2): $HAVE"
echo "arch ffmpeg $(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sed -n 's/^Version *: *//p'): $WANT"
if [ "$HAVE" = "$WANT" ]; then
echo "OK: the builder links the FFmpeg every up-to-date Arch box already has"
exit 0
fi
echo "::warning::arch-ci is stale ACROSS AN FFMPEG SONAME BUMP — upgrading it for this run."
echo "::warning::Bump the 'refreshed:' date in ci/arch-ci.Dockerfile so the IMAGE carries it."
pacman -Syu --noconfirm || true
HAVE="$(pacman -Qi ffmpeg | sonames)"
if [ "$HAVE" != "$WANT" ]; then
echo "::error::builder still links $HAVE while Arch ships $WANT."
echo "::error::Building on would publish a package no Arch box can install."
exit 1
fi
echo "healed: builder now links $HAVE"
- uses: actions/checkout@v4
with:
# A dispatched release rebuild takes its WORKFLOW from the ref you dispatch (the only
# way it can carry inputs the tag predates) and its SOURCE from the tag. Empty string
# = checkout's own default, i.e. the triggering ref, for every other trigger.
ref: ${{ github.event.inputs.release_tag }}
# 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
@@ -176,45 +72,12 @@ jobs:
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
#
# The run number is ZERO-PADDED to a fixed width, and that padding is load-bearing.
# pacman's own vercmp compares numeric segments numerically and gets this right either
# way, but Gitea's Arch registry picks the version it advertises in `punktfunk-canary.db`
# by STRING order. Unpadded, the run counter crossing a power of ten inverts that order
# ("0.9907" > "0.10095" because '9' > '1'), so the db pins itself to the last build
# before the rollover and every later canary becomes invisible to `pacman -Syu` — the
# packages publish fine, the index just never names them. That is exactly what happened
# on 2026-07-29 when run #10000 landed; it cost an evening and needed a manual purge of
# every 4-digit `0.22.0-0.9xxx` version to unstick. Padding keeps string order and
# numeric order in agreement, so the two can never disagree again.
#
# Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable
# release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising
# it to `1.` would sort canaries ABOVE the release and is not an option.)
env:
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
REBUILD_PKGREL: ${{ github.event.inputs.pkgrel }}
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
if [ -n "${RELEASE_TAG:-}" ]; then
# Dispatched rebuild of a published release (see the workflow_dispatch note at the
# top): same upstream version, higher pkgrel, straight into the stable repo.
# ⚠ Keep that pkgrel SINGLE-DIGIT. Gitea's Arch registry picks the version its .db
# advertises by STRING order (the same trap the canary zero-padding below exists for),
# so "0.25.0-10" sorts BELOW "0.25.0-2" and the rebuild would never be advertised.
V="${RELEASE_TAG#v}"
R="${REBUILD_PKGREL:-2}"
REPO=punktfunk
case "$R" in
''|*[!0-9.]*) echo "::error::pkgrel '$R' is not digits+dots"; exit 1 ;;
1) echo "::error::pkgrel 1 is the published build — a rebuild MUST go up (2, 3, …)"; exit 1 ;;
esac
else
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
esac
fi
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"
@@ -243,150 +106,16 @@ jobs:
sudo -u builder git config --global --add safe.directory "$PWD"
mkdir -p dist && chown builder: dist
cd packaging/arch
# sudo env_reset strips the ambient env, so the sccache wiring must cross the
# boundary explicitly (same values as the workflow env block).
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
RUSTC_WRAPPER="$RUSTC_WRAPPER" CARGO_INCREMENTAL="$CARGO_INCREMENTAL" \
SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \
SCCACHE_REGION="$SCCACHE_REGION" \
AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
makepkg -f -d --holdver
ls -lh "$GITHUB_WORKSPACE/dist"
# The host must ship a VERSIONED libav soname dep, and nothing else in this pipeline proves
# it. packaging/arch/PKGBUILD lists bare `libavcodec.so` etc. and relies on makepkg rewriting
# each into `libavcodec.so=<soname>-<arch>` from the built binary's DT_NEEDED; if that
# rewrite ever stops happening — Arch dropping the soname `provides`, someone "tidying" the
# entries out of `depends`, a makepkg change — the dep silently degrades to an unversioned
# name that ANY ffmpeg satisfies. That is precisely the 2026-08-08 state in which `pacman
# -Syu` walked every Arch/CachyOS install across the FFmpeg 8 -> 9 soname bump and left the
# host unable to start (exit 127 before main(), restart loop). The failure is invisible in a
# green build and only shows up as a bricked box weeks later, so assert it here.
- name: Assert the host pins the FFmpeg soname
run: |
PKG="$(ls "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst | head -1)"
DEPS="$(bsdtar -xOf "$PKG" .PKGINFO | sed -n 's/^depend = //p')"
echo "$DEPS" | sed 's/^/ depend = /'
for lib in libavcodec libavutil; do
echo "$DEPS" | grep -qE "^$lib\.so=[0-9]+-[0-9]+$" || {
echo "::error::punktfunk-host declares no VERSIONED $lib.so dependency."
echo "::error::makepkg did not expand the bare soname from DT_NEEDED, so pacman can"
echo "::error::upgrade FFmpeg across a soname break and brick the install."
echo "::error::See the depends comment in packaging/arch/PKGBUILD."
exit 1
}
done
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
# completely different dependency set, published into the same repo so `pacman -S
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
#
# CACHED on `packaging/gamescope/**`: it depends on nothing else in this repo, so a normal
# push restores the built package instead of spending ~10 minutes on someone else's C++ tree.
# Arch is rolling, so the cache is invalidated by our own patch changes only — a stale binary
# against newer system libs is the same risk the distro's own package carries between rebuilds.
- uses: actions/cache@v4
id: gamescope
with:
path: dist-gamescope
key: punktfunk-gamescope-arch-${{ hashFiles('packaging/gamescope/**') }}
- name: Build punktfunk-gamescope (makepkg)
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort: punktfunk-host works without it (SDR on the gamescope backend), and a
# failure building gamescope must not cost the packages this workflow exists to publish.
run: |
set -x
# Baked into arch-ci — a no-op guard, like the dep step above.
pacman -S --noconfirm --needed \
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
libxkbcommon libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
hwdata luajit pipewire seatd sdl2-compat vulkan-icd-loader wayland \
xcb-util-errors xcb-util-wm xorg-xwayland \
meson cmake glm wayland-protocols benchmark libxcursor || true
mkdir -p dist-gamescope && chown builder: dist-gamescope
chown -R builder: packaging/gamescope
if sudo -u builder env PKGDEST="$GITHUB_WORKSPACE/dist-gamescope" \
bash -c 'cd packaging/gamescope && makepkg -f -d --holdver'; then
ls -lh dist-gamescope
else
echo "::warning::punktfunk-gamescope failed to build — Arch boxes stay SDR on the gamescope backend this run"
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
fi
# THE GATE THIS PIPELINE WAS MISSING. The soname assert above proves the libav dep is
# VERSIONED; it cannot prove the version is one that EXISTS. v0.25.0 passed it and still
# shipped `libavcodec.so=62-64` to a world that had moved to 63 — every affected user got
# "unable to satisfy dependency … required by punktfunk-host", and because pacman prepares
# one transaction, their whole system upgrade stopped there. So ask the only question that
# matters before publishing: would a real, up-to-date Arch box install this?
#
# An empty --dbpath is what makes the answer honest. It means "nothing is installed", so
# pacman must satisfy every dependency FROM THE REPOS exactly as a user's box does. Checking
# against the builder's own installed set instead would let a stale ffmpeg satisfy the stale
# bound and hide the break completely — the very illusion that shipped v0.25.0. `--print`
# resolves and prints; it downloads nothing and installs nothing. Verified against the real
# broken artifact on an ffmpeg-9 box: it reproduces the user-visible failure verbatim.
- name: Assert every package installs on an up-to-date Arch box
run: |
export LC_ALL=C
mkdir -p /tmp/pf-instcheck
if ! pacman -Sy --dbpath /tmp/pf-instcheck --logfile /dev/null >/dev/null 2>&1; then
echo "::error::could not sync the Arch db — cannot prove these packages install"
exit 1
fi
check() { # check FILE -> 0 installable, 1 not (reason on stdout)
pacman -U --print --noconfirm --dbpath /tmp/pf-instcheck --logfile /dev/null "$1" 2>&1
}
ls dist/*.pkg.tar.zst >/dev/null 2>&1 || { echo "::error::nothing in dist/ to check"; exit 1; }
rc=0
for pkg in dist/*.pkg.tar.zst; do
if out="$(check "$pkg")"; then
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
else
rc=1
echo "::error::$(basename "$pkg") CANNOT be installed on an up-to-date Arch box:"
echo "$out" | sed 's/^/ /'
fi
done
# gamescope stays best-effort, exactly as its build step is: a companion that cannot
# install is dropped from the upload with a warning, never a reason to withhold the
# packages this workflow exists to publish. (It is also the one package that can be
# restored from a cache older than the current Arch snapshot.)
for pkg in dist-gamescope/*.pkg.tar.zst; do
[ -e "$pkg" ] || continue
if out="$(check "$pkg")"; then
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
else
echo "::warning::$(basename "$pkg") is not installable on current Arch — NOT publishing it"
echo "$out" | sed 's/^/ /'
rm -f "$pkg"
fi
done
if [ "$rc" != 0 ]; then
echo "::error::refusing to publish: pacman would reject this on a current box, and a"
echo "::error::rejected dependency blocks the user's ENTIRE upgrade, not just punktfunk."
echo "::error::Usual cause: the arch-ci builder image lags Arch across a soname bump —"
echo "::error::bump 'refreshed:' in ci/arch-ci.Dockerfile, let docker.yml republish it, re-run."
exit 1
fi
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
# per-partition-set), which is the standing packaging verdict behind the on-device
# distrobox build (scripts/steamdeck/, see scripts/steamdeck/README.md). That flow builds
# its own HDR gamescope too. packaging/arch/build-sysext.sh remains a by-hand tool for the
# Deck CLIENT image and for operators who accept the prebuilt-host trade-off.
- name: Publish to the Gitea Arch registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
# The gamescope companion rides the same loop (same repo, same channel).
cp -f dist-gamescope/*.pkg.tar.zst dist/ 2>/dev/null || true
for pkg in dist/*.pkg.tar.zst; do
echo "uploading $pkg"
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
@@ -401,48 +130,14 @@ jobs:
done
echo "published to $OWNER/arch/$REPO"
# On a real release, also attach the packages to the unified Gitea Release. A dispatched
# rebuild attaches to that SAME release object: the release page is a distribution surface
# too, and leaving the superseded .pkg.tar.zst sitting on it is one click away from handing
# someone the exact break the rebuild exists to fix.
- name: Attach packages to the Gitea release (stable tags + release rebuilds)
if: startsWith(gitea.ref, 'refs/tags/v') || github.event.inputs.release_tag != ''
# 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 }}
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
run: |
. scripts/ci/gitea-release.sh
TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
RID=$(ensure_release "$TAG" "$TAG" auto)
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for pkg in dist/*.pkg.tar.zst; do
upsert_asset "$RID" "$pkg"
done
# A rebuild bumps pkgrel, so its FILENAMES differ from the ones already attached, and
# upsert_asset only replaces by name — the superseded set would survive untouched.
# Drop every pacman asset (and .sha256 sidecar) this upload did not just write.
#
# ⚠⚠ THIS MUST LIVE IN THE WORKFLOW, NOT IN scripts/ci/gitea-release.sh. The sourced
# script comes from the CHECKED-OUT TREE, which on a release rebuild is the OLD TAG —
# so it can only ever offer the helpers that existed when that tag was cut. A helper
# added for this feature is therefore guaranteed ABSENT in the one code path that
# calls it: the first attempt failed with `prune_release_assets: command not found`
# after publishing perfectly. Only the workflow file itself is taken from the ref you
# dispatch. Same reason a packaging fix made after a tag does NOT reach a rebuild of
# that tag — the PKGBUILD is the tag's too.
if [ -n "${RELEASE_TAG:-}" ]; then
KEEP="$(cd dist && printf '%s ' *.pkg.tar.zst)"
# An UNMATCHED glob would come through literally and match nothing in the keep set —
# i.e. "delete every pacman asset on the release". Skip entirely instead.
case "$KEEP" in *'*'*) KEEP="" ;; esac
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
if [ -n "$KEEP" ]; then
curl -fsS "$API/releases/$RID/assets" -H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import json,sys;k=set(sys.argv[1].split());k|={n+'.sha256' for n in k};print('\n'.join('%s %s'%(a['id'],a['name']) for a in json.load(sys.stdin) if a.get('name','').endswith(('.pkg.tar.zst','.pkg.tar.zst.sha256')) and a['name'] not in k))" "$KEEP" \
| while read -r id name; do
[ -n "$id" ] || continue
echo "dropping superseded release asset: $name"
curl -fsS -o /dev/null -X DELETE "$API/releases/$RID/assets/$id" \
-H "Authorization: token $GITEA_TOKEN" || true
done
fi
fi
+13 -115
View File
@@ -1,64 +1,32 @@
# Supply-chain advisory scan for EVERY dependency tree the project ships or publishes, plus the
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
# it lands).
# Supply-chain advisory scan for BOTH dependency trees the project ships to users:
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
# * bun audit → each Bun-managed tree that ships or publishes: web (the mgmt console BFF —
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
# plugin-kit (@punktfunk/plugin-kit).
# * pnpm audit → clients/decky (the Steam Deck plugin).
# * docs-site → scanned NON-blocking (continue-on-error): known transitive advisories ride in
# via the CMS/UI chain (@unom/ui → payload → dompurify/monaco) and the nitropack
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
# verified against the LIVE site (the docs don't build standalone) — tracked in
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
# * cargo-about → license-allowlist gate over BOTH Rust workspaces (about.toml `accepted`);
# fails if any crate carries a license outside the allowlist — the regression
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
# nothing scans it — see the CRA roadmap.)
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
# change, and on demand.
# * bun audit → the web management console (Nitro/Bun BFF) — the component that holds the login
# gate, session sealing, and the mgmt bearer token, so its deps matter too.
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile change (catch a bad
# dep the moment it lands), and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
name: audit
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
push:
branches: [main]
paths:
- 'Cargo.lock'
- 'packaging/windows/drivers/Cargo.lock'
- 'web/bun.lock'
- 'docs-site/bun.lock'
- 'sdk/bun.lock'
- 'plugin-kit/bun.lock'
- 'clients/decky/pnpm-lock.yaml'
- 'about.toml'
- '.gitea/workflows/audit.yml'
paths: ['Cargo.lock', 'web/bun.lock', '.gitea/workflows/audit.yml']
workflow_dispatch:
jobs:
cargo-audit:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Cache /usr/local/cargo so the cargo-audit binary (and the advisory DB clone) persist.
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/bin
/usr/local/cargo/registry
path: /usr/local/cargo
key: cargo-audit-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-audit-
- name: cargo audit
@@ -68,17 +36,13 @@ jobs:
cargo audit
bun-audit:
strategy:
fail-fast: false
matrix:
tree: [web, sdk, plugin-kit]
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: ${{ matrix.tree }}
working-directory: web
steps:
# oven/bun's slim base lacks a CA bundle + git — actions/checkout's HTTPS fetch needs them
# (same preamble as web-screenshots.yml / ci.yml's web job).
@@ -86,75 +50,9 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
# `bun audit` queries the registry advisory DB for the versions pinned in the tree's
# bun.lock. No install/build needed — it reads the manifest + lockfile. Fails the job on any
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
# bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
# noting why here).
# `bun audit` queries the registry advisory DB for the versions pinned in web/bun.lock. No
# install/build needed — it reads the manifest + lockfile. Fails the job on any advisory, the
# same fail-on-vulnerability stance as cargo-audit above; triage a finding by bumping the dep
# (or, if genuinely unfixable + inapplicable, pinning a resolution and noting why here).
- name: bun audit
run: bun audit
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
# act_runner does not reliably honor — a red job here would take the whole run red). The full
# advisory list still lands in the log; the warning marks it wasn't clean.
docs-site-audit:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: docs-site
steps:
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- name: bun audit (non-blocking)
run: bun audit || echo "::warning::docs-site has known advisories (CMS/UI + nitropack chains) — tracked in punktfunk-planning design/cra-readiness.md"
pnpm-audit:
runs-on: ubuntu-24.04
container:
image: node:22-bookworm
timeout-minutes: 15
defaults:
run:
working-directory: clients/decky
steps:
- uses: actions/checkout@v4
# decky is pnpm-managed (pnpm-lock.yaml lockfileVersion 9.0 → pnpm 10 reads it). Like
# bun audit, `pnpm audit` needs no install/build — lockfile + registry advisory DB only.
# --prod: rollup bundles only the prod deps into the shipped plugin; devDependencies are
# build tooling that never leaves CI (auditing them fails on toolchain advisories that
# can't reach a user — the docs-site problem in miniature).
- name: pnpm audit
run: |
npm install -g pnpm@10
pnpm audit --prod
# The regression guard about.toml documents: fail if any crate in either Rust workspace carries
# a license outside the `accepted` allowlist (e.g. a copyleft dep silently entering the linked
# set). cargo-about is version-pinned: the config uses the per-crate `accepted` syntax
# validated against exactly this version.
license-gate:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/bin
/usr/local/cargo/registry
key: cargo-about-0.9.1
restore-keys: cargo-about-
- name: cargo about license gate (host + driver workspaces)
run: |
git config --global --add safe.directory "$PWD"
command -v cargo-about >/dev/null 2>&1 || cargo install --locked cargo-about --version 0.9.1 --features cli
cargo about generate about.hbs --fail -o /dev/null
cargo about generate -m packaging/windows/drivers/Cargo.toml -c about.toml about.hbs --fail -o /dev/null
+1 -6
View File
@@ -29,9 +29,4 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Tier-3 GPU stream benchmark
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
# substituted before the shell parses the line, so an input carrying shell syntax would run
# as this step (2026-08-05 review H-6).
env:
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
-59
View File
@@ -1,59 +0,0 @@
# Report-only CPU benchmarks, moved out of ci.yml: they never fail the build (shared CI
# hardware is too noisy to gate on), so running them per-push only occupied a fleet slot
# during fan-out storms. Nightly + on demand is exactly as much signal at none of the
# queue cost. The tight regression gate + the real encode/stream path live on the
# self-hosted GPU runner (Tier 3, bench-gpu.yml).
name: bench
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
schedule:
- cron: '30 4 * * *'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
bench:
# Tier-1 (criterion microbenchmarks) + Tier-2 (FEC loss recovery) — GPU-free, so they run here.
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
- name: Prep
run: |
git config --global --add safe.directory "$PWD"
command -v python3 >/dev/null || { apt-get update && apt-get install -y --no-install-recommends python3; }
- name: Tier-1 microbenchmarks (criterion)
run: cargo bench -p punktfunk-core --bench pipeline -- --warm-up-time 1 --measurement-time 3
- name: Tier-2 FEC loss recovery (loss-harness)
run: cargo run -q -p loss-harness
- name: Compare vs baseline (report-only)
run: python3 scripts/bench/compare.py --threshold 0.5
+21 -188
View File
@@ -1,58 +1,23 @@
# CI for punktfunk (Gitea Actions). Linux jobs run on the `ubuntu-24.04` fleet label; the
# Rust job runs inside the prebuilt builder image (ci/rust-ci.Dockerfile — system FFmpeg 8,
# CI for punktfunk (Gitea Actions). Linux jobs run on the `ubuntu-latest` runner; the Rust
# job runs inside the prebuilt builder image (ci/rust-ci.Dockerfile — system FFmpeg 8,
# PipeWire, GL/GBM, libcuda link stub, pinned-channel rustup) so the workspace links the
# same libs as the dev boxes. Builder images come from the LAN registry on home-ci-core
# (content-keyed, docker.yml) — never the WAN. Apple client CI lives in apple.yml (macOS
# runner). The report-only benchmarks moved to bench.yml (nightly + dispatch) so they stop
# occupying a fleet slot on every push.
# same libs as the dev boxes. Apple client CI lives in apple.yml (macOS runner).
name: ci
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
pull_request:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
rust:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# punktfunk-client-linux link deps. Also baked into rust-ci.Dockerfile — but ci.yml
# runs against the image from the PREVIOUS push (docker.yml bootstrap note), so this
# keeps the job green across image-content changes; a no-op once the image has them.
@@ -61,30 +26,6 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev
# The committed pf-zerocopy SPIR-V blobs are pulled in with include_bytes! and rebuilt only
# by hand — edit a .comp, forget the rebuild, and the OLD kernel ships with no compile error
# or failing test. Recompile each shader and diff the disassembly. Filtering OpSourceExtension
# (+ --no-header) is exactly what absorbs the shaderc-vs-glslang generator difference; every
# instruction, ID and constant must match.
#
# Disassemble to FILES rather than `diff <(…) <(…)`: Gitea's runner executes a step's `run:`
# under `sh -e`, not bash, and dash has no process substitution — the shell rejected the
# script at PARSE time, so the gate never compared anything. Worse, it took the whole `rust`
# job with it: Format, Clippy, Build, Test and every gate below were skipped on each of the
# 35 commits between the gate landing (143a707f) and this fix. A gate that cannot run is
# indistinguishable from one that passes, which is exactly the failure it exists to prevent.
- name: Shader SPIR-V drift gate (pf-zerocopy)
run: |
apt-get install -y --no-install-recommends glslang-tools spirv-tools
for s in rgb2nv12_buf cursor_blend; do
d=crates/pf-zerocopy/src/imp
glslangValidator -V "$d/$s.comp" -o "/tmp/$s.spv" >/dev/null
spirv-dis --no-header "$d/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.committed"
spirv-dis --no-header "/tmp/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.rebuilt"
diff "/tmp/$s.committed" "/tmp/$s.rebuilt" \
|| { echo "::error::$d/$s.spv is stale — rebuild it from $s.comp"; exit 1; }
done
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
# registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
@@ -120,49 +61,9 @@ jobs:
- name: Test (unit + loopback + proptest + C ABI harness)
run: cargo test --workspace --locked
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
# `cargo build`, where warnings are not errors, so pf-encode's own
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
# `default = ["pyrowave"]`, so the steps above already cover it.)
#
# `--all-targets` is load-bearing, not decoration: without it the feature-gated
# `#[cfg(test)]` modules are never compiled, which is exactly how all ten
# `NvencCudaEncoder::open` call sites in nvenc_cuda.rs's tests drifted to the wrong arity
# (E0061 x10) without any job noticing.
#
# GPU-free: every test needing real hardware is `#[ignore]`d, and NVENC/CUDA resolve their
# entry points at RUNTIME (dlopen), so the test binary links without a driver present.
# (On MSVC the same crate link-imports those symbols instead, which is why windows-host.yml
# can only type-check these tests via clippy — see the note there.)
#
# Scoped to `-p pf-encode` with ITS OWN feature names: punktfunk-host has no code gated on
# `nvenc`/`vulkan-encode` (its only `cfg(feature)` sites are the two `pyrowave` ones in
# capture.rs, and pyrowave is default-on, so the steps above already cover them). Going
# through `--features punktfunk-host/...` would force punktfunk-host into the selection and
# re-run its entire test suite a second time for no extra coverage.
#
# `pyrowave` is listed explicitly even though it is punktfunk-host's default: selecting only
# `-p pf-encode` takes the host out of the resolution, and pf-encode's own default is empty.
# Naming it keeps this the SHIPPED Linux feature set — deb.yml builds
# `--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode` WITHOUT
# `--no-default-features`, so the .deb carries nvenc + vulkan-encode + pyrowave together, and
# that combination is what deserves the lint.
- name: Clippy + test the feature-gated Linux encode backends
run: |
cargo clippy -p pf-encode --all-targets --locked \
--features nvenc,vulkan-encode,pyrowave -- -D warnings
cargo test -p pf-encode --locked --features nvenc,vulkan-encode,pyrowave
- name: C ABI harness (standalone link proof)
run: bash crates/punktfunk-core/tests/c/run.sh
- name: sccache stats (visibility only)
run: sccache --show-stats
- name: Verify generated header is committed & up to date
run: |
cargo build -p punktfunk-core --locked
@@ -170,63 +71,6 @@ jobs:
git diff --exit-code include/punktfunk_core.h \
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
# The client stack cross-checked for aarch64. NOT an artifact job — deb.yml ships those —
# this exists so a portability defect fails here instead of surfacing in a release build or
# on a user's board. It earns its runtime: the bug that motivated it (a Vulkan extension
# array typed `*const i8`, where `c_char` is signed on x86_64 and UNSIGNED on aarch64)
# compiled cleanly on every target CI built at the time.
#
# Client crates only, listed explicitly: the host's encode stack is x86 (NVENC/QSV/AMF) and
# `--workspace` would drag it in. Runs in the cross image (amd64 toolchain + arm64 sysroot,
# ci/rust-ci-arm64cross.Dockerfile) on the ordinary runner — no arm64 runner involved.
rust-arm64:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
with:
path: target
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
- name: Clippy for aarch64 (deny warnings)
run: |
cargo clippy --target aarch64-unknown-linux-gnu --all-targets --locked \
-p punktfunk-core -p pf-client-core -p pf-presenter -p pf-console-ui \
-p punktfunk-client-session -p punktfunk-client-linux \
-- -D warnings
# The minimal embedded build — no Skia, no PyroWave — is what a small image installs, so
# it has to keep compiling on its own, not just as a subset of the default features.
- name: Build the session binary, minimal features
run: |
cargo build --release --target aarch64-unknown-linux-gnu --locked \
-p punktfunk-client-session --no-default-features
web:
runs-on: ubuntu-24.04
container:
@@ -252,11 +96,6 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
# that only a browser rejects, which nothing else here would catch.
- name: Test
run: bun run test
docs-site:
runs-on: ubuntu-24.04
@@ -281,30 +120,24 @@ jobs:
- name: Typecheck
run: bun run lint
# web/bun.nix and sdk/bun.nix are GENERATED from their bun.lock (bun2nix) and committed; the Nix
# build fetches node_modules from nothing else. They regenerate only on a local `bun install` that
# runs lifecycle scripts — never under CI's `--ignore-scripts`, and never on a merge or rebase,
# which happily carries a lockfile change past a bun.nix generated before it. That is not
# theoretical: web/bun.nix sat stale on main for 553 commits (2026-07-27 → 2026-08-05) with
# `nix build .#punktfunk-web` broken, and was repaired only by accident when an advisory bump
# happened to rerun a real `bun install`.
#
# Deliberately UNFILTERED and in ci.yml rather than nix.yml: it needs no Nix, takes well under a
# minute, and the whole point is that the drift arrives through commits that look unrelated to
# Nix. The Nix-toolchain gates (flake eval + building the bun packages) live in nix.yml.
bun-nix:
bench:
# Tier-1 (criterion microbenchmarks) + Tier-2 (FEC loss recovery) — GPU-free, so they run here.
# Report-only: prints the numbers + a diff vs the committed baseline to the job summary and never
# fails the build (shared CI hardware is too noisy to gate on). The tight regression gate + the
# real encode/stream path live on the self-hosted GPU runner (Tier 3, bench-gpu.yml).
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
# Regenerates each bun.nix from its committed bun.lock and diffs, and checks that the
# bun2nix version pin agrees across flake.nix and both package.json files (bun.nix has no
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
- name: bun.nix drift gate
run: sh scripts/ci/check-bun-nix.sh
- name: Prep
run: |
git config --global --add safe.directory "$PWD"
command -v python3 >/dev/null || { apt-get update && apt-get install -y --no-install-recommends python3; }
- name: Tier-1 microbenchmarks (criterion)
run: cargo bench -p punktfunk-core --bench pipeline -- --warm-up-time 1 --measurement-time 3
- name: Tier-2 FEC loss recovery (loss-harness)
run: cargo run -q -p loss-harness
- name: Compare vs baseline (report-only)
run: python3 scripts/bench/compare.py --threshold 0.5
+14 -192
View File
@@ -1,13 +1,9 @@
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
# apt distribution/component:
#
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
# build-publish-client-arm64
# — the same client package for arm64, CROSS-compiled on the same amd64
# runner in the rust-ci-arm64cross image. No host counterpart: the Linux
# host's encode stack is x86 (NVENC/QSV/AMF).
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
@@ -23,36 +19,10 @@
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
name: deb
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'clients/linux/**'
- 'clients/session/**'
- 'clients/shared/**'
- 'clients/cli/**'
- 'web/**'
- 'sdk/**'
- 'packaging/debian/**'
- 'packaging/linux/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/deb.yml'
# Single project version: a `vX.Y.Z` tag is THE release for every platform (see
# docs-site channels.md). The old version-shadow (a client tag shipping a host package
# that outranked rolling builds) is now structurally impossible — main publishes to the
@@ -64,35 +34,16 @@ env:
REGISTRY: git.unom.io
OWNER: unom
COMPONENT: main
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
build-publish:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
- name: Version + channel
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
# A main push -> <next-minor>~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
@@ -119,15 +70,10 @@ jobs:
run: |
apt-get update
# python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach.
# No libvulkan-dev: nothing here compiles or links against Vulkan (ash dlopens
# libvulkan and pf-vkdecode binds nothing at build time), so neither the compile nor
# dpkg-shlibdeps — which resolves DT_NEEDED sonames only — ever asks for it. The
# client's `Depends: libvulkan1` is added by hand in packaging/debian/build-client-deb.sh
# precisely because a dlopen is invisible to shlibdeps.
# No libav*-dev: the client links no FFmpeg since M10 (§6 of
# design/client-native-decode.md).
# libvulkan-dev: /usr/include/vulkan/vulkan.h for the client's pf-ffvk bindgen
# (FFmpeg's hwcontext_vulkan.h includes it).
apt-get install -y --no-install-recommends dpkg-dev python3 \
libgtk-4-dev libadwaita-1-dev libsdl3-dev
libgtk-4-dev libadwaita-1-dev libsdl3-dev libvulkan-dev
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
- name: Cache keys
@@ -152,16 +98,10 @@ jobs:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
run: |
git config --global --add safe.directory "$PWD"
# FOUR binaries ship in the client .deb, so all four are built here: the GTK shell,
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect),
# punktfunk-cli (the headless `punktfunk` front-end), and pf-update (the root helper
# behind `punktfunk-client --apply-update`). build-client-deb.sh installs all four;
# leaving punktfunk-cli out here made it fall over on `install: No such file or
# directory`, because its build-if-missing guard only tested the first two and so decided
# everything was already built. The HOST is built separately in the build-publish-host
# job (Ubuntu 24.04 image + bundled FFmpeg 8).
cargo build --release --locked \
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
# both client binaries must ship (build-client-deb.sh installs both). The HOST is built
# separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8).
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
- name: Build + smoke-boot web console (bun preset)
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
@@ -240,21 +180,11 @@ jobs:
build-publish-host:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci-noble:latest
image: git.unom.io/unom/punktfunk-rust-ci-noble:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
- name: Version + channel
run: |
git config --global --add safe.directory "$PWD"
@@ -300,18 +230,11 @@ jobs:
git config --global --add safe.directory "$PWD"
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). ffmpeg-sys-next links the image's
# bundled FFmpeg 8 via PKG_CONFIG_PATH (set in rust-ci-noble).
#
# punktfunk-tray is deliberately NOT in this invocation — build-deb.sh builds it separately,
# and that split is load-bearing (see the identical note in the RPM spec / Arch PKGBUILD):
# cargo unifies features across one build, so co-building the tray with the host pulls the
# host's ashpd -> zbus/tokio onto the tray's shared zbus and the tray panics at every launch
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
# Arch packages — which already split it — were fine.
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
# via PKG_CONFIG_PATH (set in rust-ci-noble).
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host
-p punktfunk-host -p punktfunk-tray
- name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
@@ -346,104 +269,3 @@ jobs:
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
# ---------------------------------------------------------------------------------------------
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
# ci/rust-ci-arm64cross.Dockerfile); there is no arm64 runner in the fleet and none is needed.
# Client only, by decision: the Linux host encodes with NVENC/QSV/AMF, all x86.
# Publishes to the same distribution/component as the amd64 jobs — the apt registry keys pool
# entries by arch, so `apt` on an arm64 box picks this one up with no client-side configuration.
build-publish-client-arm64:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# Byte-identical to build-publish's version step (pf-version.sh is deterministic per
# commit), so the arm64 package always shares the amd64 version line.
- name: Version + channel
run: |
eval "$(bash scripts/ci/pf-version.sh)"
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "package version $V -> apt distribution '$DIST' (arm64)"
# dpkg-shlibdeps + dpkg-deb. The arm64 link deps themselves are the cross image's whole
# point and are already baked in; python3 is for scripts/ci/gitea-release.sh.
- name: dpkg-dev
run: |
apt-get update
apt-get install -y --no-install-recommends dpkg-dev python3
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
with:
path: target
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
# and must never share the amd64 jobs' target cache.
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
- name: Build the arm64 client .deb
env:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
run: |
git config --global --add safe.directory "$PWD"
ARCH=arm64 TARGET=aarch64-unknown-linux-gnu \
bash packaging/debian/build-client-deb.sh
# Fail here rather than shipping an amd64 binary under an arm64 package name.
readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \
| grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; }
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for DEB in dist/*.deb; do
echo "uploading $DEB"
NAME=$(dpkg-deb -f "$DEB" Package)
VER=$(dpkg-deb -f "$DEB" Version)
ARCH=$(dpkg-deb -f "$DEB" Architecture)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
done
echo "published arm64 client to $OWNER/debian $DISTRIBUTION/$COMPONENT"
- name: Attach the arm64 .deb to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
+31 -43
View File
@@ -6,12 +6,17 @@
#
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
# We build the frontend with pnpm and stage the store-layout tree with the SAME script local
# builds use (clients/decky/scripts/package.sh) — the plugin's file list lives in exactly ONE
# place, so a file added there (bin/, assets/, controller_config/, …) can never be silently
# missing from the published build. (Hand-assembling the zip here is how the shipped plugin
# lost the shortcut artwork + Steam Input layout for a while.) CI only adds `update.json` on
# top: the {channel, manifest} pointer the plugin's self-update check polls.
# We build the frontend with pnpm and assemble the store-layout zip by hand:
#
# punktfunk.zip
# punktfunk/ <- single top-level dir == plugin.json "name"
# plugin.json [required]
# package.json [required; CI stamps "version" — Decky reads the installed version here]
# main.py [required: python backend]
# dist/index.js [required: rollup output]
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
# README.md (recommended)
# LICENSE [required by the plugin store]
#
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
@@ -20,25 +25,10 @@
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
name: decky
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'clients/decky/**'
- 'scripts/ci/**'
- '.gitea/workflows/decky.yml'
tags: ['v*']
workflow_dispatch:
@@ -46,10 +36,7 @@ env:
REGISTRY: git.unom.io
OWNER: unom
PACKAGE: punktfunk-decky # generic-registry package name
# The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name"
# (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by
# the folder) — see clients/decky/scripts/package.sh.
PLUGIN: punktfunk
PLUGIN: punktfunk # plugin.json "name" == zip top-level dir
jobs:
build-publish:
@@ -103,27 +90,28 @@ jobs:
- name: Assemble store-layout zip
working-directory: ${{ gitea.workspace }}
run: |
# node:22-bookworm ships python3 (a package.sh dep) but not zip; install both anyway
# so an image change can't silently break the build.
apt-get update && apt-get install -y --no-install-recommends zip python3 >/dev/null
# Stage the canonical plugin tree (dist/, main.py, bin/, assets/, controller_config/,
# LICENSE, …) with the same script local/sideload builds use — see the header comment.
# Runs AFTER the version stamp, so the staged package.json carries $VERSION.
bash clients/decky/scripts/package.sh
DEST="clients/decky/out/$PLUGIN"
# CI-only addition: the self-update channel pointer the backend reads (main.py
# check_update). It points at THIS channel's manifest.json (published below); that
# manifest in turn points at the immutable per-version zip, so its sha256 stays valid
# across future alias re-uploads.
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
STAGE="$RUNNER_TEMP/decky"
DEST="$STAGE/$PLUGIN"
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
cp clients/decky/plugin.json "$DEST/"
cp clients/decky/package.json "$DEST/"
cp clients/decky/main.py "$DEST/"
cp clients/decky/dist/index.js "$DEST/dist/"
cp clients/decky/README.md "$DEST/"
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
chmod 0755 "$DEST/bin/punktfunkrun.sh"
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
cp LICENSE-MIT "$DEST/LICENSE"
# Self-update channel pointer the backend reads (main.py check_update). It points at
# THIS channel's manifest.json (published below); that manifest in turn points at the
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
( cd clients/decky/out && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
ls -lh "$RUNNER_TEMP/punktfunk.zip"
unzip -l "$RUNNER_TEMP/punktfunk.zip"
# Backstop against packaging drift: the runtime-loaded pieces MUST be in the zip.
for f in main.py dist/index.js bin/punktfunkrun.sh assets/grid.png \
controller_config/punktfunk.vdf update.json; do
unzip -l "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN/$f" >/dev/null || { echo "MISSING $f" >&2; exit 1; }
done
# The update manifest the plugin polls: the immutable per-version artifact + its
# sha256 (Decky's installer verifies the download against this hash, aborting on
# mismatch — so it MUST be the per-version URL, never the mutable alias).
-38
View File
@@ -99,41 +99,3 @@ jobs:
mkdir -p ~/unom-flatpak/site/repo
cd ~/unom-flatpak
docker compose -f compose.production.yml up -d
winget:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync winget source compose + server
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
# Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix).
source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs"
target: "~/unom-winget"
strip_components: 3
overwrite: true
- name: Start winget REST source
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
# ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each
# stable tag (same content/config split as the flatpak repo). Ensure the bind-mount
# source exists so the container starts; it serves 503 until the first catalogue lands.
mkdir -p ~/unom-winget/data
cd ~/unom-winget
docker compose -f compose.production.yml up -d
# Surface a missing catalogue here rather than letting winget report "no package found".
sleep 3
curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it"
+30 -268
View File
@@ -1,72 +1,17 @@
# Build + push the dockerized pieces.
#
# Two very different image families now:
#
# BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm)
# live on the LAN registry (home-ci-core — unom/infra runners/ci-core/) and are
# CONTENT-KEYED: the tag is a hash of what they are built from (the ci/ tree, +
# rust-toolchain.toml for the cross image), and a build only happens when that key
# has no manifest yet. A push that doesn't touch ci/ costs one curl per image
# (~seconds), pushes nothing over the WAN, and mints no per-SHA tag debris on the
# runners — the failure mode that filled the fleet's disks. `:latest` is re-pushed
# alongside every new key and is what the consuming workflows pin.
#
# READS come from :5010 and need no credential. WRITES go to :5011 and need
# CI_REGISTRY_PASSWORD. Same store behind both — a registry keys by repository name,
# not by the host:port the client used — so an image pushed to :5011 is the same
# image every consumer pulls from :5010.
#
# APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the
# Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because
# unom-1 deploys pull from there and releases pin them.
#
# Build + push the dockerized pieces to the Gitea container registry:
# punktfunk-web — management console (web/Dockerfile, repo-root context)
# punktfunk-docs — documentation site (docs-site/Dockerfile)
# punktfunk-rust-ci — Rust CI builder image consumed by ci.yml
# punktfunk-fedora-rpm — Fedora 43 builder image consumed by rpm.yml (Bazzite RPM)
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images).
# CI_REGISTRY_PASSWORD: repo Actions secret, the LAN registry's push credential for user
# `ci`. Generated on ci-core into /srv/ci/stack/registry-secret; rotate in both places.
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope.
#
# --- security-review-2026-08-05 H-6, FIXED 2026-08-05 -------------------------------
# The registry used to accept anonymous pushes from any LAN peer, and every
# secret-bearing job in this repo runs INSIDE an image pulled from it. Attacker
# position #1 of the project's own threat model did not need to break any signing
# logic: push one tag, and the next android.yml run executes their code in the same job
# that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape for
# rpm.yml (RPM_GPG_PRIVATE_KEY) and android-promote.yml (SERVICE_ACCOUNT_JSON).
#
# The infra half is done (unom/infra runners/ci-core/): :5010 serves GET/HEAD only and
# refuses everything else with 405, :5011 demands basic auth on every request. The half
# in this file is done below: pushes and release-tag manifest PUTs authenticate.
#
# ⚠ On the second half as the review originally worded it — "pin consumers by @sha256:
# digest". We deliberately do something else, because after authentication the digest
# pin no longer buys what it was meant to buy. The set of people who can overwrite a tag
# is now exactly the set who can push to main and edit a pinned digest in this very
# file: a pin defends against nobody it did not already trust, while costing a
# two-commit dance on every ci/ change (~3x a month) during which consumers silently run
# a builder image that predates the ci/ change they are testing.
#
# What actually closes the residual gap — a tag quietly overwritten out of band — is
# making :latest a CHECKED function of the tree instead of a tag someone remembered to
# move. The "Reconcile :latest" step below asserts on every run that :latest and
# :ck-$KEY are the same digest, repairs it when they are not, and says so loudly. That
# catches an out-of-band overwrite on the next push to main, needs no churn, and fixes
# a real pre-existing bug on the side: reverting ci/ used to leave :latest pointing at
# the newer build forever. Revisit inline digest pins if the push credential ever leaves
# the maintainer trust set.
#
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
# images); after that, this workflow keeps :latest current whenever ci/ changes.
# Bootstrap note: ci.yml's rust job pulls punktfunk-rust-ci:latest from the registry, so
# this workflow (or a manual push) must have succeeded once before that job can run; on
# the same push, ci.yml builds against the PREVIOUS image. All three were seeded manually
# on 2026-06-12.
name: docker
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
@@ -77,209 +22,9 @@ on:
env:
REGISTRY: git.unom.io
OWNER: unom
# Read port (anonymous, GET/HEAD only) and write port (basic auth). Two doors onto
# one store; see the header.
CI_REGISTRY: 192.168.1.58:5010
CI_REGISTRY_PUSH: 192.168.1.58:5011
jobs:
builders:
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
matrix:
include:
- image: punktfunk-rust-ci
dockerfile: ci/rust-ci.Dockerfile
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
- image: punktfunk-rust-ci-noble
dockerfile: ci/rust-ci-noble.Dockerfile
- image: punktfunk-fedora-rpm
dockerfile: ci/fedora-rpm.Dockerfile
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
- image: punktfunk-fedora44-rpm
dockerfile: ci/fedora-rpm.Dockerfile
buildargs: --build-arg FEDORA_VERSION=44
keysuffix: -f44
# Android builder (JDK + SDK/NDK + cargo-ndk + sccache) — android.yml and
# android-screenshots.yml run in it; ~3 GB of per-run Google downloads became
# image layers.
- image: punktfunk-android-ci
dockerfile: ci/android-ci.Dockerfile
# Arch builder (base-devel + both makepkg legs' deps + bun + sccache) —
# arch.yml runs in it; ~1 GB of per-run pacman traffic became image layers.
- image: punktfunk-arch-ci
dockerfile: ci/arch-ci.Dockerfile
steps:
- uses: actions/checkout@v4
# The key is the git TREE HASH of ci/ — every byte any of these Dockerfiles can see
# (they all use ci/ as build context). One key for the whole family on purpose: a
# change to any of them re-keys all four, and a spurious rebuild of a sibling is
# cheap, rare, and infinitely better than a stale one.
- name: Content key
run: |
git config --global --add safe.directory "$PWD"
echo "KEY=ck-$(git rev-parse HEAD:ci | cut -c1-12)${{ matrix.keysuffix }}" >> "$GITHUB_ENV"
- name: Check whether this key already exists
id: exists
run: |
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
if curl -sf -o /dev/null -H "$ACCEPT" \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"; then
echo "hit=true" >> "$GITHUB_OUTPUT"
echo "::notice::${{ matrix.image }}:$KEY already in the LAN registry — nothing to build"
else
echo "hit=false" >> "$GITHUB_OUTPUT"
fi
# Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it
# can only fail. Consumers still pull the identical image from :5010.
- name: Build
if: steps.exists.outputs.hit == 'false'
# --pull is cheap now: base images come through the ci-core pull-through mirror.
run: |
docker build --pull ${{ matrix.buildargs }} \
-f "${{ matrix.dockerfile }}" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
ci
# Gated like Build/Push: only the docker CLI needs this login (Reconcile and Tag-for-release
# authenticate via curl -u), so a cache-hit job with nothing to push must not be able to fail
# on a login it never uses — proven on run 16013, where a host with a misconfigured daemon
# failed exactly here on a hit=true leg.
- name: Log in to the LAN registry
if: steps.exists.outputs.hit == 'false'
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest"
# :latest must be whatever ci/ says it is, on every run — not only on the runs that
# happened to build. Two things break that: an out-of-band overwrite (the H-6
# attack, now only reachable by someone holding the push credential), and a plain
# revert of ci/, which leaves :latest on the newer build because the older key is
# already a cache hit and nothing re-points it. Both look identical from here and
# both are repaired the same way, so repair and shout rather than fail the build.
- name: Reconcile :latest with the content key
run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
# A release pins reproducible builder images without any rebuild: copy the key's
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
- name: Tag for release
if: startsWith(github.ref, 'refs/tags/v')
run: |
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
MT=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY" \
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
--data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
# Today the job container is ephemeral (the ubuntu-24.04 label is a docker://
# image), so the credential docker login wrote would die with it anyway. Don't
# make that a load-bearing assumption about a runner label somebody may change to
# a host runner later.
- name: Log out of the LAN registry
if: always()
run: docker logout "$CI_REGISTRY_PUSH" || true
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
# by the arm64 client legs in ci.yml/deb.yml. Its key also folds in rust-toolchain.toml:
# the Dockerfile installs the aarch64 target against the toolchain the workspace pins.
builders-arm64cross:
runs-on: ubuntu-24.04
needs: builders
timeout-minutes: 60
env:
IMAGE: punktfunk-rust-ci-arm64cross
steps:
- uses: actions/checkout@v4
- name: Content key
run: |
git config --global --add safe.directory "$PWD"
echo "KEY=ck-$(printf '%s%s' "$(git rev-parse HEAD:ci)" "$(git rev-parse HEAD:rust-toolchain.toml)" | sha256sum | cut -c1-12)" >> "$GITHUB_ENV"
- name: Check whether this key already exists
id: exists
run: |
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
if curl -sf -o /dev/null -H "$ACCEPT" \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"; then
echo "hit=true" >> "$GITHUB_OUTPUT"
echo "::notice::$IMAGE:$KEY already in the LAN registry — nothing to build"
else
echo "hit=false" >> "$GITHUB_OUTPUT"
fi
- name: Build
if: steps.exists.outputs.hit == 'false'
# Root context: it needs rust-toolchain.toml to install the target against the
# toolchain the workspace actually pins.
run: |
docker build --pull \
-f ci/rust-ci-arm64cross.Dockerfile \
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
.
# Same gate as the builders job above: the login only serves Push.
- name: Log in to the LAN registry
if: steps.exists.outputs.hit == 'false'
run: |
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY"
docker push "$CI_REGISTRY_PUSH/$IMAGE:latest"
- name: Reconcile :latest with the content key
run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Tag for release
if: startsWith(github.ref, 'refs/tags/v')
run: |
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
MT=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
--data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
env:
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Log out of the LAN registry
if: always()
run: docker logout "$CI_REGISTRY_PUSH" || true
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
apps:
build-push:
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
@@ -291,6 +36,23 @@ jobs:
- image: punktfunk-docs
dockerfile: docs-site/Dockerfile
context: docs-site
- image: punktfunk-rust-ci
dockerfile: ci/rust-ci.Dockerfile
context: ci
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
- image: punktfunk-rust-ci-noble
dockerfile: ci/rust-ci-noble.Dockerfile
context: ci
- image: punktfunk-fedora-rpm
dockerfile: ci/fedora-rpm.Dockerfile
context: ci
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
- image: punktfunk-fedora44-rpm
dockerfile: ci/fedora-rpm.Dockerfile
context: ci
buildargs: --build-arg FEDORA_VERSION=44
steps:
- uses: actions/checkout@v4
@@ -305,7 +67,7 @@ jobs:
# On a release tag, also tag the image vX.Y.Z so a release pins reproducible web/docs images.
EXTRA=""
case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac
docker build --pull \
docker build --pull ${{ matrix.buildargs }} \
-f "${{ matrix.dockerfile }}" \
-t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \
-t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \
@@ -324,7 +86,7 @@ jobs:
# unom-ci-deploy key).
deploy-docs:
runs-on: ubuntu-24.04
needs: apps
needs: build-push
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
+6 -64
View File
@@ -19,14 +19,6 @@
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
name: flatpak
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
@@ -34,10 +26,7 @@ on:
# The flatpak is the CLIENT — only rebuild when the client/core/manifest change, not on every
# design/host push (this is a heavy flatpak-builder run). Tags (v*, the client release) build too.
# The bundle ships BOTH client binaries (shell + Vulkan session), so every crate in either
# binary's dependency closure must be listed here — including the native decode rungs, or a
# commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly
# stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core
# and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead).
# binary's dependency closure must be listed here.
paths:
- 'clients/linux/**'
- 'clients/session/**'
@@ -45,9 +34,6 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-vaadec/**'
- 'packaging/flatpak/**'
- 'Cargo.lock'
- '.gitea/workflows/flatpak.yml'
@@ -70,19 +56,8 @@ jobs:
container:
# Fedora ships a recent flatpak + flatpak-builder + the kernel userns support.
# --privileged is required for bubblewrap inside the Docker executor (see header).
#
# --network host is what finally fixed the years-long "Could not resolve
# hostname" on every flathub fetch. MEASURED 2026-07-30 on home-runner-2, all
# in ONE container: `getent hosts dl.flathub.org` resolved, `curl` got HTTP
# 200 (both auto and -4), and flatpak still failed error [6] — so it was never
# DNS config, the resolver, the docker version, or the per-job network. It is
# ostree's own resolver refusing to work through Docker's embedded 127.0.0.11
# (proven: rewriting resolv.conf to a real nameserver did NOT help, and the
# default bridge failed too, while the host netns — no embedded resolver in the
# path at all — works every time). Host networking also means this job no
# longer needs the nsswitch surgery below to be lucky.
image: fedora:43
options: --privileged --network host
options: --privileged
steps:
# DNS fix — MUST run before any network step. fedora:43's nsswitch.conf is
# `hosts: files myhostname resolve [!UNAVAIL=return] dns`: the `resolve`
@@ -98,21 +73,8 @@ jobs:
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
# there, right before the first `flatpak` network call.
- name: Fix container DNS (drop nss-resolve)
run: |
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# History: this step used to ALSO force glibc onto TCP DNS (`options use-vc`) because
# the runner fleet's Docker embedded resolver dropped UDP lookups under parallel-job
# load (investigated 2026-07-11; v0.15.0/v0.16.0 each burned retry.sh's whole budget).
# That root cause is now fixed at the infra level (2026-07-22): the runner host runs a
# local dnsmasq cache on the docker bridge and daemon.json points every job container
# at it, so lookups terminate on-box instead of crossing the saturated uplink — the
# UDP path is reliable again. The TCP path through the same chain proved FLAKY under
# fleet concurrency (flatpak remote-add failed 10/10 with instant NXDOMAIN while dnf
# in the same container resolved fine), so `use-vc` flipped from mitigation to sole
# cause of this leg's failures — removed. retry.sh (10×) stays as the backstop for
# genuine upstream blips.
cat /etc/resolv.conf || true
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
# executes via the container shell (no node needed), so install node BEFORE checkout.
@@ -134,7 +96,7 @@ jobs:
# authselect trigger fires — so this line alone was never the fix for the failures
# below. See the retry.sh bump for the real cause.
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# Flathub provides the GNOME runtime/SDK + the rust-stable and llvm20 extensions.
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
#
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
@@ -152,25 +114,6 @@ jobs:
https://dl.flathub.org/repo/flathub.flatpakrepo
git config --global --add safe.directory "$PWD"
# This job was the fleet's single heaviest network consumer: every run re-downloaded
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and
# every crate source. Both live in well-defined directories, both are idempotently
# verified/extended by the steps below, and the central cache server restores them
# at LAN speed — so cache them. Keyed on what actually pins them: the manifest tree
# (runtimes/extensions) and manifest+Cargo.lock (crate sources + builder state).
- name: Cache Flathub runtimes
uses: actions/cache@v4
with:
path: ~/.local/share/flatpak
key: flatpak-runtimes-${{ hashFiles('packaging/flatpak/**') }}
restore-keys: flatpak-runtimes-
- name: Cache flatpak-builder state (crate sources, ccache)
uses: actions/cache@v4
with:
path: .flatpak-builder
key: flatpak-builder-state-${{ hashFiles('Cargo.lock', 'packaging/flatpak/**') }}
restore-keys: flatpak-builder-state-
- name: Version + channel
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
# <next-minor>-ciN.g<sha> on the `canary` branch (base one minor ahead of the latest stable
@@ -257,8 +200,7 @@ jobs:
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
# extensions. (No codec extension: the client links no FFmpeg — see the
# manifest header.)
# extensions, plus the runtime's auto codecs-extra (HEVC libavcodec).
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
+1 -33
View File
@@ -5,55 +5,23 @@
# Standalone + best-effort: a failure here reds nothing else. PNGs land as a 30-day
# artifact; they are not committed or published.
name: linux-client-screenshots
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
tags: ["v*"]
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
screenshots:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
# Same image as ci.yml/deb.yml — already carries the Rust toolchain + GTK/SDL build deps.
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# Client link deps (baked into the image; kept here so the job is green across image
# rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server,
# software GL+Vulkan (llvmpipe/lavapipe), the icon theme + fonts the UI draws with, and a
-167
View File
@@ -1,167 +0,0 @@
# Nix packaging gate. Until this existed, NOTHING in CI ever evaluated flake.nix: the word "nix"
# appeared in exactly one workflow file, and only in a comment about bun2nix breaking a Windows
# step. Every Nix regression therefore reached main invisibly and was found by hand on a Nix box —
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
# in ci.yml for that story).
#
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
# the NixOS module without building them. Catches the failures that actually happen to
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
# error, a package attribute dropped from packages.nix.
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
# the generated bun.nix really does materialise a working node_modules offline — it
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
#
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
# workflow parser is not a place to bet on anchor support. Keep them in step by hand.
name: nix
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- "flake.nix"
- "flake.lock"
- "packaging/nix/**"
- "**/bun.lock"
- "**/bun.nix"
- "**/package.json"
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".gitea/workflows/nix.yml"
- "scripts/ci/check-bun-nix.sh"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "packaging/nix/**"
- "**/bun.lock"
- "**/bun.nix"
- "**/package.json"
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".gitea/workflows/nix.yml"
- "scripts/ci/check-bun-nix.sh"
workflow_dispatch:
inputs:
build-rust:
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
type: boolean
default: false
jobs:
flake:
runs-on: ubuntu-24.04
container:
# NOT nixos/nix. That image contains nix and essentially nothing else — in particular no
# /bin/sleep, and Gitea's act_runner starts every job container with
# `entrypoint=["/bin/sleep","10800"]`. The container therefore never starts:
# failed to create shim task: OCI runtime create failed: unable to start container
# process: exec: "/bin/sleep": stat /bin/sleep: no such file or directory
# and — the part that makes this expensive to debug — every step is then reported as
# `cancelled` rather than failed, which reads exactly like a superseded run.
#
# node:22-bookworm instead: a full Debian with coreutils (so the entrypoint exists) and a
# real node (so actions/checkout works with no pre-checkout install dance), and audit.yml
# already pulls it on this fleet, so it is proven to resolve here. Nix is installed below.
image: node:22-bookworm
timeout-minutes: 90
env:
# The flake needs both experimental features. Also baked into the installer's --extra-conf
# below; this covers any step that shells out before that config is read.
NIX_CONFIG: "experimental-features = nix-command flakes"
# Absolute path rather than $GITHUB_PATH: one less runner behaviour to assume.
NIX: /nix/var/nix/profiles/default/bin/nix
# `--init none` installs Nix with NO daemon running, but the installer still writes a profile
# script that exports NIX_REMOTE=daemon. Anything that sources it (any `-l` login shell) then
# dies on `cannot connect to socket at '/nix/var/nix/daemon-socket/socket'` — which is exactly
# how the installer's own self-test fails during this step, harmlessly, and would be a
# confusing first thing to read in the log. The steps below never source that profile, but pin
# the empty value so a future step cannot reintroduce it. Empty = talk to the local store
# directly, which works because the job runs as root (MEASURED: "Store URL: local, Trusted: 1",
# and a real `nix build` of a trivial derivation succeeds).
NIX_REMOTE: ""
steps:
- uses: actions/checkout@v4
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout.
# (node:22-bookworm is the full image and already has all three — this is belt-and-braces
# against a future slim-image swap, and costs one cached apt call.)
- name: Installer prerequisites
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
# `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks
# to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the
# hand-verification box stay on the same distribution.
- name: Install Nix
run: |
curl -fsSL https://install.determinate.systems/nix -o /tmp/nix-installer.sh
sh /tmp/nix-installer.sh install linux --init none --no-confirm \
--extra-conf "experimental-features = nix-command flakes"
"$NIX" --version
# Nix reads the flake through libgit2 and refuses a checkout owned by another uid
# ("detected dubious ownership"), which is the normal case for a container job.
- name: Trust the checkout
run: git config --global --add safe.directory "$PWD"
# Diagnostics. This fleet ran a runner out of disk on 2026-08-06 (the ci.yml `web` job died
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
# here — so record the headroom, or a future failure is a guess.
- name: Environment
run: df -h / /nix /tmp || true
# Evaluates + instantiates every flake output without building any of it.
- name: nix flake check (eval only)
run: |
"$NIX" flake check --no-build --show-trace
# The bun packages, built for real. This is the leg that would have caught the stale
# web/bun.nix end to end: the derivation's offline `bun install` runs against a store cache
# built strictly from bun.nix, so a lockfile that cache does not cover fails here.
# Path-filtered, so it runs only when the packaging or a lockfile actually moves. If it ever
# starts going red on runner disk rather than on real defects, demote it to the dispatch
# opt-in below rather than leaving an infra-red gate on the board.
- name: Build the bun packages
run: |
"$NIX" build --print-build-logs .#punktfunk-web .#punktfunk-scripting
# Both launchers exec pkgs.bun from the store; confirm they were produced and are real entry
# points rather than dangling wrappers.
- name: Smoke the built launchers
run: |
set -eu
web=$("$NIX" path-info .#punktfunk-web)
scripting=$("$NIX" path-info .#punktfunk-scripting)
test -x "$web/bin/punktfunk-web-server" || { echo "no punktfunk-web-server in $web" >&2; exit 1; }
test -x "$scripting/bin/punktfunk-scripting" || { echo "no punktfunk-scripting in $scripting" >&2; exit 1; }
# The console must be the bun bundle, not a node one — the same assertion packages.nix
# makes at build time, re-checked on the installed output.
grep -q 'Bun\.serve' "$web/share/punktfunk-web/.output/server/index.mjs" \
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
echo "bun packages OK: $web $scripting"
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
- name: Build the Rust packages (dispatch opt-in)
if: ${{ github.event.inputs.build-rust == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
-103
View File
@@ -1,103 +0,0 @@
# Publish the plugin framework (@punktfunk/plugin-kit) to the Gitea npm registry
# (https://git.unom.io/api/packages/unom/npm/).
#
# Trigger: push a tag `plugin-kit-vX.Y.Z` (must equal plugin-kit/package.json "version"),
# or run manually. Versions independently of the app's `v*` and the SDK's `sdk-v*` tags.
#
# The kit's devDependency on @punktfunk/host is `file:../sdk`, so the SDK's dist must be
# built BEFORE the kit's `bun install` copies it.
#
# Auth: REGISTRY_TOKEN — the same repo Actions secret sdk-publish.yml uses.
name: plugin-kit-publish
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
tags: ['plugin-kit-v*']
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
steps:
# oven/bun's slim base ships neither git, a CA bundle, nor node — actions/checkout's HTTPS
# fetch needs git + ca-certificates, and the version-guard step below uses node.
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- name: Build the SDK (file:../sdk dependency source)
working-directory: sdk
run: |
bun install --frozen-lockfile --ignore-scripts
bun run build
- name: Install dependencies
working-directory: plugin-kit
run: bun install --frozen-lockfile --ignore-scripts
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
# dangling self-reference. `dist/` therefore arrives intact while the manifest that points at
# it does not, so module resolution dies at the first step and every `@punktfunk/host` import
# reads as "cannot find module". Replacing the tree with a real copy is the whole fix; drop
# this step once bun links `file:` deps correctly again.
- name: "Repair the file: dependency (bun 1.3 self-symlink)"
working-directory: plugin-kit
run: |
# -f follows the link, so this is true only when the manifest actually resolves.
if test -f node_modules/@punktfunk/host/package.json; then
echo "bun linked it correctly — this step can go"
else
rm -rf node_modules/@punktfunk/host
cp -R ../sdk node_modules/@punktfunk/host
fi
test -f node_modules/@punktfunk/host/package.json
test -f node_modules/@punktfunk/host/dist/index.d.ts
# The kit had no biome config and no lint step, while every plugin repo that consumes it does
# — so its source drifted (unused imports, formatting) with nothing to catch it. Now gated
# here, on the same config and pinned biome version the plugins use.
- name: Lint & format
working-directory: plugin-kit
run: bun run check
- name: Typecheck
working-directory: plugin-kit
run: bun run typecheck
- name: Test
working-directory: plugin-kit
run: bun test
- name: Build (dist/ JS + .d.ts + theme.css)
working-directory: plugin-kit
run: bun run build
- name: Tag matches package version
if: startsWith(github.ref, 'refs/tags/')
working-directory: plugin-kit
run: |
TAG="${GITHUB_REF_NAME#plugin-kit-v}"
PKG="$(node -p "require('./package.json').version")"
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME does not match package version $PKG"; exit 1; }
- name: Publish to Gitea registry
working-directory: plugin-kit
env:
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
bun publish
-129
View File
@@ -56,14 +56,6 @@
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
# loud warning.
name: release
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
@@ -88,21 +80,6 @@ on:
required: false
default: "true"
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
# side stays on DerivedData (sccache doesn't cache swiftc).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
# runner's persistent target/ disposable instead of precious.
CARGO_INCREMENTAL: "0"
jobs:
apple:
runs-on: macos-arm64
@@ -172,38 +149,6 @@ jobs:
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
- name: Pin + prune Xcode DerivedData
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
# under ~/Library that nothing ever collected. 31 of them piled up in three days
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
run: |
DD="$HOME/ci/derived-data/release"
mkdir -p "$DD"
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
# Safety net for trees the pin does not own: the legacy per-path ones from before this
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
@@ -231,7 +176,6 @@ jobs:
-project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGNING_ALLOWED=NO
@@ -329,7 +273,6 @@ jobs:
-project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
@@ -393,7 +336,6 @@ jobs:
-project "$PROJECT" -scheme Punktfunk-iOS \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
@@ -428,76 +370,6 @@ jobs:
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: iOS — export .ipa (Gitea release + run artifact)
# The TestFlight step above uploads straight to App Store Connect (destination=upload) and
# leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an
# App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as
# that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent,
# e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs
# only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact.
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
id: ios_ipa
run: |
ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive"
if [ ! -d "$ARCHIVE" ]; then
echo "::warning::iOS archive not found — skipping .ipa export"
exit 0
fi
PROFILE="Punktfunk iOS App Store Distribution"
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
# destination=export writes the .ipa to -exportPath; otherwise identical manual signing to
# the upload plist (both profiles, Apple Distribution). No ASC key needed — no network.
cat > "$RUNNER_TEMP/export-appstore-ipa.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>export</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \
-exportPath "$RUNNER_TEMP/export-ipa"
SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1)
[ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; }
mkdir -p "$GITHUB_WORKSPACE/dist"
IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa"
mv "$SRC" "$IPA"
echo "IPA=$IPA" >> "$GITHUB_ENV"
echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT"
echo "exported $IPA"
- name: Attach .ipa to the workflow run
if: steps.ios_ipa.outputs.ipa != ''
# v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses
# (same reason as android.yml / apple.yml). Download is a zip of the .ipa.
uses: actions/upload-artifact@v3
with:
name: punktfunk-ios-ipa
path: ${{ steps.ios_ipa.outputs.ipa }}
if-no-files-found: warn
retention-days: 30
- name: Attach .ipa to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != ''
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$IPA" "Punktfunk-$VERSION.ipa"
- name: tvOS — archive + upload to TestFlight
# Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built
# on every apple push (above), so this matches the iOS step's gate exactly.
@@ -522,7 +394,6 @@ jobs:
-project "$PROJECT" -scheme Punktfunk-tvOS \
-destination 'generic/platform=tvOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
+9 -121
View File
@@ -9,33 +9,10 @@
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
name: rpm
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'web/**'
- 'sdk/**'
- 'packaging/rpm/**'
- 'packaging/gamescope/**'
- 'packaging/bazzite/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/rpm.yml'
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
# version-shadow (a release outranking rolling builds in one group) is structurally gone.
@@ -45,15 +22,6 @@ on:
env:
REGISTRY: git.unom.io
OWNER: unom
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
build-publish:
@@ -72,23 +40,13 @@ jobs:
group: fedora-44
fedver: 44
container:
image: 192.168.1.58:5010/${{ matrix.image }}:latest
image: git.unom.io/unom/${{ matrix.image }}:latest
timeout-minutes: 90
env:
CARGO_HOME: /usr/local/cargo
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# rpmbuild + git archive need the checkout trusted; cache the crates download.
# The client link deps are also baked into the fedora-rpm image, but this job runs
# against the image from the PREVIOUS push (docker.yml bootstrap note) — keep it
@@ -96,19 +54,11 @@ jobs:
- name: Prep
run: |
git config --global --add safe.directory "$PWD"
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers.
# The host's Vulkan encode hand-rolls its structs, pyrowave-sys bindgens its own vendored
# copy, and both host and client reach Vulkan through ash, which dlopens the loader. (The
# HDR gamescope leg further down does need them, and pulls them itself via `dnf builddep
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# vulkan-headers: the client's pf-ffvk crate runs bindgen over FFmpeg's
# libavutil/hwcontext_vulkan.h (#include <vulkan/vulkan.h>).
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
# luajit / seatd / SDL2 in. Cheap, and it tracks gamescope's dep list for us.
dnf -y install gamescope || true
# 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 || {
@@ -120,8 +70,8 @@ jobs:
- uses: actions/cache@v4
with:
path: /usr/local/cargo/registry
key: cargo-home-fedora-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-fedora-
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- name: Version + channel
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> <next-minor>-0.ciN.g<sha>
@@ -147,9 +97,7 @@ jobs:
# Recommends both). Both need bun (ensured in Prep).
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
# Signs with packages@unom.io (org secret) and self-verifies before publish. On a v* tag a
# missing key FAILS the build rather than publishing unsigned RPMs into a gpgcheck=1 repo.
- name: Sign RPMs
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md)
env:
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
@@ -176,87 +124,27 @@ jobs:
done
echo "published to $OWNER/rpm/$GROUP"
# The HDR-capable gamescope the sysext carries (packaging/gamescope) — what lets the
# gamescope backend stream 10-bit BT.2020 PQ instead of 8-bit SDR.
#
# CACHED, and that is the whole reason this is affordable: it is a ~10-minute C++ meson build
# of an entirely separate tree that depends on NOTHING in this repo except
# `packaging/gamescope/**` (the patches and the upstream pin, which lives in the build
# script). So the key is that directory's hash and a normal push restores a binary instead of
# building one. Per-Fedora-major, because the binary is soname-coupled to its base exactly
# like the RPM is — an f43 build does not start on f44 (libavutil.so.59 vs .60).
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-f${{ matrix.fedver }}-${{ hashFiles('packaging/gamescope/**') }}
- name: Build the HDR gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort ON PURPOSE. The sysext is the primary Bazzite delivery path and works without
# this binary (the host just stays SDR on the gamescope backend, which is what every
# release before this one did) — so a hiccup building someone else's tree must not cost the
# whole image. It is loud, though: the warning below, and `--gamescope` silently absent
# downstream is impossible because build-sysext.sh verifies the +pfhdr marker itself.
run: |
set -x
# `dnf builddep` resolves Fedora's PACKAGED gamescope, which is older than the master we
# pin, so it can come up short — xorg-x11-server-Xwayland-devel is the one that actually
# bites (wlroots' configure dies on a missing xserver.wrap several minutes in).
dnf -y install dnf-plugins-core meson ninja-build glslc || true
dnf builddep -y gamescope || true
dnf -y install xorg-x11-server-Xwayland-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
# 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: |
# Execute it here rather than only handing it over: build-sysext.sh treats an unusable
# --version as fatal (rightly — it is how the +pfhdr marker is read), and a cached binary
# whose runtime libs are missing from this container must cost the image its HDR, not the
# image itself.
gs=()
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope gs-cache/punktfunk-gamescope)
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
else
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
"${gs[@]}" \
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
# anything, sitting on the same registry as the images they describe.
- name: Publish the sysext feed
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
run: |
case "$GROUP" in
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6; OTHER="f${{ matrix.fedver }}" ;;
*) FEED="f${{ matrix.fedver }}"; KEEP=0; OTHER="f${{ matrix.fedver }}-canary" ;;
*-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"
# Re-seal this Fedora major's OTHER channel too. Stable feeds only publish on a tag, so
# without this a stable box would sit in front of an unsigned (hence refused) feed until
# the next release; canary pushes are frequent, so every live feed gets sealed within a
# day of this landing, and a key rotation propagates without rebuilding any image.
# Best-effort: a channel that has never published yet has no manifest to seal.
bash packaging/bazzite/publish-sysext-feed.sh --seal "$OTHER" \
|| echo "::warning::could not seal the $OTHER feed (no manifest yet?)"
# 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
-76
View File
@@ -1,76 +0,0 @@
# Per-release SBOM (CRA Annex I Part II §1: identify and document the components in the product,
# in a commonly used machine-readable format — we emit CycloneDX JSON).
#
# Tag push → the SBOM is attached to the Gitea release, next to the artifacts it describes.
# Release assets are never pruned (security updates must stay available ≥10 years, CRA Art. 13),
# so the SBOM's retention rides on the release's.
# workflow_dispatch on a non-tag ref → generated and uploaded as a workflow artifact only
# (pipeline validation / an on-demand snapshot); no release is touched.
#
# What goes in: scripts/ci/gen-sbom.sh = syft over the checkout (every lockfile-pinned dep in
# both Rust workspaces + the JS trees + Swift Package.resolved) merged with
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, gamescope).
name: sbom
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
sbom:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 20
steps:
# fetch-depth 0: the dispatch path derives the canary base from the tag history
# (scripts/ci/pf-version.sh), which a shallow clone cannot see.
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
#
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
# the whole step reproducible: bump the tag in both places together.
- name: Install syft
env:
SYFT_VERSION: v1.49.0
run: |
set -euo pipefail
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
- name: Generate SBOM
run: |
git config --global --add safe.directory "$PWD"
case "$GITHUB_REF" in
refs/tags/v*) VERSION="${GITHUB_REF_NAME#v}" ;;
*) eval "$(bash scripts/ci/pf-version.sh)"; VERSION="${PF_BASE}-snapshot" ;;
esac
sh scripts/ci/gen-sbom.sh "$VERSION" "punktfunk-${VERSION}.cdx.json"
echo "SBOM_FILE=punktfunk-${VERSION}.cdx.json" >> "$GITHUB_ENV"
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
upsert_asset "$RID" "$SBOM_FILE"
# v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see release.yml).
- name: Upload artifact (non-tag runs)
if: "!startsWith(github.ref, 'refs/tags/')"
uses: actions/upload-artifact@v3
with:
name: sbom
path: punktfunk-*.cdx.json
-8
View File
@@ -7,14 +7,6 @@
# Auth: REGISTRY_TOKEN — the same repo Actions secret docker.yml uses (a Gitea PAT with
# write:package scope). No new secret needed.
name: sdk-publish
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
-8
View File
@@ -6,14 +6,6 @@
# host packaging). Best-effort: a standalone workflow, so a failure here reds
# nothing else. PNGs land as a 30-day artifact; they are not committed or published.
name: web-screenshots
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
+2 -10
View File
@@ -11,14 +11,6 @@
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
# first GITHUB_ENV line — see windows.yml).
name: windows-drivers
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
@@ -161,9 +153,9 @@ jobs:
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
run: |
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
+14 -308
View File
@@ -22,9 +22,7 @@
#
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
# (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag
# the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert.
# See packaging/windows/pack-host-installer.ps1.
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
#
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
@@ -40,14 +38,6 @@
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
name: windows-host
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
@@ -60,23 +50,6 @@ on:
# builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
- 'crates/pf-encode/**'
- 'crates/libvpl-sys/**'
# …and the rest of the W6 subsystem crates this build compiles. pf-encode was listed while
# the crates it speaks (pf-frame's CapturedFrame/PixelFormat/dxgi vocabulary, pf-gpu's
# adapter selection, pf-zerocopy, pf-host-config) were not, so a change that broke the
# Windows host through one of THEM reached main with no Windows build at all — the same
# Cargo.lock-luck gap the two lines above were added to close.
- 'crates/pf-frame/**'
- 'crates/pf-gpu/**'
- 'crates/pf-zerocopy/**'
- 'crates/pf-host-config/**'
- 'crates/pf-capture/**'
- 'crates/pf-win-display/**'
- 'crates/pf-vdisplay/**'
- 'crates/pf-inject/**'
- 'crates/pf-paths/**'
- 'crates/pf-driver-proto/**'
- 'crates/pf-clipboard/**'
- 'crates/pyrowave-sys/**'
- 'packaging/windows/**'
- 'scripts/windows/**'
- 'web/**'
@@ -91,15 +64,6 @@ env:
REGISTRY: git.unom.io
OWNER: unom
PKG: punktfunk-host-windows
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
package:
@@ -132,24 +96,30 @@ jobs:
shell: pwsh
run: |
# CARGO_TARGET_DIR=C:\t dodges the MAX_PATH wall in the CMake-from-source crates (aws-lc,
# opus) the host pulls; via GITHUB_ENV (pwsh Out-File utf8 = no BOM, unlike Windows
# PowerShell 5.1 — keeps the first line clean).
# opus) the host pulls; CARGO_WORKSPACE_DIR mirrors the client workflows. Both via GITHUB_ENV
# (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
"CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# audiopus_sys' vendored opus declares cmake_minimum_required < 3.5, which CMake 4.x
# refuses outright. Green runs today only survive on the cached configure output — a
# target-dir purge (the runner's disk-cleanup task) would fail the fresh configure, as
# observed on a clean build on this very runner (2026-07-17). No-op for compliant
# projects (libvpl-sys pins 3.13+).
"CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by
# scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10
# it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
# and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend
# FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
# by scripts/ci/provision-windows-punktfunk-extras.ps1). The host's AMD/Intel AMF/QSV encode backend
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
if (-not $env:FFMPEG_DIR) {
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
# VBCABLE_DIR: the pinned official VB-CABLE package (provisioned by
# provision-windows-punktfunk-extras.ps1) -> pack-host-installer.ps1 bundles the
# streaming virtual microphone. Same daemon-env-or-fallback pattern as FFMPEG_DIR
# (the daemon env only refreshes on a runner-task restart).
if (-not $env:VBCABLE_DIR) {
"VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
$env:GITHUB_REF_NAME -replace '^v', ''
@@ -184,81 +154,10 @@ jobs:
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
# pf-vkhdr-layer's clippy below runs --release.
#
# pf-encode, pf-capture and pf-vdisplay are linted SEPARATELY with --all-targets so their
# Windows `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
# (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored
# vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
# tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
# cannot cover them: `-p punktfunk-host` only builds those crates as dependencies, so their
# test targets are never compiled anywhere, and that blind spot is what let the Linux twin's
# tests rot to the wrong arity unnoticed. pf-capture has no cargo features, so it needs no
# feature juggling and pulls in no extra dep tree.
# NOTE: for the HOST and pf-encode, clippy (a check, no link step) is deliberately the
# vehicle — `cargo test` with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk
# link-imports NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve
# only against the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can
# and does run the tests there.) Running them here would need an `--features amf-qsv,qsv`
# build without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 —
# not worth it while ci.yml executes the same tests.
#
# That reasoning does NOT extend to pf-capture: it has no encoder dependency at all
# (`cargo tree -p pf-capture` lists no nvidia/ffmpeg/libvpl/pyrowave), so its test binary
# links against nothing this runner lacks, and it reuses the release artifacts the steps
# above already built. Its Windows `#[test]`s — StallWatch, the f16 conversions, the cursor
# truth table, the IDD generation masking — are Windows-only code that NO other job can
# execute, so linting them was leaving real coverage on the table. See the run step below.
run: |
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
cargo clippy --release -p pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" }
cargo clippy --release -p pf-vdisplay --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-vdisplay clippy" }
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Test (pf-capture, Windows)
shell: pwsh
# The only Rust tests that RUN on Windows CI. pf-capture's `#[cfg(target_os = "windows")]`
# test modules cover code no Linux job compiles, let alone executes: 19 declared, of which
# 18 execute here — the IDD-push StallWatch state machine and ring-generation masking
# (idd_push.rs), the cursor shape→wire truth table (idd_push/cursor_poll.rs), and
# `f32_to_f16` including the rounding-carry / saturation edges the HDR P010 path depends on
# (dxgi/selftest.rs). All 18 are pure — no Win32, no device, no desktop. The 19th,
# `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel
# adapter; it stays a manual `-- --ignored` run on the validation boxes. Until this step
# the whole set was type-checked by the clippy line above and nothing more.
#
# --release for the same reason as the clippy step: it reuses C:\t\release instead of
# spawning a second debug dep tree (the C1069 disk-exhaustion trigger). If this step ever
# starts tripping C1069 anyway, record THAT here rather than quietly dropping the step.
#
# The link question this step turns on was settled empirically before it was added: the same
# command was run on a Windows dev box against a workspace checkout and linked + executed
# cleanly, building in ~51 s off an existing release target dir.
run: |
cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" }
- name: Test (pf-vdisplay, Windows)
shell: pwsh
# pf-vdisplay's Windows half is ~3,400 lines (manager.rs, pf_vdisplay.rs, ddc.rs, the three
# manager/ submodules) that NO other job compiles — the host lint above builds the crate as
# a dependency, so its test targets were reaching no compiler anywhere. Worse, the only two
# Windows `#[test]`s were `if env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { return; }`
# early-returns, so an unrun hardware test reported `ok`. They are `#[ignore]`d now and this
# step reports them as `ignored`, which is the truth.
#
# The link objection recorded for the host and pf-encode above does NOT apply here, for the
# same reason it does not apply to pf-capture: `pf-encode` is `default = []`, and nothing in
# `-p pf-vdisplay`'s graph turns on `nvenc`/`amf-qsv`/`qsv`, so no nvidia/ffmpeg/libvpl
# import libs are ever asked for. Settled empirically before this step was added, to the
# same standard as pf-capture's: the exact two commands were run on this runner against a
# checkout at C:\temp\pf-vd-check — clippy clean, then 46 passed / 2 ignored in 0.19 s.
#
# --release to reuse C:\t\release rather than spawning a debug tree (the C1069 trigger).
# Note this DOES build a second, featureless pf-encode; it is small precisely because none
# of the encoder features are on.
run: |
cargo test --release -p pf-vdisplay; if ($LASTEXITCODE) { throw "pf-vdisplay tests" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
@@ -271,18 +170,6 @@ jobs:
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
Pop-Location
# The console output is fully self-contained (Nitro noExternals) and most pushes
# don't touch web/ or sdk/ — restore it from the central cache and skip the ~2.5 min
# bun build+smoke entirely on a hit. First workflow on this runner to use the
# actions cache at all (the runner's config.yaml needed cache.external_server —
# see unom/infra runners/ci-core/README.md).
- name: Cache web console output
id: webconsole
uses: actions/cache@v4
with:
path: web/.output
key: web-console-win-${{ hashFiles('web/**', 'sdk/**') }}
- name: Fetch portable bun runtime (build tool + bundled to run the console)
shell: pwsh
run: |
@@ -302,7 +189,6 @@ jobs:
& $bun --version
- name: Build + smoke-boot web console (bun)
if: steps.webconsole.outputs.cache-hit != 'true'
shell: pwsh
env:
# PAT with read access to the unom org packages — the @unom npm registry needs auth to BUILD.
@@ -320,15 +206,7 @@ jobs:
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
}
Push-Location web
# `--ignore-scripts` like every other web install in CI (ci.yml, web-screenshots.yml,
# sdk/plugin-kit-publish, and the SDK install further down this same file). This step was
# the one site that ran lifecycle scripts, and web's `postinstall` is `bun2nix -o bun.nix`
# — a NIX codegen step that shells out to `bun` on PATH. CI runs a fetched PORTABLE bun by
# absolute path (`$env:BUN_EXE`), so PATH has none, and bun2nix aborted the install:
# error: bun is not installed in %PATH% ... postinstall script exited with 255
# Nothing here needs those scripts — `build` re-runs its own `prebuild` codegen — and
# bun.nix is a Nix artifact this job neither consumes nor commits.
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
@@ -343,21 +221,6 @@ jobs:
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
Write-Output "web console smoke (bun): /login -> $code"
if ($code -ne 200) { throw "web console failed to boot under bun" }
# WEB_OUTPUT_DIR has to be exported whether or not the step above ran. It used to be that step's
# last line, so a CACHE HIT skipped it and left the variable unset — and pack-host-installer.ps1
# treats an unset WEB_OUTPUT_DIR as "don't bundle the console", silently ("installer built
# WITHOUT the web console"). That shipped in 0.22.1 and 0.22.2: no {app}\web, so no web-run.cmd,
# so `web setup` bails, so no PunktfunkWeb task and no console at all. It also removed the only
# thing that stopped bun before the copy (StopBunRuntimes was #ifdef WithWeb), while bun.exe kept
# shipping under WithScripting — which is the "DeleteFile failed; code 5" modal on bun.exe.
# The throw is the point: never silently ship a console-less installer again.
- name: Export the console output dir (cache hit or fresh build)
shell: pwsh
run: |
if (-not (Test-Path 'web\.output\server\index.mjs')) {
throw "web\.output is missing - neither the cache restore nor the build produced it, and the installer must not ship without the console"
}
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build plugin/script runner bundle (bun)
@@ -378,51 +241,11 @@ jobs:
}
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# NOT cached, and it must stay that way: the UMDF drivers build IN-TREE inside the pack
# step (a relocated CARGO_TARGET_DIR breaks wdk-build's manifest walk), and act rotates
# the job workspace path (~/.cache/act/<hash>/hostexecutor) between runs. A cargo target
# dir restored under a DIFFERENT absolute path brings state that points at the old one:
# measured 2026-07-30, `pf-umdf-util` died with 14 × "unable to create file lock (os
# error 3)" and took the whole job with it. ~1 min of rebuild is the correct price; the
# same rotation is why the other Windows jobs use a fixed C:\t instead of a cached
# workspace-relative target.
# Every payload this job is SUPPOSED to bundle, asserted before packing. The packer treats each
# one as optional — correct for a local debug pack, and the reason 0.22.1/0.22.2 shipped with no
# web console: an unset WEB_OUTPUT_DIR omitted it behind a single Write-Host. CI knows it bundles
# all of these, so here a missing input is a build failure rather than a quietly smaller
# installer. (pack-host-installer.ps1 already does this for VB-CABLE, for the same reason.)
- name: Verify every installer payload is present
shell: pwsh
run: |
$need = @(
@{ n = 'web console (WEB_OUTPUT_DIR)'; p = $env:WEB_OUTPUT_DIR; f = 'server\index.mjs' }
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
)
$missing = @()
foreach ($x in $need) {
if (-not $x.p) { $missing += "$($x.n): env var not set"; continue }
$full = if ($x.f) { Join-Path $x.p $x.f } else { $x.p }
if (-not (Test-Path $full)) { $missing += "$($x.n): missing $full" }
else { Write-Output "payload OK - $($x.n) -> $full" }
}
if ($missing.Count) {
$missing | ForEach-Object { Write-Output "MISSING PAYLOAD - $_" }
throw "$($missing.Count) installer payload(s) missing - refusing to ship an incomplete installer"
}
- name: Pack + sign installer
shell: pwsh
env:
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
# The DRIVER cert is separate from the host/MSIX one and reaches the two driver build
# scripts through the environment (pack-host-installer.ps1 invokes them, they read
# $env:DRIVER_CERT_PFX_B64 themselves). Without it they sign with a per-build throwaway,
# which the installer then trusts as a machine root — see packaging/windows/README.md.
DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }}
DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }}
run: |
& packaging/windows/pack-host-installer.ps1 `
-Version $env:HOST_VERSION -TargetDir C:\t\release -OutDir C:\t\out
@@ -471,120 +294,3 @@ jobs:
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
# winget manifests for the release just attached above. Runs AFTER the attach step so the
# InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a
# manifest published ahead of its artifact is a hard 404 for every client that picks it up.
# Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/`
# alias has nothing it could point at.
- name: Emit + attach winget manifests (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
& scripts/ci/winget-manifest.ps1 `
-Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) {
Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName
}
# Republish the winget REST source on unom-1 once the release above carries its manifests.
#
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
# reads the manifests from the release, so it must not run before they are attached.
# Publish the SIGNED canary update manifest after the canary installer lands (planning:
# host-update-from-web-console.md §3.3 — canary rides this workflow because the installer is
# the only artifact the manifest references by URL; other canary channels may trail by minutes,
# which the per-PM apply path tolerates). A Linux job: the signer is bash+openssl. Skips (with
# a warning) when UPDATE_MANIFEST_KEY is absent — a canary build must not fail over it.
canary-manifest:
needs: package
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Publish the canary update manifest
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
run: |
set -euo pipefail
# Same derivation the package job used: canary = <next-minor base>'s major.minor + run#.
eval "$(bash scripts/ci/pf-version.sh)"
VER="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"
URL="https://${REGISTRY}/api/packages/${OWNER}/generic/${PKG}/${VER}/punktfunk-host-setup-${VER}.exe"
curl -fsSL "$URL" -o /tmp/installer.exe
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
CHANNEL=canary VERSION="$VER" CI_RUN="${GITHUB_RUN_NUMBER}" \
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
NOTES_URL="https://git.unom.io/unom/punktfunk/releases" \
bash scripts/ci/publish-update-manifest.sh
winget-source:
needs: package
if: startsWith(gitea.ref, 'refs/tags/v')
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
# so the result cannot drift and re-running any tag reproduces it byte for byte.
- name: Build + test the source catalogue
working-directory: packaging/winget/server
run: |
set -euo pipefail
npm install --no-audit --no-fund
node build-data.mjs --out data/data.json
# A wrong response SHAPE does not fail loudly — winget just reports "no package found".
# Gate on the suite before anything reaches the box.
node test.mjs
# Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the
# flatpak repo's content and config deploy on separate paths.
- name: Ship the catalogue to unom-1
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "packaging/winget/server/data/data.json"
target: "~/unom-winget/data"
strip_components: 4
overwrite: true
# No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the
# one actually being served, and fails the release if it is not.
- name: Verify the served catalogue
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
curl -fsS http://127.0.0.1:3240/healthz
echo
curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' \
| grep -q "${GITHUB_REF_NAME#v}" \
|| { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
echo "winget source serving ${GITHUB_REF_NAME#v}"
# `env:` below populates the RUNNER's environment; this action runs `script` on the
# REMOTE host, which inherits nothing from it. `envs:` is the action's OWN input —
# it must live under `with:` (matching docker.yml/deploy-services.yml's REGISTRY_TOKEN
# forwarding) — naming the variables to forward into the remote shell. A prior fix put
# it as a step-level sibling of `with:`/`env:` instead: that key is not part of the
# step schema, so appleboy/ssh-action never received it as an input and the step kept
# failing ("GITHUB_REF_NAME: unbound variable") on every tag after 24d2f97e too.
envs: GITHUB_REF_NAME
env:
GITHUB_REF_NAME: ${{ gitea.ref_name }}
+23 -46
View File
@@ -1,17 +1,13 @@
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, FFmpeg
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
#
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the
# BOM/MAX_PATH runner gotchas.
#
# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the
# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer
# (windows-host.yml) is unchanged.
# toolset has the ARM64 cross compiler; the matrix points FFMPEG_DIR at the ARM64 FFmpeg tree). See
# windows.yml for the cross-build rationale + the BOM/MAX_PATH runner gotchas.
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
@@ -25,23 +21,12 @@
# Published to the generic registry + the `canary/` alias.
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
#
# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
# .cer is published next to the .msix (users import it to Trusted People before install).
#
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
# Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets
# are set (a real or shared code-signing .pfx whose subject DN == Publisher), the package is signed
# with them. Otherwise an ephemeral self-signed cert is generated and its public .cer is published
# next to the .msix (users import it to Trusted People before install). Drop in a real cert later
# with no workflow change — just add the secrets (+ pass -Publisher if its subject differs).
name: windows-msix
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
@@ -53,9 +38,7 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'crates/pf-ffvk/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-msix.yml'
@@ -66,15 +49,6 @@ env:
REGISTRY: git.unom.io
OWNER: unom
PKG: punktfunk-client-windows
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
package:
@@ -86,10 +60,12 @@ jobs:
include:
- arch: x64
target: x86_64-pc-windows-msvc
ffmpeg: C:\Users\Public\ffmpeg
td: C:\t
session_flags: ''
- arch: arm64
target: aarch64-pc-windows-msvc
ffmpeg: C:\Users\Public\ffmpeg-arm64
td: C:\t-a64
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
@@ -98,17 +74,22 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Configure + version
shell: pwsh
run: |
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
# crates (see windows.yml). No FFMPEG_DIR: nothing in this package links libav* (M10),
# and pack-msix.ps1 no longer copies runtime DLLs from one.
# windows-reactor's build.rs unwraps CARGO_WORKSPACE_DIR; CARGO_TARGET_DIR (per-arch, short)
# dodges the MAX_PATH wall in the CMake-from-source crates (see windows.yml). FFMPEG_DIR
# selects the arch's import libs + is read by pack-msix.ps1 for the runtime DLLs. All via
# GITHUB_ENV.
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }}
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
@@ -123,15 +104,11 @@ jobs:
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
# All three client binaries — the shell spawns punktfunk-session.exe (a package
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
- name: Build (release)
shell: pwsh
# punktfunk-cli builds the `punktfunk.exe` the manifest aliases and pack-msix.ps1
# requires (bf981027 added the requirement without the build — same gap 90c84ef4
# closed for deb).
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
- name: Pack + sign MSIX
shell: pwsh
+37 -61
View File
@@ -1,30 +1,29 @@
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup,
# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast
# no-op once already present, so any runner with that label works with no manual dispatch step
# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell
# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - FFmpeg,
# Vulkan-Headers, WDK, Inno Setup, the ARM64 rustup target - self-provision via the "Ensure
# Windows toolchain" step below, a fast no-op once already present, so any runner with that label
# works with no manual dispatch step first). Build + clippy + fmt + test BOTH client binaries:
# the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
# (pf-presenter/pf-client-core/pf-console-ui/pf-ffvk — every stream runs in it, spawned by the
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
# flip when skia-binaries adds the target.
#
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
# `amf-qsv` leg link-imports them.
#
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64.
# arch-specific external dep is FFmpeg's import libs: the runner keeps an x64 tree at
# C:\Users\Public\ffmpeg and an ARM64 tree at C:\Users\Public\ffmpeg-arm64 (both FFmpeg 7.x /
# avcodec-61); the matrix points FFMPEG_DIR at the right one. aarch64 can't *run* on the x64 host,
# so fmt + test run only for x64.
#
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
# The MSVC/WinUI/FFmpeg toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, the x64 FFmpeg,
# CARGO_HOME, CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
# / per-arch vars are set in a step:
# - CARGO_WORKSPACE_DIR windows-reactor's build.rs unwraps it + stages the Win App SDK
# NuGets/winmd under it (from GITHUB_WORKSPACE).
# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under
# C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\,
# so the default target\ path blows past Windows' MAX_PATH (260) inside the
@@ -32,20 +31,13 @@
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
# root keeps every nested path well under the limit (per-arch so the two
# matrix legs don't share a target dir).
# - FFMPEG_DIR per-arch FFmpeg import libs (x64 vs arm64 tree).
#
# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that
# var silently never gets set). pwsh writes no BOM.
# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (the
# CARGO_WORKSPACE_DIR var silently never gets set -> reactor build.rs panics). pwsh writes no BOM.
# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh.
name: windows
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
@@ -57,9 +49,7 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'crates/pf-ffvk/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
@@ -71,28 +61,12 @@ on:
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'crates/pf-ffvk/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
@@ -116,44 +90,46 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Configure + toolchain versions
shell: pwsh
run: |
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav*
# since M10 (see this file's header), so nothing here needs import libs or runtime DLLs.
# The HOST still does — windows-host.yml sets them for its amf-qsv leg.
# Per-arch FFmpeg import libs (provision-windows-punktfunk-extras.ps1 fetches both).
$ff = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\Users\Public\ffmpeg-arm64' } else { 'C:\Users\Public\ffmpeg' }
"FFMPEG_DIR=$ff" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# pf-ffvk's bindgen needs Vulkan headers (arch-independent; provisioned alongside FFmpeg).
"PF_FFVK_VULKAN_INCLUDE=C:\Users\Public\vulkan-headers\include" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# $ff\bin on PATH too (not just FFMPEG_DIR, which only satisfies the linker): the test
# binary needs the actual DLLs to load at runtime. Set here rather than relying on the
# daemon's own env (project-env.ps1) - on a freshly cloned/registered runner the daemon
# starts before this job's "Ensure Windows toolchain" step ever writes that file, so its
# PATH doesn't include this yet on a first run (confirmed live: STATUS_DLL_NOT_FOUND).
"$ff\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
rustup target add ${{ matrix.target }}
rustc --version
cargo --version
Write-Output "target ${{ matrix.target }} target-dir $td"
Write-Output "target ${{ matrix.target }} target-dir $td ffmpeg $ff"
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
# for the shell, which has no features).
# punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so
# a CLI that only the release workflow compiles is a release-day surprise. Its tests
# RUN the binary (help contract), as the session's contract_smoke runs the session —
# the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber).
- name: Build
shell: pwsh
run: |
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }}
cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }}
- name: Clippy (-D warnings)
shell: pwsh
run: |
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
# dependency but only LINTS the packages it is given, so a decode crate that starts the
# run but is missing from this list would be gated by nothing.
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
$sf = @()
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
@@ -161,9 +137,9 @@ jobs:
- name: Rustfmt check
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
- name: Test
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
-717
View File
@@ -1,717 +0,0 @@
# Changelog
Protocol, ABI, driver and embedder detail, one section per stable release, newest first.
This is the **technical** half of a release. The other half — what changed for people who *use*
Punktfunk — is `docs/releases/vX.Y.Z.md`, and it deliberately contains no internal names. The two
were one document through v0.24.0; they split at v0.25.0 because the engineering section had grown
long enough to bury the user-facing half it was appended to. See `docs/releases/README.md`.
If you embed `punktfunk-core`, package Punktfunk, or write a plugin, this file is for you. Start
with the version table of the release you are moving to, then read **Breaking changes**.
---
## v0.25.0
407 commits since v0.24.0.
### Versions
| | v0.24.0 | v0.25.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged — every addition below is optional or capability-gated |
| C ABI | 14 | **17** | three steps; see below |
| Workspace crate dirs | 22 | **26** | `pf-bitstream` (+ vendored `cros-codecs`), `pf-vkdecode`, `pf-dxvadec`, `pf-vaadec` added; `pf-ffvk` removed |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.23.0 | **0.24.0** | tracks API edits, lags one release by convention |
`crates/pf-driver-proto` is byte-for-byte identical to v0.24.0 — if you ship the virtual-display
driver or the gamepad channel, nothing in this release touches you.
**Why the wire did not move.** It grew a lot and still did not break: an optional trailing
`max_shard_payload: u16` on `Hello` (absent/0 = legacy, doubling as the renegotiation capability
flag and the jumbo receive ceiling); two control messages `ShardPayloadChanged` (`0x08`) and
`ShardPayloadAck` (`0x09`); a redundant desktop-audio datagram tag `0xD2` beside the plain `0xC9`; a
controller-audio plane at `0xD1`; a new `0xCD` kind `0x06`; arrival flag bits 8/9; and
`MAX_DATAGRAM_BYTES` 2048 → 9216. Old peers never send or read any of it. Bump `WIRE_VERSION` only
when the handshake or planes change *incompatibly* — riding a C-ABI bump onto the wire once locked
every new client out of every deployed host (`ABI mismatch: client 3 host 2`, observed live).
### C ABI 14 → 17
- **v15 — the rumble policy engine's C surface.** `punktfunk_connection_next_rumble_cmd`,
`punktfunk_connection_set_rumble_quirks`, `PUNKTFUNK_RUMBLE_QUIRK_*`. These symbols are **not
new**: they landed while the constant still read 7 and no bump was made, so every core since has
exported them while advertising a version that never promised them. A shipped binary says what it
says, so this cannot be corrected retroactively — **v15 is the floor that guarantees them.** At or
above 15 the surface is present; below it, probe for the symbol. No code changed with this bump.
- **v16 — the controller-audio client surface.** `punktfunk_connection_next_pad_audio` (the `0xD1`
per-gamepad DualSense haptics/speaker plane), `punktfunk_connection_set_pad_audio_caps`, and the
`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors.
- **v17 — session end reason.** `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*`
vocabulary: after a session ends, ask *why* — this client closed it, the host's launched game
exited (its close carried `APP_EXITED_CLOSE_CODE`, which the host had been sending for a long time
with nothing consuming it), the host ended it cleanly, the host reported a failure, or the
connection was lost. Purely a read of state the core already had: **no new call is required of an
embedder**, a client that never calls it is unchanged, and the host sends identical bytes either
way.
### ⚠ Breaking changes
**1. 149 unprefixed macros are now `PUNKTFUNK_`-prefixed** (139 `#define`s renamed in the checked-in
header). Names as generic as `MAX_PADS`, `TAG_LEN`, `ABI_VERSION`, `WIRE_VERSION`, `INPUT_MAGIC` and
the whole `BTN_*` / `AXIS_*` family were landing in the namespace of every program that included the
header.
*What to do:* add the prefix. Values are identical; the change is mechanical.
*It cannot break silently.* The old spellings cease to exist, so this is always an
undeclared-identifier error, never a wrong value — which is precisely the failure being removed. A
colliding `#define` does **not** fail to compile: the preprocessor silently takes the last
definition, so an embedder whose own header defined `MAX_PADS` previously got a wrong value at
runtime. Associated constants are untouched; the generator already qualifies those by type name.
**2. Linux hosts: the virtual Steam Deck controller moved to its own `punktfunk` group.** The
capability rode on `input`, which every gamepad guide tells users to join — but it can emulate
arbitrary USB hardware. Operators must `usermod -aG punktfunk "$USER"` and re-login or the pad stops
attaching. Ordinary virtual gamepads are unaffected.
**3. Plugins may no longer set `launch.command` or the pre-launch command.** Both run through a
shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that
populated them need updating — use the `launcher_ui` / `xbox` launch kinds instead.
**4. Plugin UIs moved to their own origin** on a second listener (default `PORT + 1`,
`PUNKTFUNK_UI_PLUGIN_PORT`). Reverse proxies and firewalls must forward that port; a self-signed
console needs it trusted separately.
### Capability bits
Four added, all in the handshake's client/host capability bytes:
| Bit | Constant | Meaning |
|---|---|---|
| client `0x04` | `CLIENT_CAP_AUDIO_RED` | can decode the redundant desktop-audio plane |
| host `0x20` | `HOST_CAP_AUDIO_RED` | is sending it |
| client `0x08` | `CLIENT_CAP_PAD_AUDIO` | can render controller audio |
| host `0x40` | `HOST_CAP_PAD_AUDIO` | is sending it |
**Pressure worth watching:** `client_caps` has four bits free; **`host_caps` is down to its last
one (`0x80`)**; `video_caps` has been full since 0.23.0 (`VIDEO_CAP_MULTI_SLICE = 0x80`). The next
video capability needs a second byte *and* an ABI bump — plan for it rather than discovering it.
### Wire planes
- **Controller audio, `0xD1`** — `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]`,
one Opus frame per datagram behind a 15-byte header. `PAD_AUDIO_KIND_HAPTICS = 0` is the pad's
BACK channel pair (the voice coils) at 5 ms frames; `PAD_AUDIO_KIND_SPEAKER = 1` is the FRONT pair
at 10 ms. Best-effort like every audio plane: loss is a sequence gap concealed by the gap tracker,
silence is a frozen sequence under the mic-mute discipline, host gating at 60 dBFS with a 250 ms
hangover. `0xD2` (redundant desktop audio) deliberately skipped `0xD1` to reserve it for this.
- **`HidOutput::AudioCtl`** — `0xCD` kind `0x06`, carrying the DualSense output report's
volume/routing bytes, change-only and value-deduped. Older clients drop it as an unknown kind.
- **Arrival flags** — bits 8 (haptics) and 9 (speaker), sent only toward a `HOST_CAP_PAD_AUDIO` host.
- **Adaptive-trigger effects are length-bounded** on encode and decode against one shared constant;
the header emits `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]` in place of a literal `11` (same value,
so the struct layout is byte-identical). A zero-length effect body is now rejected rather than
decoding as an empty — that is, a *release* — effect.
- Out-of-range pad indices are dropped before **either** rumble consumer sees them. The reorder gate
bounds-checked and the legacy queue did not, so an embedder draining it could be handed an index it
would use to subscript its own array. The client also clamps the host's rumble lease receive-side
at 5 s, where the ceiling had been sender-side only.
### Host environment variables
| Variable | Default | Notes |
|---|---|---|
| `PUNKTFUNK_AUDIO_QUALITY` | `high` | `low`/`standard`/`high`; `high` = stereo 256 kbps. `standard` reproduces the pre-0.25 encoder exactly for an A/B. A typo warns once rather than silently downgrading. |
| `PUNKTFUNK_AUDIO_REDUNDANCY` | unset = automatic | on when the client supports it and the budget allows |
| `PUNKTFUNK_AUDIO_OUTPUT_MODE` | `client_only` | `client_only`/`host_and_client`/`follow_default`. **Windows host only.** |
| `PUNKTFUNK_PAD_AUDIO` | on | `0` disables controller audio host-wide |
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1` | max 4; multi-pad needs an operator to raise it |
| `PUNKTFUNK_PAD_AUDIO_STAMPS` | unset | debug bisect hook |
| `PUNKTFUNK_WIRE_MTU` | unset | pins on-wire IP MTU for all sessions; above 1500 also enables jumbo |
| `PUNKTFUNK_JUMBO` | unset (off) | fixed 9000-MTU profile |
| `PUNKTFUNK_UI_PLUGIN_PORT` | `PORT + 1` | the plugin-UI origin |
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | platform default | art-serving roots; POSIX now defaults to `$HOME` |
| `PUNKTFUNK_DECODER` | client | **values changed**: `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software`. Legacy `vulkan`/`vaapi`/`d3d11va` still accepted and migrated. Now **trimmed** — a trailing space used to fall through to `auto` silently. |
| `PUNKTFUNK_VAAPI_DEVICE` | client | **new** — pin the VAAPI render node |
| `PUNKTFUNK_DUMP_VIDEO` / `PUNKTFUNK_AU_DUMP` | client | **new** — capture exact decoder input / the AU as it arrived from the host |
| `PUNKTFUNK_AU_FAULT=drop\|truncate\|flip[:period]` | client | **new** — deliberate decoder-input corruption for recovery testing; native rungs only |
| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE=1` | host | **new** — opt-in live split-encode arbitration (Linux-wired) |
| `PUNKTFUNK_NO_AUDIO_MINT` | host (Win) | **new** — opt out of minted endpoints; restores the name ladder |
| `PUNKTFUNK_GPU_PRIORITY` | host (Win) | **removed** — superseded by `PUNKTFUNK_GPU_PRIORITY_CLASS`, a strict superset |
| `PUNKTFUNK_FFMPEG_LOG` | client | **removed** with the av_log machinery |
Legacy `PUNKTFUNK_HOST_AUDIO=1` and `PUNKTFUNK_KEEP_DEFAULT=1` still work, mapping to
`host_and_client` and `follow_default`; `follow_default` wins if both are set. New devtest command:
`punktfunk-host pad-endpoint ensure|remove|status`.
### Security
- **Origin isolation.** A second listener serves `/plugin-ui/**` and nothing else; the console origin
refuses those paths and the plugin origin refuses everything else, `/api/**` above all. Different
origin (scheme+host+port) so same-origin policy *is* the boundary; same site so the `SameSite=Lax`
session cookie still flows. Bind failure disables plugin UIs rather than falling back.
`x-pf-listener` is stripped inbound and set by the entry; active ports republish as
`*_PORT_ACTIVE`; the plugin origin's CSP names the console as its only `frame-ancestors`; the proxy
allowlist drops the plugin's `Clear-Site-Data`, `Access-Control-Allow-Origin` and `Set-Cookie`.
⚠ The kit's `postMessage(..., "*")` is **load-bearing** — narrowing it to `location.origin` would
target the plugin's own origin and drop every message.
- **Authorization is an allowlist with a build-time gate.** `plugin_may_access` is a list of
permitted `(method, path)` pairs with `{}` segment matching, enforced by a test that walks the live
route table and **fails the build on any unclassified route** — the block-list it replaces let new
endpoints through silently. Field authority is tracked separately from route reachability:
requests carry the lane that authorized them, and `prep` / `launch.kind = "command"` are
operator-token only.
- **Art serving** gained an extension whitelist plus magic-byte sniffing, canonicalize-or-refuse, UNC
refusal, config-dir exclusion and root checking, with `file://` percent-decoded *before*
canonicalization so `%2e%2e` cannot hide. Validation also runs at write time, so an unservable path
can no longer be persisted.
### Native decode — FFmpeg is gone from the client
268 files, +129k / 25k. `cargo tree -p punktfunk-client-session` finds zero `ffmpeg`. **The host
keeps `libavcodec` unconditionally** (pf-encode); no host workflow, packaging script or licence file
was touched.
| Platform | v0.24.0 | v0.25.0 |
|---|---|---|
| Linux desktop | ffmpeg-next: Vulkan hwcontext (`pf-ffvk`) → VAAPI → libavcodec sw | `pf-vkdecode` (ash, presenter's own `VkDevice`, zero-copy) → `pf-vaadec` (dlopen'd libva, DRM-PRIME dmabuf) → `openh264` + `rav1d` |
| Windows desktop | ffmpeg-next Vulkan → libavcodec D3D11VA half | `pf-vkdecode``pf-dxvadec` (plans into `ID3D11VideoDecoder`) → `openh264` + `rav1d` |
| Android | MediaCodec (never had FFmpeg) | unchanged |
| Apple | VideoToolbox (never had FFmpeg) | unchanged |
**Workspace members:** added `pf-bitstream` (+ vendored `cros-codecs`, compiler-enforced
`unsafe`-free), `pf-vkdecode`, `pf-dxvadec`, `pf-vaadec`; removed `pf-ffvk`. **Deleted:**
`video_vulkan.rs`, `video_vaapi.rs`, `video_libav.rs`, the libavcodec half of `video_d3d11.rs`, the
`av_log` machinery, `ffmpeg::codec::Id` as decoder vocabulary, `DecodedImage::VkFrame`/`::Dmabuf`,
the `ffmpeg-fallback` feature, and swscale — and with it the BT.601 default its correction code
existed to undo.
**Software rung:** `openh264 = "0.9"` (BSD-2) and `rav1d = { version = "1", default-features =
false, features = ["bitdepth_8"] }` (BSD-2). `dav1d-sys` was rejected because it is `system-deps`-
only and would add a system library plus a `.pc` to every client package. `default-features = false`
drops `asm` — rav1d's `build.rs` *panics* without nasm, unlike openh264-sys2, which degrades quietly.
**`bitdepth_8` only** ⇒ software AV1 refuses 10-bit by contract, read from the sequence header before
any byte reaches the decoder.
**⚠ HEVC has no CPU floor.** An HEVC session that exhausts its hardware rungs tears down and re-dials
advertising HEVC-less caps, and the host picks H.264 (`last_rung_verdict` / `NoSoftwareRung`). This is
a first-class path, not a failure.
**Rung × codec × hardware evidence** (`native_evidence`) — the admission filter is driven by this, so
an unproven rung yields only to one that is both verified for the codec and usable on the device:
| Rung | Codecs | Evidence |
|---|---|---|
| `native-vulkan` | H.264, H.265 Main/Main10/4:4:4 | **yes** — bit-exact vs libavcodec, 250/250 AUs on 3 drivers + 92-min soak |
| | AV1 | **yes** — 250/250 bit-identical on one vendor, no soak |
| `native-d3d11va` | H.264, H.265 | **yes** — frame-hash parity on RTX 4090 + AMD iGPU, 30-min soak |
| | AV1 | **not proven** — decoded 4K60 once, no parity, no soak ⇒ excluded from the filter |
| `native-vaapi` | H.264, H.265, AV1 | **NO — has never decoded a frame anywhere**; no VAAPI hardware was reachable |
| `software` | H.264 (openh264), AV1 (rav1d) | **not proven**; openh264 has never run on glass. No HEVC at all. |
Vendor order (unchanged): Linux NVIDIA/AMD `vk → vaapi → sw`; Linux Intel/unknown
`vaapi → vk → sw`; Windows NVIDIA/AMD `vk → d3d11va → sw`; Windows Intel/unknown
`d3d11va → vk → sw`.
**AV1 advertisement** now answers from device facts (`av1_hardware_decodable`: Vulkan `DECODE_AV1`
queue op, or the Windows D3D11 import path) rather than `ffmpeg::decoder::find(AV1)`, which was true
on any build linking libdav1d. **Settings migration:** stored `vulkan`/`vaapi`/`d3d11va` migrate to
`native-*` at decoder construction *and* at each dialog's lookup — the second is load-bearing, since
an unmatched value renders as "Automatic" and a save would silently rewrite the preference.
### The three decode data-loss bugs
**AV1 sub-frame truncation — shipped in v0.24.0, host-side.** NVENC sub-frame readback has two halves
armed by *different* conditions: `build_init_params` arms the writer from `subframe_on` alone, while
the chunked reader additionally requires `slices >= 2` — and `resolve_slices` returns `1` for AV1
unconditionally, because AV1 partitions via tiles, not slices. So an AV1 session told the driver to
publish tile-by-tile and then took only the first tile. Measured at 4K60: every AU carried a header
declaring two tile rows plus a single Tile Group OBU with `tg_start = tg_end = 0`; libdav1d rejected
**835/836** AUs. NVIDIA's *hardware* decoder accepts it (so Vulkan Video looked healthy at 60 fps);
its DXVA path did not. 1080p is one tile and unaffected; 4K splits into two tile rows and loses half
the picture. Fixed by disarming sub-frame for AV1 while leaving `split_mode` untouched — AV1 keeps
every engine. Arming the reader instead is *not* a drop-in: the reader cuts at
`bitstreamSizeInBytes` on the reasoning that slices are contiguous Annex-B, which AV1 OBUs are not.
Post-fix 654/654 clean. The test that had pinned the old behaviour as *correct* is replaced by one
pinning the disarm, plus one comparing the reader's gate against the writer's — the comparison
nothing made.
**HEVC DPB from the level ceiling — new in this release, client-side.** `dpb_limit` computed
`max(A-2_level_ceiling, sps_max_dec_pic_buffering_minus1 + 1)`. HEVC equation A-2 is a **ceiling on
what an SPS may legally signal**, not a statement of need, and it branches on picture size against
the *level's* `MaxLumaPs`. The host is blameless: NVENC autoselects L5.1 because the bitrate exceeds
L5.0's ceiling, and signals six pictures at every resolution. At 720p and 1080p the A-2 branch yields
16 frames / **17 slots** — one more than NVIDIA's `maxDpbSlots` of 16 — so every AU fell outside
device caps, flushed, waited for an IRAP, and the fresh IDR needed 17 again; rungs exhausted, and
there is no software HEVC. It hid because the path was only ever exercised at 4K, the one size that
falls through to the honest answer. Fixed to `buffering.min(16)`: the `max()` bought no tolerance,
since `Dpb::needs_bumping` already evicts at the signalled depth — it only over-allocated ten
surfaces per 1080p session. **H.264 escaped by luck** (its ceiling lands at 13 for 1080p) and is left
alone, because H.264's DPB size genuinely *is* level-derived absent a VUI `bitstream_restriction`.
**rav1d aborts the process — new in this release, client-side.** rav1d 1.1.0 `abort()`s on *any*
decode error while holding one frame context: the `c.fc.len() == 1` branch decodes inline, always
finishes in `rav1d_decode_frame_exit` which unconditionally takes `frame_hdr`, then on `Err` re-enters
an `on_error` whose first act is `frame_hdr.as_ref().unwrap()` on the `None` it just left. The panic
unwinds into `dav1d_send_data`, which is `extern "C"``panic_cannot_unwind``abort()`. **No
`catch_unwind`, no rung demotion and no refusal can catch it**, and every `rav1d_*` entry is
`pub(crate)`, so no in-process guard is possible. 4K was only *where* the first error happened — the
CPU rung does 3539 fps against a 60 fps stream, the backlog stopped draining, the pump flushed to
live, and the next AU referenced undecoded frames. Fixed by opening with `n_fc >= 2` and asking
`dav1d_get_frame_delay` what the settings actually bought. Decode now drains **past** the first
`EAGAIN`, which is why two frame contexts cost no latency (2042 ms/unit at `n_fc=2` vs 2153 at
`n_fc=1`). On glass: 4K60 AV1 was SIGABRT on the second frame every run; after, exit 0 with 1204
frames and 13 decode errors recovered across 17 backlog flushes. Reported upstream as **rav1d#1497**
with a reproducer. Does **not** make the CPU rung panic-proof.
**Settings loader BOM — shipped in v0.24.0, client-side.** `.and_then(|s| from_str(&s).ok())` turned
every parse failure into `Default`. `Set-Content -Encoding UTF8` writes `EF BB BF`, serde_json
correctly rejects at byte 0, and every setting vanished silently. A shared `load_json_or_default` now
strips the BOM and warns with path plus serde line/column, covering settings, known-hosts (where a
BOM silently unpaired every host) and profiles on both desktop clients. The result is deliberately
still `Default`, never an error.
### Other decode/encode
- **Intel Arc pNext ordering.** `vkGetPhysicalDeviceVideoCapabilitiesKHR` was called with the codec
caps struct chained *before* `VkVideoDecodeCapabilitiesKHR` (`push_next` prepends). Arc/Windows
fills those two **by position, not by sType**, and returned them swapped — we read a level as a
capability bitmask. Measured A/B: `decode_flags_raw=12 max_level_idc=1` before,
`decode_flags_raw=1 max_level_idc=12` after. NVIDIA and RADV dispatch by sType, which is why the
fleet stayed green. ⚠ **This does not yet give Arc Vulkan Video** — the refusal only moves down: the
device advertises only COINCIDE, and its NV12 coincide entry does not advertise `SAMPLED` usage,
which the zero-copy presenter needs. Unresolved whether that is ours or an Intel constraint.
- **NVENC split encode.** The 10-bit rule sat *above* the pixel-rate arm and took no codec, so it
vetoed 10-bit 4K120 — the exact case the pixel-rate arm exists for — and applied an
HEVC-Main10-on-Ada result to AV1 10-bit, which has no such measurement. Re-measured on Ada and
Blackwell: 4K60 2.06×, 5120×1440@240 1.31×, 4K120 1.89× — **split wins at every mode on both
architectures, including the configuration the veto came from.** New order: env override →
pixel-rate arm (now taking `max_forced_split_mode(engines)`, not a hard-coded 2) →
HEVC-Main10-below-the-bar → AUTO. Operator over-asks are clamped with a warning because **the driver
honours an over-ask and silently encodes narrower**. Also newly logged: HEVC + plain AUTO +
sub-frame is **silently single-engine** — the fleet's default shape, and nothing said so.
**Unvalidated consequence:** 5120×1440@240 Main10 now clears the pixel-rate bar and *will* be
forced to split — the exact configuration the old veto came from. `PUNKTFUNK_SPLIT_ENCODE=0` is the
escape.
- **PyroWave on Windows stamped over the host's GPU scheduling policy.** It raised the process WDDM
class to HIGH at every session open, while `auto_priority_gate` already owns that process-wide —
starting at HIGH, *upgrading* to REALTIME once safe, and leaving a monitor that drops back when VRAM
tightens (REALTIME + NVIDIA + HAGS + near-full VRAM is a documented NVENC hang). Opening PyroWave
stamped HIGH back and **orphaned the monitor's decision**. Removed rather than reconciled.
- **A `pf-vkdecode` AV1 use-after-free fix had stabilised the wrong pointer** —
`OwnedStdAv1SequenceHeader` kept the Std struct *inline*, so `pStdSequenceHeader` was a dead stack
address; it worked only because NVIDIA happened to retain `pColorConfig` instead. Std structs are
now boxed inside each owning wrapper, and create-time arrays are fields of the stored parameters
assembled at their final address. The same shape was fixed pre-emptively in H.264/H.265.
### A/V sync — it did not previously exist
The host has always stamped `pts_ns` on every audio datagram. **Every client decoded it into
`AudioPacket` / `AudioPCM` and never read it.** Video's `pts_ns` was used end to end; audio free-ran
at whatever depth its jitter ring reached; nothing compared them. The A/V offset was an emergent
property of buffer depths — it moved whenever the ring ratcheted under underrun pressure, and it got
**worse every time video got faster**, because a quicker decoder lowers the video leg and leaves
audio's where it was. That is why shaving milliseconds off the audio budget had never helped.
Two host defects were prerequisites:
- **`pts_ns` was stamped at encode time**, inside the loop draining an already-accumulated chunk, so
every frame of a chunk carried near-identical timestamps describing *when we got round to
encoding*. Now derived from the chunk's arrival instant minus queued-frame duration, re-anchored
per chunk.
- **The host did not pace.** One capture callback hands over a whole quantum (5 ms honoured, **21.3 ms
on a VM**, where stock PipeWire raises `min-quantum` to 1024), drained into back-to-back
`send_datagram` calls — a 45 frame burst then ~21 ms of nothing, which a ring could only absorb by
standing a burst period deep. Frames now leave on the audio clock (`FRAME_INTERVAL` 5 ms,
`PACE_MAX_SLEEP` 10 ms, `PACE_REANCHOR` 100 ms). Costs no average latency.
```
audio_e2e = (now + buffered_ahead + clock_offset) pts_ns
av_offset = audio_e2e video_e2e (> 0 ⇒ audio behind the picture)
```
`AvSync` EWMAs it (`AV_EWMA_TAU_MS = 2000`), ignores anything inside `AV_DEADBAND_MS = 10`, waits
`AV_MIN_OBSERVATIONS = 100` before a first correction, and **refuses rather than clamps** beyond
`AV_SANE_LIMIT_MS = 1000` — a wall-clock step must not steer the ring.
**Video is the master, and continuity outranks sync.** `JitterPolicy::set_sync_target` takes only a
*request*, clamped between the existing underrun-driven adaptive floor and the hard cap: a link whose
jitter genuinely needs more buffer than the picture is away keeps its buffer, and the residual is
reported rather than forced. `None`/`nil` reproduces prior behaviour bit-identically, which is how
the four rings adopted it one at a time.
Per client: the Rust desktop reference is a new `video_e2e_ns` atomic beside `clock_offset`, written
by the presenter and read by the audio thread. **Android** publishes `OnFrameRendered` — the one
place that knows a frame *latched***raw, not floor-shaved** (the HUD shaves the OS present floor;
sound must reach the ear when light reaches the eye), and stays inert below API 33 rather than
substituting the release instant, which targets a future vsync 821 ms ahead of glass. **Apple**
publishes its `LatencyMeter` sample as an *expiring level*, because that client has a backgrounded
keep-alive that keeps audio playing while dropping video decode; its clamp raises the ceiling to the
floor rather than `min(max(…))`, which on a device whose callback quantum alone exceeds the hard cap
would otherwise hand back the cap, silently below the continuity floor.
Escape hatches: `PUNKTFUNK_NO_AV_SYNC=1` everywhere, plus
`adb shell setprop debug.punktfunk.no_av_sync 1` on Android (a launcher-started app inherits no
environment). Observability: `buffer_ms`/`target_ms` had only ever been a `tracing::debug!` line —
and on a Deck the client runs under Steam's `reaper` with stdout on a pipe nobody can read, so the
one number identifying a deep ring was unobtainable *on the device reporting the latency*. Now on the
HUD and in the 1 Hz stats log on every client.
### Decode-target aliasing — caught before it shipped
**None of this ever shipped.** `git ls-tree v0.24.0 crates/` has no `pf-vkdecode`, `pf-dxvadec`,
`pf-vaadec` or `pf-bitstream`; v0.24.0's decode rungs were libavcodec. This was a ship-blocker for
the new stack, cleared — not a field bug.
Three of the four native rungs released a picture's surface **inside the plan→submission
conversion**, then assigned the decode target a slot. `SlotMap::assign` returns the *lowest free
slot* — the one just vacated. The submission then named one surface as both decode target and its own
reference: `CurrPicTextureIndex == RefFrameMapTextureIndex[k]` on DXVA, or `pSetupReferenceSlot`
sharing an array layer with `pReferenceSlots` on Vulkan. **Decode into the surface you are predicting
from.**
- **AV1 / D3D11VA** — AV1 applies `refresh_frame_flags` *after* decode (7.20), so "read a slot then
overwrite it" is the ordinary case: **268 of the vendored vector's 274 frames**, first at frame 6.
- **H.264 / both Vulkan and D3D11VA** — `H264Planner` snapshots `dpb_refs` in `begin_picture`, before
8.2.5 marking and the C.4.5.3 bump, so a picture the sliding window unmarks and the bump evicts
lands in *both* `dpb_refs` and `dpb.removed`. Both conditions coincide only in low-delay H.264 —
and NVENC guarantees it (`max_num_ref_frames = 3` alongside `max_dec_frame_buffering = 3`, plus
`max_num_reorder_frames = 0`). Result: **297 of every 300 access units of every stream a punktfunk
host emits**, at every resolution, on both rungs.
- **H.265 is exempt, now measured rather than argued** — 0 of 120 aliases, with a counterfactual that
moves the snapshot one call earlier and reproduces 115 of 120.
- **VAAPI's exemption was incidental**: the precondition is fully present (117 of 120 AUs) but
`plan_to_va` never invents a surface. That held only because three call sites happened to write
`free_surface()` and `surface_table()` adjacently; `acquire_target` now returns index, surface and
table together so a later edit cannot split them.
Fix is uniform: the plans grow `release_after_decode`, conversions hand removals back, callers
release once the decode op is issued. Costs no slot (`SlotMap::new` allocates `max_dpb_frames + 1`).
Both rungs hold the `Result` rather than `?`-ing it so the deferred release runs on failure paths —
seven exits sat between conversion and release, each of which would have leaked a slot.
**Why four gates missed it**, all recorded: the conformance vector is *structurally blind* (level 1.3,
no VUI `bitstream_restriction` ⇒ a 7-frame DPB against 2 reference frames, and it reorders) and
passed 250/250 for two milestones; **a test had encoded the bug as correct**; another assertion was
*vacuous* (it asserted the decode target was never also a reference while handing every picture its
own never-reused surface id — distinct integers cannot collide); and **it streamed clean** — *"the
2026-08-07 field sessions that looked clean were looking at wrong pixels."*
`gpu_parity` is now **11 legs** (not 9 — that note was written mid-PR): each decodes a vendored stream,
reads back every output frame's NV12, crops to the display region and SHA-256s in *display order*
against libavcodec goldens, frame count and flush tail included. The three new legs are our own
encoder's output rather than conformance vectors — H.264 because the vector is blind to the shape,
H.265 because an exemption with no stream behind it is how the H.264 defect survived two milestones,
AV1 because the vector is one tile on all 274 frames while our encoder splits 4K into two tile rows,
so every tile array the conversions fill had only ever been written at index 0. `video_vaapi_native`
parity is new entirely: 7 legs, bit-identical on RDNA3.
⚠ Promoting D3D11VA AV1 to `verified` **changes rung selection** on Windows Intel/unknown vendors, not
just a label. VAAPI stays `verified = false` deliberately — one vendor, never soaked; flipping it
would move `auto` off Vulkan Video on every Linux AMD/Intel client including the Deck.
### FFmpeg 9, and the Arch soname trap
`pf-encode` now builds against **FFmpeg 9**. The host still links libavcodec unconditionally; the
client has none (see above).
**`pacman` is the only one of our packaging formats that does not derive dependencies from ELF
`DT_NEEDED`.** rpm auto-generates `libavcodec.so.62()(64bit)`, `dpkg-shlibdeps` emits `libavcodec62`,
nix pins the closure — but a bare `depends=('ffmpeg')` let `pacman -Syu` walk the host across a
soname bump with no warning and no conflict. FFmpeg 8 → 9 (`2:9.0-5`: libavutil .60→.61, libavcodec
.62→.63, libavfilter .11→.12, libavdevice .62→.63, libswscale .9→.10) therefore **bricked every
Arch/CachyOS install**: the dynamic loader cannot start the binary, so it is **exit 127 before
`main()`** in a systemd restart loop, with nothing in the host's own log to explain it.
`ldd /usr/bin/punktfunk-host | grep "not found"` is the one-line diagnosis.
⭐ The fix is **SONAME deps, not a hand-written version bound**: `depends=(… 'libavcodec.so'
'libavutil.so' …)`. Arch's ffmpeg declares matching `provides=(libavcodec.so=63-64 …)`, and makepkg
rewrites each bare `libfoo.so` into `libfoo.so=<soname>-<arch>` by reading the built binary's
`DT_NEEDED` — so the bound tracks whatever FFmpeg the builder linked against with nothing to
maintain across the next bump. A literal `ffmpeg<2:9` would go stale on every bump. pacman now
refuses the upgrade instead of bricking the install. All seven libs are listed even though
`--as-needed` currently drops two: an unlinked soname is left bare by makepkg and satisfied by any
ffmpeg, so listing it costs nothing and a future link picks up the bound automatically.
🛑 **The v0.25.0 Arch packages shipped with that bound pointing at the WRONG FFmpeg — install
`punktfunk-host 0.25.0-2` or newer.** The soname fix and the FFmpeg-9 build landed as one merge;
the release tag was pushed four minutes later, while the CI builder image was still being
rebuilt. arch.yml deliberately runs no `-Syu` ("the image's snapshot IS the build environment"),
so the release was linked against FFmpeg 8 and published `libavcodec.so=62-64` — a bound no
up-to-date Arch box can satisfy. It fails *safely* (pacman refuses; nothing bricks), but it fails
**loudly and broadly**: pacman prepares one transaction, so an unsatisfiable dependency of ours
stopped affected users' entire `pacman -Syu`. `0.25.0-2` is the identical source rebuilt against
FFmpeg 9. Only Arch was exposed — every other format derives its dependency from the ELF at build
time and could not disagree with itself this way.
Two guards now stand where only a convention did. arch.yml compares the builder's libav
`provides` against the live repos before building and `-Syu`s itself if they differ; and no
package is published until a **pristine-`--dbpath`** `pacman -U --print` resolves it, which asks
"would a real, up-to-date Arch box install this?" instead of "does the builder happen to satisfy
it?" — the distinction that let this ship. Keeping `ci/arch-ci.Dockerfile` current is still the
cheap path; the guards are the backstop.
### Linux playback filled the buffer ceiling
The PipeWire playback callback sized its writes from the mapped buffer's **capacity** — PipeWire's
quantum limit, 8192 frames ≈ 170 ms — instead of the graph's per-cycle ask (`pw_buffer.requested`).
Every cycle queued up to 170 ms of PCM downstream of the ring **and** taught `JitterPolicy` that the
device drains 170 ms per callback, so the underrun floor (want + one frame) rose above any depth the
A/V sync loop could request: sync measured audio ~280 ms late and was then forbidden — **by its own
continuity rule** — from draining it. The first on-glass run of the latency overhaul showed exactly
that: `audio buffer 272 ms, a/v +284 ms`, stable. Now honours `requested` (capacity remains both the
ceiling and the fallback when `requested == 0`) and logs requested-vs-capacity once per stream.
Needs libpipewire ≥ 0.3.49; every ship target clears it.
### Windows audio substrate
The host now mints its **own** devnodes from Valve's INFs (`SteamStreamingSpeakers.inf` /
`SteamStreamingMicrophone.inf` under `{CommonProgramFiles(x86)}\Steam\drivers\Windows10\…`) instead
of bundling VB-CABLE.
- **Two persistent endpoints**, `Punktfunk Speakers` (client-only loopback sink — the wiring plan
parks the default playback on it during a stream, its WASAPI loopback feeds the encoder, the host
stays silent) and `Punktfunk Microphone` (host writes decoded client voice into the render side;
the capture side surfaces as the mic). Both survive host restarts and re-resolve by marker.
- **Identity is the recorded endpoint id, never the name** — a minted instance is name-identical to
Steam's primaries. Durable marker `PunktfunkAudioRole` (1 = Speakers, 2 = Mic) under Device
Parameters. Name stamping is device-desc + device-name **only**: a wider stamp set makes
`AudioEndpointBuilder` re-mint under a new GUID. Best-effort via the SYSTEM ACL route; on failure
the endpoint still wires and simply keeps the driver's default name.
- **Format stamps are per-direction.** Render gets the PCM16-device / float-mix stereo split; capture
gets the **device-format key only** — mix and host-format keys are render-engine properties, and
stamping them onto a capture endpoint breaks its shared-mode graph (`IsFormatSupported` reports
2ch/48k fine, `Initialize` then fails `0x88890008`).
- **`MintedIds` is tier-0 in the wiring plan.** The mic takes its minted device outright (paired by
provider id — a name search cannot distinguish it from the primary); the loopback prefers the
minted sink at the head of the silent tier. Below that the old ladder is unchanged: Steam primaries
→ cable → real hardware. `PUNKTFUNK_MIC_DEVICE` still beats everything.
- **Mic-vs-loopback arbitration**: the mic may hold the Streaming Microphone only while the loopback
still gets a non-last-resort pick; otherwise the loopback takes it and `mic_withheld` is set. This
fixes a field case where a headless Steam-only host streamed **silence**.
- **New `AudioReadiness`** — `Full` / `AudioOnly` / `MicOnly` / `Nothing`, logged on every plan
change and surfaced at `GET /api/v1/status``RuntimeStatus.audio` (`AudioWiring`, Windows-only,
absent before the first wiring pass; a status poll triggers no COM work or `IPolicyConfig` writes).
The console Dashboard renders it as an "Audio wiring" card.
- **Requires Steam installed** (never running) — without the INFs the host streams video only, and
picks the drivers up automatically if Steam is installed later. Opt out entirely with
`PUNKTFUNK_NO_AUDIO_MINT`, which restores the previous name-based ladder exactly.
-**VB-CABLE is no longer bundled but is deliberately NOT uninstalled** — it is a third-party
shared component other apps may use, and it stays in the ladder as a live fallback. Demoting it was
considered and rejected: on a box where minting transiently fails, that would let the Steam
Streaming Microphone outrank an installed cable, steal the silent sink and make stream audio
audible on the host.
-**The minted endpoints survive Punktfunk's uninstall by design** (they are plain instances of
Steam's drivers and are inert without the host). There is no user-facing removal path; cleanup is
the devtest `punktfunk-host audio-probe cleanup`.
- New devtest: `punktfunk-host audio-probe ssm|sink|sss-primary|mint|plan|micpitch|micpins|cleanup`.
`plan` is the field-triage command; `micpins` maps exclusive+shared `IsFormatSupported` across
{1,2}ch × {16,32}bit × {44.1,48,96}kHz on both mic pins.
### Apple audio
- **The microphone was never in the render graph.** On the combined (voice-processing) engine — made
default a week earlier and never run on a device — the input node carried a tap and **no
connection**, so nothing pulled it: the IO unit came up, the recording indicator lit for a beat,
and not one buffer ever reached the tap, with no error and no failed start. The 10 s silence
tripwire counts *captured* frames, so it never fired. Input now runs through a silent sink into the
main mixer at `outputVolume = 0` (Apple's own voice-processing sample topology). Two more: the tap
read the input format **before** `prepare()`, and enabling voice processing swaps in the VPIO unit
and renegotiates, so the pre-swap read could be 0 Hz / 0 ch; and a mic-chain failure on the
voice-processed engine took the whole uplink down for the session — it now falls back to the split
path, because **the mic outranks the AEC**.
- **No packet-loss concealment on the one client that decodes Opus in core.** Linux, Windows and
Android all feed an `AudioGapTracker` and synthesize libopus PLC; the in-core path had the tracker
sitting unused in the same crate and decoded only packets that arrived. At ~200 packets/s of 5 ms
frames every lost datagram was a hard time-domain gap — one click per loss. The redundant plane
(`0xD2`) hides single losses, so the survivors were exactly the burstier gaps that most needed
concealing. Concealed frames now land in front of the arriving frame in one contiguous buffer, a
DTX marker advances accounting without being decoded, and the output buffer is pre-sized for a full
concealment run so the borrow-until-next-call pointer cannot dangle (50 ms cap).
- **The Apple jitter ring never grew.** The shared Rust `JitterPolicy` has an adaptive target floor;
the hand-written Apple mirror mirrored the *shed* half but not the *growth* half, pinning its
target at the 20 ms base forever. On Wi-Fi that bunches arrivals, 20 ms is regularly shorter than
one delivery stall, so the ring re-primed through every stall for the whole session. Now the full
`note_read` mirror: 3 underruns in a 5 s window grow the target 10 ms (capped at CoreAudio's 70),
30 s of quiet steps back, and the write-side hard trim follows the grown target.
### Clients
- **Nothing in the desktop console had ever been clickable.** `SkiaOverlay::handle_event` matched
only `KeyDown` and `TextInput`, so every mouse button, wheel and touch contact fell past the console
into the run loop, which routes pointer input exclusively at `stream.capture``None` while
browsing. New `Overlay::handle_pointer` carries mouse/touch in swapchain pixels; the run loop
converts (it owns the window and hence display scale); the console hit-tests the rects it drew last
frame. Only **direct** touch devices are offered — an indirect trackpad already drives the mouse.
Widgets act on **press**, not release, because both carousels scroll the focused item toward centre
and what you pressed would slide out from under your finger. Host menu on Up from a saved tile;
`UpdateHost` edits **in place** (remove-and-re-add would silently drop the fingerprint, learned MAC,
pinned cards and profile binding), and `ForgetHost` arms on first press and fires on second.
- **Discovery went permanently deaf three ways**, each needing an app relaunch: a failed resolve was
never retried (`browseResultsChangedHandler` fires only when the result *set* changes, and a host
whose resolve failed is still in the set); a stuck resolve never ended (`NWConnection` has no
timeout, so the throwaway UDP flow could sit in `.preparing` forever, and a service with a
connection in flight was skipped); and an `NWBrowser` parking in `.waiting` was ignored — **which is
exactly where iOS's local-network privacy prompt lands on first launch, and granting it does not
revive the browser that was already waiting.** A 1 Hz sweep now times out stuck resolves, retries
failed ones on a 1→30 s backoff, and re-arms a dead browser; the advert's TXT is re-read on every
browse report. `discovery::Rescan` forces a fresh mdns-sd query — the browse otherwise re-queries on
a doubling backoff **capped at one hour**, so a long-lived browse is effectively passive. ⚠
`clients/windows/src/discovery.rs` is a **second copy** of the browse that the earlier IPv4 pinning
missed; it took an arbitrary first address, so a host whose OS responder answered AAAA rendered a
card that failed on every click.
- **Phone gyro mirror**, off by default, player 1 / wire pad 0 only, and only while that pad has no
motion source of its own. iOS/iPadOS only on Apple (`DeviceGyro` wraps `CMDeviceMotion` at ~100 Hz
on a dedicated serial queue — the controller path's main-queue delivery is a known jitter source);
Android phones with a gyroscope at ~200 Hz with `maxReportLatencyUs = 0`, since batching is poison
for gyro aim. Both rotate from the device's natural frame into the controller frame by interface
orientation, and both send **one zero-gyro sample on stand-down** — the host holds motion as state
and re-emits it, so a leftover nonzero angular velocity reads as endless rotation.
- **Safe-area resolution** is purely a *sizing* change — no layout change, no input change; pointer
mapping follows for free since both clients derive the picture rect from the live host mode. Full
native height, width less left+right safe insets. Portrait settings screens report the housing on
`top` with zero horizontal insets, so the portrait top inset stands in (gated so an iPad's status
bar never fabricates one). Android adds the rounded-corner radius, which it does not count as
cutout. Both even-floor and clamp, because `validate_dimensions` rejects odd dimensions and an inset
subtraction lands odd about half the time.
- **Gamepad UI**: six sections (Stream · Video · Audio · Controller · Interface · Profiles, plus Input
on the desktop console) walked with L1/R1 with per-section cursor memory; 12 palettes under one
shared `ui_palette` key, Violet keeping its explicit sixteen colours so existing installs are an
identity transform. Presentation only → **device preference, never part of a profile**. Palette
maths ported three times (Rust/Swift/Kotlin) with the same assertions pinned in each language;
`every_palette_is_multi_tone` fails under 45° hue spread and caught Ember at 35° and Graphite at 3°.
Three render-only findings: additive blending blows out over a pale ground, a white scrim at the
dark field's strength bleaches the gradient, and white glass over a bright field needs more body.
### Session and game lifetime
- **`PunktfunkEndReason` replaces a single "closed" bit** (ABI 17, additive, wire untouched). Five
values — local, game exited, host ended, host error, lost — classified by the connection watcher
from close codes already on the wire (`APP_EXITED_CLOSE_CODE` had been sent for a long time with
nothing consuming it). **Latched before the shutdown flag**, because the two are read by different
threads and the reason must never arrive second. Exposed as `punktfunk_connection_end_reason` +
`is_normal()`. Shells fall back to the old wording when there is no verdict (older core, or a close
that raced the read).
- **The Steam `Running` registry hint was an unbounded veto.** Honouring it reset the absence window
every pass, so a flag Steam left set — Steam crashed, was closed first, the game re-parented —
pinned a lease in `running` for the life of the host process. The absence timer now runs
regardless; past `VETO_LIMIT` (30 s) with nothing of the game on the box, the session ends anyway
and logs at WARN. Extracted as a pure `exit_confirmed(gone_for, hint_running)` with tests — the
watch loop polls a live process table and cannot be unit-tested, which is exactly how the
unbounded veto shipped.
- **New `launchreg.rs`: one record per `(client fingerprint, library id)`**, written at launch and
independent of the termination policy. The old fingerprint-keyed reclaim only ran under
`GameOnSessionEnd::Always`, so under the default `Keep` nothing was recorded — and a client retry
re-sent `Hello::launch` verbatim, which the host obeyed unconditionally. Steam/Epic URIs hid it
(the launcher just focuses the running copy) but a `gog:`/`custom:` target genuinely started a
second instance over the same save files. The same retry also minted a fresh `launch_stamp`, so
procscan refused to adopt a game older than 2 s and **a reconnected session lost game-exit
detection for the rest of its life.** Identity now flows backwards from the watcher, which
publishes the concrete `ProcRef`s it adopted; liveness is `Scanner::alive` over that recorded set,
re-verified by `(pid, start)`. Tradeoffs: a `custom:` command with no detection hints stays
`Unknown` forever (trading exit detection for not double-spawning), and `IN_FLIGHT_WINDOW` is a
fixed 90 s, deliberately not `disconnect_grace_seconds`.
- **A launcher entry is `LeaseKind::Untracked` unconditionally**, checked ahead of
`nested`/`child`/`spec`. Its lifetime previously depended on invisible state: launcher not running
→ live child → `Child` lease → quitting the launcher ended the session; launcher already running →
command forwards and exits inside `SHIM_WINDOW``Untracked` → session persists. Steam Big Picture
is a *mode*, not a process (and on a Deck it is always running); Heroic is single-instance
Electron. The real trap was the GameStream path, whose `GsApp` intermediate silently dropped the
field.
### Library and plugins
- **Store claims keep identity across the scanner-to-plugin handover.** `library.json` gains a v2
`{entries, claims}` shape that reads the old bare array unchanged and rewrites on first mutation.
`PUT /library/provider/{p}?store=<s>` claims a store; entries then surface as
`<store>:<external_id>` rather than `custom:<id>`, so entry ids, GameStream app ids, client art
caches and Moonlight pins all survive. One provider per store (409 otherwise); while a claim is
held the matching built-in scanner is skipped, so the two never double-list.
- `GET/PUT /library/scanners` is now a **sources** endpoint over the same disabled-set file.
- New entry fields: `role: game|launcher`; launch kinds `steam_ui` (`bigpicture|desktop`),
`launcher_ui` (platform-gated, 400 on invalid) and `xbox`.
- **Plugin kit 0.3.0** adds a `./library` subpath: `defineLibraryPlugin` plus ported total parsers —
text VDF/ACF, the binary `shortcuts.vdf` walker with CRC-32 appid derivation, read-only immutable
SQLite, a registry wrapper that refuses HKCU, path-confinement joins. `GET/PUT /__config` returns
`{schema, value}` and persists raw, so a plugin with settings need not ship an SPA.
### Platform and packaging
- **The client's config writer** falls back to an in-place write when the atomic replace is
unavailable, verifies it by reading the bytes back, and records the last persistence failure
centrally so the UI can surface it. Scratch files are now per-process, closing a real collision
between the five processes that write these stores (shell, session, console UI, CLI, Decky) — one
could previously rename its half-written temp over another's target.
- **Host send pacing** gained a pure, unit-tested budget function: oversized frames are budgeted at
the pacing rate with a 100 ms absolute ceiling rather than compressed into one frame interval.
Steady-state schedules are byte-identical, the legacy behaviour stays reachable via an environment
escape hatch, and the GameStream-compatible path is untouched.
- **Mid-session shard renegotiation is gated off for PyroWave sessions**, which parse the video
stream in windows fixed at session start — re-sizing mid-stream would corrupt the parse. Those
sessions get the next-session clamp only and are excluded from jumbo. The ABR decode-cap latch
likewise does not apply to PyroWave, where adaptive bitrate is open-loop by design.
- **The Deck's Vulkan compatibility layer is built from source**, pinned to the same upstream
revision as the host's own packaged build — bump both together. ~4 MB of app content replaces a
94 MB external extension, and Flathub is no longer needed at install time. ⚠ `subprojects/vkroots`
is a gamescope **submodule** and flatpak-builder clones submodules by default; declaring it again
as an explicit source breaks the build during extraction. `glm` and `stb` are `.wrap` files, not
submodules, and *do* need explicit sources.
- **Build-container images push to an authenticated registry endpoint**, and `:latest` is reconciled
against the content key on every push to main — an out-of-band tag move is detected and repaired
rather than silently inherited.
- **Windows pad drivers** publish their sequence counters with release ordering (the host was already
loading with acquire and pairing with nothing) and serialize the output-ring publish. The
`/dev/uhid` event ABI, previously transcribed into all five Linux gamepad backends, is consolidated
into one module.
### Verification status
Honest about what has and has not been on hardware, because several things in this release have not:
- **Controller audio has never run on a real DualSense.** Its entire verification is unit tests and
compile checks, and its rumble arbitration rests on an explicitly retracted assumption about
whether the voice coils and the rumble motors are the same actuators. The evidence-based 500 ms
idle window is correct either way, but the underlying exclusivity is unsettled. Android's arbiter
is the evidence-based one; the desktop twin and the coil restore on Android's stop path are owed.
Some Android OEM kernels refuse the isochronous claim outright, which degrades to ordinary rumble.
- The **plugin-UI origin split** is validated against a fake console and a fake plugin, not yet in a
real browser.
- The **packaging default-on changes** have had no installer run or package build.
- **No launcher tile has been clicked on a real host** — the first source that would publish one does
not exist yet.
- Desktop-audio, packet-sizing and iPad-pointer work is build-verified only.
-**The FFmpeg-deletion milestone itself has never executed on a GPU.** It was gated on
cross-clippy, 160 tests, a workspace check and an `ffmpeg` count of 0 in the client / 2 in the host.
The software on-glass check, the D3D11 and VAAPI AV1 hardware legs and the field bake were all owed
at merge; later commits closed some of that but not all. The "no FFmpeg" claim is verified by
`cargo tree` and a notices-generator mention count, not by inspecting a shipped binary.
-**`pf-vaadec` has never decoded a frame anywhere** — no VAAPI hardware was reachable. It is the
*first* rung on Linux/Intel and unknown vendors; the evidence filter bars it there in favour of
`pf-vkdecode`, but an explicit pin reaches it.
- **openh264 has never run on glass**; the H.264 software rung is unit-tested only.
- **`native-d3d11va` AV1 is deliberately `verified = false`** — one 25 s 4K60 session, no parity.
- **Split arbitration is opt-in and Linux-wired only**; the Windows arm is built and unit-tested but
not on hardware. The 5120×1440@240 Main10 behaviour flip is explicitly unvalidated and is named as
the first thing to re-measure.
- **Software throughput is unmeasured in general** — the CPU rung does 3539 fps at 4K AV1 against a
60 fps stream, which is why the backlog flush that triggered the rav1d abort happens at all.
- **The Apple mic fix is a proven root cause, not a verified session.** Its own commits call it "a
strong inference plus one proven logic defect rather than a confirmed fix" and close "awaiting the
reporter's on-device confirmation" — which nothing later in the range records. It also leaves a
known gap: nothing reports whether the uplink actually opened, so the HUD still offers a Mute
Microphone button over a session that may be sending nothing.
- **The Windows audio substrate is, by contrast, well-evidenced on hardware** — repeated "measured on
the target box", a live bisect on a fresh endpoint, and a `micpitch` proof reading 440 Hz in →
440 Hz out at exact peak. The one thing not evidenced is a real client speaking through the minted
microphone end to end; the pitch proof is probe-driven.
- **The phone-gyro mirror is not recorded as hardware-verified** — remap matrices are pinned by unit
tests in both languages, but there is no "played a game with a clip-on pad" evidence in the tree.
- **The iOS gamepad-UI pale-palette sweep on glass is still owed**, per its own commit.
-**The CI runner scripts are hand-installed** (`/usr/local/bin/ci-docker-prune.sh`,
`/usr/local/sbin/ci-docker-reclaim.sh`). Merging does not deploy them — both runner hosts need the
files copied out of `scripts/ci/`, and the missing `192.168.1.58:5011` insecure-registry entry on
one host is routed around, not fixed.
+11 -57
View File
@@ -1,10 +1,10 @@
# Contributing to Punktfunk
# Contributing to punktfunk
Thanks for your interest in contributing!
## Licensing of contributions (inbound = outbound)
Punktfunk is dual-licensed under **MIT OR Apache-2.0**.
punktfunk is dual-licensed under **MIT OR Apache-2.0**.
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
@@ -28,30 +28,6 @@ If you add a new third-party dependency, it must be permissive (MIT / Apache-2.0
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
## Prerequisites
The Rust toolchain is **pinned exactly** in `rust-toolchain.toml`; rustup installs it for you the
first time you build, so don't override it — a different rustc reformats files nobody touched.
The workspace links real system libraries, so a bare `cargo build --workspace` fails on a stock
machine. The authoritative list is what CI installs, in `ci/rust-ci.Dockerfile` — on **Ubuntu 26.04**,
which is what gets you FFmpeg 8:
```sh
sudo apt install build-essential clang libclang-dev pkg-config cmake \
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev \
libpipewire-0.3-dev libopus-dev libwayland-dev libxkbcommon-dev \
libgl-dev libegl-dev libgbm-dev \
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
libvulkan-dev
```
(The last two groups are the Linux client shell and the Vulkan session presenter; skip them only
if you never build those crates. `libvulkan-dev` is for the LOADER's pkg-config/soname — ash
dlopens it, and the client links no FFmpeg at all, so no libav*-dev appears here.
`scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway, PipeWire —
and is not a substitute for the list above.)
## Before you push
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
@@ -62,40 +38,18 @@ on formatting alone:
git config core.hooksPath scripts/git-hooks
```
Then the usual full pass. Use `--locked` as CI does — otherwise a silent `Cargo.lock` update can pass
locally and fail CI:
Then the usual full pass:
```sh
cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```
Two more gates that only apply to some changes:
Generated artifacts are checked in and CI fails on drift: `include/punktfunk_core.h` (cbindgen) and
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
- **Touched `web/` or `docs-site/`?** CI builds and typechecks both. Run, in that directory:
```sh
bun install && bun run build && bun run lint
```
Build first — it generates the API client / MDX typegen that the typecheck imports.
- **Touched Windows- or Linux-gated code from another OS?** `scripts/xcheck.sh windows` (or
`linux`) type-checks and lints that platform's `#[cfg(target_os = …)]` code in about a second,
instead of waiting for the CI job that compiles it.
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
and copy the snapshot the docs site serves:
```sh
cargo run -p punktfunk-host -- openapi > api/openapi.json
cp api/openapi.json docs-site/public/openapi.json
```
Match the surrounding code's comment density and naming. Commit messages end with the
`Co-Authored-By` trailer (see `git log`).
See the [README's Build & test section](README.md#build--test-from-source) for the extra dev
commands (the FEC loss harness, the standalone C-ABI proof) and
[Design invariants](README.md#design-invariants) for the rules a change is expected to hold to, and
the [docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
See the [README's Build & test section](README.md#build--test-from-source) and
[Design invariants](README.md#design-invariants) for the full build/test/run guide, and the
[docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
Generated
+117 -582
View File
File diff suppressed because it is too large Load Diff
+2 -40
View File
@@ -5,16 +5,13 @@ members = [
"crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim",
"crates/punktfunk-tray",
"crates/pf-bitstream",
"crates/pf-bitstream/vendor/cros-codecs",
"crates/pf-client-core",
"crates/pf-clipboard",
"crates/pf-presenter",
"crates/pf-console-ui",
"crates/pf-ffvk",
"crates/pf-driver-proto",
"crates/pf-paths",
"crates/pf-update",
"crates/pf-update-check",
"crates/pf-host-config",
"crates/pf-gpu",
"crates/pf-zerocopy",
@@ -24,19 +21,13 @@ members = [
"crates/pf-capture",
"crates/pf-inject",
"crates/pf-vdisplay",
"crates/pf-vkdecode",
"crates/pf-dxvadec",
"crates/pf-vaadec",
"crates/pyrowave-sys",
"crates/libvpl-sys",
"clients/probe",
"clients/cli",
"clients/linux",
"clients/session",
"clients/windows",
"clients/android/native",
"tools/cursor-probe",
"tools/display-disturb",
"tools/latency-probe",
"tools/loss-harness",
]
@@ -57,42 +48,13 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.25.0"
version = "0.13.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
authors = ["unom"]
repository = "https://git.unom.io/unom/punktfunk"
# The `unsafe` discipline the `packaging/windows/drivers/*` crates already run, extended to the
# workspace. `unsafe fn` marks a CONTRACT the caller must uphold; it is not a licence for the whole
# body to skip checking. Without this lint an `unsafe fn` body is unchecked end to end, so a 600-line
# function hides which handful of lines are actually the unsafe ones — exactly the reviewer-hostile
# shape we are working down. (This is the Rust 2024 default; adopting it early also pays off the
# edition migration.)
#
# `deny`, not `warn`. `warn` was never actually a softer setting: CI runs `cargo clippy … -D
# warnings`, which promotes it to a hard error anyway — that is how adopting this lint turned main
# red on every platform for a day without the level in this file ever saying `deny`. A level that
# lies about its own severity is worse than a strict one, so this now states what CI already does,
# and the exemptions are written down per file instead of hiding in a 689-warning wall nobody reads.
#
# THE EXEMPTIONS. Fourteen GPU/FFI backend files carry `#![allow(unsafe_op_in_unsafe_fn)]` with a
# one-line reason each. They are not "not done yet" — they are where this lint stops paying:
# their bodies are ash/CUDA/AMF/libav calls almost line for line (measured: 64% of the sites are a
# single third-party FFI call, and of the 44 `unsafe fn`s in them only 4 have a body containing no
# unsafe operation at all). Narrowing them means one `unsafe {}` per line plus, since pf-encode also
# denies `clippy::undocumented_unsafe_blocks`, one hand-written SAFETY comment per line that could
# only ever restate "an ash call on a live device" — the precise noise that made `unsafe` stop
# meaning anything here before (see the header of `pf-win-display/src/win_display.rs`).
#
# Everything else in the workspace is at zero and enforced. Removing one of those allows, file by
# file, is real work with a real payoff; blanket-narrowing all fourteen is not. Prefer DELETING an
# `unsafe fn` marker over wrapping its body: keep the marker only where a caller can actually break
# something (a raw pointer, a borrowed HANDLE, a GPU object that must not be in flight).
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[profile.release]
opt-level = 3
lto = "thin"
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 unom - Enrico Bühler
Copyright 2026 unom
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 unom - Enrico Bühler
Copyright (c) 2026 unom
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+31 -85
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="assets/punktfunk-logo.svg" alt="Punktfunk" width="320" />
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
</p>
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
@@ -18,7 +18,7 @@ access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
[SECURITY.md](SECURITY.md). Please don't open a public issue.
Punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
@@ -43,13 +43,8 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on-box,
~1.3 ms cross-machine on a LAN. (On Linux AMD/Intel, Vulkan Video for HEVC and AV1 with VAAPI for
H.264 and as the fallback; a GPU-less software H.264 encoder exists as a last resort.)
- **A library that fills itself.** Steam and non-Steam titles show up as a grid on every client, and
plugins add their own sources — ROM Manager (your ROM collection, matched to installed emulators),
Playnite, VirtualHere. Install them from the console's **Plugins** page or with
`punktfunk-host plugins add`. See
[Plugins](https://docs.punktfunk.unom.io/docs/plugins).
~1.3 ms cross-machine on a LAN. (AMD/Intel encode via VAAPI, and a GPU-less software H.264
encoder exists as a fallback.)
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
@@ -63,12 +58,12 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (Windows 11 22H2+, x64) | ✅ Beta — shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). Hardware decode and HDR10 present validated on glass on NVIDIA and Intel, including HDR pass-through on the Intel D3D11VA path |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, game library, virtual-display presets, plugin store, GPU selection, performance capture graphs, live host logs, host updates |
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
@@ -84,80 +79,38 @@ mid-stream mode renegotiation and a wall-clock skew handshake so latency stays v
Both run from **one process**: bare `punktfunk-host serve` is the **secure native-only default**
(`punktfunk/1` + the management API/web console), and `serve --gamestream` additionally enables the
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
weaknesses). The host is managed through a REST API and web console. The **host** builds against
FFmpeg 7 or 8; the **clients** link no FFmpeg at all — they decode natively (Vulkan Video, DXVA,
VAAPI, VideoToolbox, MediaCodec, openh264 + rav1d).
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
What works where: **[the support matrix](https://docs.punktfunk.unom.io/docs/support-matrix)** ·
where it's heading: **[the roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
## Install the host
Pick your platform and install from its package registry — the per-platform guide covers adding the
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; on
SteamOS the host is built on-device by a script instead, and a Windows host ships as a signed
installer (all-vendor: NVIDIA, AMD, Intel).
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
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 / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
| **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) |
| **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).
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
installed first:
```sh
mkdir -p ~/.config/punktfunk
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
# (on Bazzite take host.env.bazzite instead)
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
systemctl --user enable --now punktfunk-host # the streaming host
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
```
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
line before the replacement — the install guide has the snippet). Then open
`https://<host-ip>:47992` and pair.
How the virtual display and input are wired up depends on your desktop — see
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
[Sway](https://docs.punktfunk.unom.io/docs/sway).
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
nothing to run by hand — open the web console and pair. Use
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
`setup.exe` over the old install; uninstall from Add/Remove Programs.
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
The console's **Host** page also shows when a newer host is out, along with the exact command for
how *this* box was installed (or a one-click **Update now** on Windows) — see
[Updating the host](https://docs.punktfunk.unom.io/docs/updating). To remove it again, or to go back
to an earlier version, see [Uninstalling](https://docs.punktfunk.unom.io/docs/uninstall) and
[Release Channels](https://docs.punktfunk.unom.io/docs/channels#pin-a-version-or-roll-back).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
add `--gamestream` on a trusted LAN if you also want stock Moonlight clients), then pair from the web
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
## Connect a client
| Streaming to… | Use |
|---|---|
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
| Linux desktop / laptop | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
| Steam Deck | The **Decky plugin** in Gaming Mode — it launches the client for you ([Steam Deck](https://docs.punktfunk.unom.io/docs/steam-deck)); in Desktop Mode, the Flatpak directly |
| Linux desktop / laptop, Steam Deck | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
| Android phone or TV | The **Android app** (`clients/android`) |
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
| Scripts, automation, another launcher | **`punktfunk`** — the headless CLI shipped in the Linux client packages (`punktfunk pair`, `punktfunk hosts list --json`, `punktfunk launch <host>`) |
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
Each client discovers hosts on the network automatically and does a one-time
@@ -169,7 +122,7 @@ Each client discovers hosts on the network automatically and does a one-time
For development, or as an install fallback where no package is available:
```sh
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, the `punktfunk` CLI, probe (Linux & macOS)
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
cargo test --workspace # unit + loopback + proptest + C ABI harness
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
@@ -189,13 +142,10 @@ and the [docs site](https://docs.punktfunk.unom.io).
crates/
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
pf-client-core/ shared client plumbing (Linux + Windows): session pump · native decode ladder · audio · SDL3 gamepads · trust · discovery
pf-client-core/ shared client plumbing (Linux + Windows): session pump · FFmpeg decode · audio · SDL3 gamepads · trust · discovery
pf-presenter/ Vulkan session presenter: SDL3 window · ash swapchain · frame present · input capture
pf-console-ui/ Skia console UI for the session client: gamepad shell · stats OSD · pairing · on-screen keyboard
pf-bitstream/ H.264 / H.265 / AV1 bitstream parsing + per-AU decode plans — the one parser every native rung submits from
pf-vkdecode/ native Vulkan Video decode (H.264 / H.265 / AV1) on the presenter's own device
pf-dxvadec/ native DXVA buffer layouts + AuPlan → picparams conversion (the Windows D3D11VA rung)
pf-vaadec/ native libva buffer layouts + AuPlan → picparams conversion (the Linux VAAPI rung)
pf-ffvk/ FFmpeg Vulkan hwcontext bindings (AVVkFrame) for Vulkan Video decode on the presenter's device
pf-driver-proto/ host ↔ pf-vdisplay driver contract: control IOCTLs + IDD-push frame transport (no_std)
punktfunk-tray/ host tray icon (Windows notification area / Linux StatusNotifierItem)
clients/
@@ -204,14 +154,11 @@ clients/
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
cli/ punktfunk, the headless client CLI — pair · hosts · wake · library · launch · punktfunk:// links
probe/ headless reference / measurement client for punktfunk/1
decky/ Steam Deck Decky plugin
web/ web console (TanStack) over the management API — status · devices · pairing · library · displays · plugins · GPUs · performance · logs · updates
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
sdk/ `@punktfunk/host` — TypeScript management-API client + event stream (Effect)
plugin-kit/ `@punktfunk/plugin-kit` — the plugin authoring kit (bun / TypeScript)
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite sysext + bootc · Windows installer + drivers · winget · Nix · gamescope
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
include/punktfunk_core.h cbindgen-generated C header (checked in)
tools/ latency-probe · loss-harness (measurement)
@@ -248,16 +195,15 @@ additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
### Third-party components
Punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows **host** build also
bundles FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
notice ship in the installed `licenses/` folder). The **clients** bundle no FFmpeg — they link
none.
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
notice ship in the installed `licenses/` folder).
### Trademarks
Punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
NVIDIA, Microsoft, Sony, Valve, or the Moonlight project. "GameStream", "Moonlight", "Xbox",
"DualSense", "DualShock", and "PlayStation" are trademarks of their respective owners and are used
here only to describe interoperability.
+3 -44
View File
@@ -1,17 +1,8 @@
# Security Policy
Punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
machine, so we take security reports seriously and appreciate responsible disclosure.
## Supported versions
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
we don't backport to older minor versions, so the supported versions are the latest stable release
and the current canary build. If you're on an older build, please check that the issue still
reproduces on the latest stable before reporting it. See
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
## Reporting a vulnerability
**Please report security issues privately by email to security@punktfunk.com.**
@@ -23,7 +14,7 @@ exposes other users before a fix exists.
The more of this you can give us, the faster we can act:
- The component and version (e.g. `punktfunk-host 0.22.3`, Windows or Linux, which client).
- The component and version (e.g. `punktfunk-host 0.9.0`, Windows or Linux, which client).
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
admin, a paired client, …).
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
@@ -65,38 +56,6 @@ https://docs.punktfunk.unom.io/docs/security):
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
## Verifying what you downloaded
Every distribution path is authenticated. Nothing below needs an account or a network round trip to
us beyond the download itself.
- **Release-page downloads** (DMG, MSIX, setup.exe, APK, decky zip, .deb/.rpm) each ship a
`<file>.sha256` next to them. In your download directory:
`sha256sum -c punktfunk-1.2.3.dmg.sha256` (macOS: `shasum -a 256 -c …`).
- **RPMs** from the dnf repo are OpenPGP-signed with `packages@unom.io` (`AF245C506F4E4763`); the
repo file in [`packaging/rpm/README.md`](packaging/rpm/README.md) sets `gpgcheck=1`, so dnf
checks every package for you. `rpmkeys --checksig` on a downloaded RPM verifies it by hand.
- **The Bazzite sysext feed** carries a detached signature over its `SHA256SUMS`, from that same
key. `punktfunk-sysext` verifies it before installing and refuses a feed it cannot verify — the
public key is baked into the script rather than fetched from the feed.
- **Windows installers and MSIX packages** are Authenticode-signed; a release build that cannot
reach its code-signing certificate fails to build rather than falling back to a self-signed one.
Check with `Get-AuthenticodeSignature punktfunk-host-setup-1.2.3.exe`.
- **The Windows drivers** (virtual display, virtual gamepads) are signed with a stable self-signed
certificate, `CN=punktfunk-driver`
(SHA-1 `4B8493E7CD565758D335F8F4F05C5A7261A13E02`), also published in
[`packaging/windows/README.md`](packaging/windows/README.md). The installer has to add it to the
machine's trusted roots for a self-signed driver to install at all, so — unlike the cases above —
this signature does **not** authenticate the download: it gives the drivers a stable publisher
identity you can compare against the published fingerprint, and it is removed again on uninstall.
Verify with `Get-AuthenticodeSignature` on the installed `pf_vdisplay.dll`, or list what is
trusted with `Get-ChildItem Cert:\LocalMachine\Root | ? Subject -like '*punktfunk*'`.
A checksum on its own only tells you the download wasn't corrupted in transit — it says nothing
about who produced the file, since anyone able to replace an artifact can replace its checksum.
Where that distinction matters (the update feeds, the package repos), the checksums are covered by
a signature. If a signature check fails, please don't work around it; report it.
## Safe harbor
We consider good-faith security research that follows this policy to be authorized, and we won't
@@ -107,4 +66,4 @@ pursue legal action against researchers who:
- give us reasonable time to remediate before public disclosure,
- don't exfiltrate more data than needed to demonstrate the issue.
Thank you for helping keep Punktfunk and its users safe.
Thank you for helping keep punktfunk and its users safe.
+1045 -1302
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
THIRD-PARTY SOFTWARE NOTICES
============================================================================
Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.
The binaries it ships statically/dynamically link the third-party Rust crates below.
Each is distributed under its own permissive license; full texts follow.
Generated by `cargo about generate about.hbs` (see about.toml) — do not edit by hand.
+12 -26
View File
@@ -4,22 +4,10 @@
# cargo about generate about.hbs > THIRD-PARTY-NOTICES.txt # (or use scripts/gen-third-party-notices.sh)
#
# `accepted` is the allow-list of SPDX licenses permitted in the dependency tree. CI fails if a crate
# carries anything not listed here — the regression guard against a copyleft dependency silently
# entering the linked set. All entries
# carries anything not listed here — which is exactly the regression guard we want against a copyleft
# dependency silently entering the linked set. All entries
# below are permissive / attribution-only; deliberately NO GPL/LGPL/AGPL/MPL-link/SSPL/EPL.
#
# ⚠ KNOW THE LIMIT OF THIS GATE. cargo-about walks the CARGO graph, so it sees CRATES. A native
# library linked through a permissively-licensed `-sys` crate is INVISIBLE to it, licence and all.
# FFmpeg is precisely that shape: `ffmpeg-sys-next` is WTFPL and passes cleanly, while the LGPL
# libavcodec/libavutil/swscale it link-imports — and which the Windows host installer bundles as
# DLLs — never appear in the harvest at all. This gate did not catch FFmpeg entering the tree and
# would not catch the next such library. Copyleft arriving as C behind a -sys crate is a REVIEW
# question, not a CI one; the LGPL obligations we do carry are discharged by hand (the notice files
# and the replaceable-DLL linkage, see packaging/windows/punktfunk-host.iss).
#
# Since M10 this is a HOST-only concern: the client links no FFmpeg, so for every client artifact
# the crate graph and the linked set finally coincide and the gate means what it appears to mean.
#
# The dependency-free fallback is scripts/gen-third-party-notices.py (reads the cargo registry cache),
# which is what produced the committed baseline when cargo-about is unavailable offline.
@@ -49,15 +37,13 @@ accepted = [
ignore-build-dependencies = true
ignore-dev-dependencies = true
# Per-crate license-acceptance additions (cargo-about ≥0.6 syntax; the old `[crate.clarify]`
# license-only form fails to deserialize under cargo-about 0.9, which now wants checksummed file
# clarifications — per-crate `accepted` extensions express the same intent without checksums).
#
# r-efi is tri-licensed with an LGPL-2.1-or-later arm; cargo-about resolves OR-expressions to an
# accepted arm on its own (MIT/Apache-2.0 are globally accepted), so it needs no entry. (It is
# also UEFI-target-gated out of every shipped build.)
#
# ring's license is an AND of permissive terms including the OpenSSL license; accept the
# OpenSSL/ISC parts for this crate only, not globally.
[ring]
accepted = ["OpenSSL", "ISC"]
# r-efi offers an LGPL-2.1-or-later arm but is tri-licensed; take a permissive arm. (It is also
# UEFI-target-gated out of every shipped build.)
[r-efi.clarify]
license = "MIT OR Apache-2.0"
[ring.clarify]
license = "MIT AND ISC AND OpenSSL"
[aws-lc-sys.clarify]
license = "ISC AND Apache-2.0 AND MIT AND BSD-3-Clause AND OpenSSL"
+156 -3008
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
the Bazzite source repository (repo_content/Bazzite.svg).
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
Licensed under the Apache License, Version 2.0,
https://www.apache.org/licenses/LICENSE-2.0.
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
gradient and decorative overlays were dropped, and the path was translated and scaled
into a 24x24 box with a monochrome fill (fill="currentColor").
Brand icons are trademarks of their respective owners and are used for identification
purposes only; their use does not imply endorsement.
@@ -1,17 +0,0 @@
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
assets/os-icons/) are from Font Awesome Free.
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
The icons are redistributed here as monochrome SVG path data with no
modifications beyond color normalization (fill="currentColor").
Per the Font Awesome Free license (https://fontawesome.com/license/free):
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Attribution is required by MIT, SIL OFL, and CC BY licenses."
Brand icons are trademarks of their respective owners and are used for
identification purposes only; their use does not imply endorsement.
-11
View File
@@ -1,11 +0,0 @@
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara in assets/os-icons/)
are from Simple Icons
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
required; this notice is provided for provenance.
Brand icons are trademarks of their respective owners and are used for
identification purposes only; their use does not imply endorsement. See
https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md.
-52
View File
@@ -1,52 +0,0 @@
# OS icon masters
The canonical OS/distro brand marks every client derives its host-card OS icon from
(web console inline SVGs, GTK symbolic icons, Windows PNGs, Apple template imagesets,
Android `ImageVector`s). One file per **icon token** of the host's OS-identity chain
(see `crates/punktfunk-host/src/osinfo.rs` and `crates/pf-client-core/src/os.rs`):
| token | mark | source |
|---|---|---|
| `windows` | Windows (the current four-pane mark, no perspective skew) | own geometry |
| `apple` | Apple (also `macos` via alias) | Font Awesome Free brands (CC BY 4.0) |
| `linux` | Tux | Font Awesome Free brands (CC BY 4.0) |
| `steam` | Steam (also `steamos` via alias) | Font Awesome Free brands (CC BY 4.0) |
| `ubuntu` | Ubuntu | Font Awesome Free brands (CC BY 4.0) |
| `fedora` | Fedora | Font Awesome Free brands (CC BY 4.0) |
| `opensuse` | SUSE | Font Awesome Free brands (CC BY 4.0) |
| `arch` | Arch Linux | Simple Icons (CC0 1.0) |
| `nixos` | NixOS | Simple Icons (CC0 1.0) |
| `debian` | Debian | Simple Icons (CC0 1.0) |
| `bazzite` | Bazzite | ublue-os/bazzite (Apache-2.0) |
| `cachyos` | CachyOS | Simple Icons (CC0 1.0) |
| `nobara` | Nobara | Simple Icons (CC0 1.0, slug `nobaralinux`) |
The last three are **distro leaves, not families**: a chain walks most-specific-first, so
`linux/fedora/bazzite` would otherwise draw the Fedora mark. They earn their own art because
"a Bazzite box" and "a Fedora box" are different machines to the person reading the card, and
they are what this project's hosts actually run. Every other distro with no file here (Pop!_OS,
Mint, …) still degrades to its family's mark and finally to Tux — that fallback is the design,
not a gap.
Windows is the one mark drawn here rather than sourced: every icon set that ships a "Windows"
brand glyph still carries the **Windows 8/10 flag with the perspective skew**, which reads as
dated next to the flat four-pane mark Microsoft has used since Windows 11. Four equal squares
(11.377 + 1.246 gap) is the current proportion.
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Because
those viewBoxes are not all square, a client must letterbox rather than stretch — see the aspect
note in `clients/android/.../components/OsIcons.kt`.
## Regenerating the per-client derivatives
`bash scripts/gen-os-icons.sh [token ...]` turns a master into the three baked forms (GTK
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
that inline it (web console, Decky plugin, Android). Adding a **new** token also means adding it
to each client's shipped-token list — the script prints that checklist too.
## Licensing
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
are used here nominatively — to *identify* the operating system a host runs, the standard
practice in this ecosystem — and imply no affiliation or endorsement.
-2
View File
@@ -1,2 +0,0 @@
<!-- apple — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaApple. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>

Before

Width:  |  Height:  |  Size: 640 B

-2
View File
@@ -1,2 +0,0 @@
<!-- arch — from Simple Icons (CC0 1.0), via react-icons SiArchlinux. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091"/></svg>

Before

Width:  |  Height:  |  Size: 841 B

-2
View File
@@ -1,2 +0,0 @@
<!-- bazzite — the Bazzite "b", from ublue-os/bazzite repo_content/Bazzite.svg (Apache-2.0), lifted out of the badge and normalized to a 24x24 box. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z"/></svg>

Before

Width:  |  Height:  |  Size: 523 B

-2
View File
@@ -1,2 +0,0 @@
<!-- cachyos — from Simple Icons (CC0 1.0). See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81"/></svg>

Before

Width:  |  Height:  |  Size: 438 B

-2
View File
@@ -1,2 +0,0 @@
<!-- debian — from Simple Icons (CC0 1.0), via react-icons SiDebian. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83"/></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

-2
View File
@@ -1,2 +0,0 @@
<!-- fedora — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaFedora. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path d="M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z"/></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

-2
View File
@@ -1,2 +0,0 @@
<!-- linux — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaLinux. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path d="M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z"/></svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

-2
View File
@@ -1,2 +0,0 @@
<!-- nixos — from Simple Icons (CC0 1.0), via react-icons SiNixos. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z"/></svg>

Before

Width:  |  Height:  |  Size: 880 B

-2
View File
@@ -1,2 +0,0 @@
<!-- nobara — from Simple Icons (CC0 1.0), slug "nobaralinux". See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z"/></svg>

Before

Width:  |  Height:  |  Size: 681 B

-2
View File
@@ -1,2 +0,0 @@
<!-- opensuse — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSuse. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="currentColor"><path d="M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-2
View File
@@ -1,2 +0,0 @@
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSteam. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>

Before

Width:  |  Height:  |  Size: 937 B

-2
View File
@@ -1,2 +0,0 @@
<!-- ubuntu — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaUbuntu. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-2
View File
@@ -1,2 +0,0 @@
<!-- windows — the modern (Windows 11) four-pane mark: four equal squares, no perspective skew. Own geometry, see README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z"/></svg>

Before

Width:  |  Height:  |  Size: 323 B

-67
View File
@@ -1,67 +0,0 @@
# Android CI builder: JDK 21 + Android SDK/NDK/CMake + pinned Rust with the three shipping
# Android targets + cargo-ndk + sccache. Everything android.yml used to download per run
# (~3 GB of NDK + SDK packages from Google, plus a from-source cargo-ndk build) is baked
# here instead; the image is content-keyed and rebuilt only when the ci/ tree changes
# (docker.yml `builders`).
#
# docker build -f ci/android-ci.Dockerfile -t punktfunk-android-ci ci
#
# Version pins mirror what android.yml installed via sdkmanager: AGP 9.3 wants JDK 1721;
# cmake;3.22.1 because kit/build.gradle.kts prepends $ANDROID_SDK/cmake/3.22.1/bin to PATH
# for cargo-ndk's audiopus_sys (libopus) CMake build; platforms;android-37 is deliberately
# absent (AGP auto-downloads it if a build ever needs it — same note as the old workflow).
FROM ubuntu:26.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git unzip zip python3 openjdk-21-jdk-headless \
build-essential pkg-config \
&& rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
# Android SDK: cmdline-tools must land under cmdline-tools/latest for sdkmanager to
# find its own root.
ENV ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk
ARG CMDLINE_TOOLS=13114758
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
&& curl -fsSL -o /tmp/clt.zip "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" \
&& unzip -q /tmp/clt.zip -d "$ANDROID_HOME/cmdline-tools" \
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
&& rm /tmp/clt.zip
ENV PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH
RUN yes | sdkmanager --licenses >/dev/null \
&& sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" \
"ndk;30.0.14904198" "cmake;3.22.1" \
&& chmod -R a+rX "$ANDROID_HOME"
# Toolchain shared across CI users (jobs may run as different uids) — same shape as
# rust-ci.Dockerfile, plus the Android cross targets and cargo-ndk. The registry/git
# download caches are stripped after the cargo-ndk install: jobs restore those from the
# shared actions cache, and baking them would only bloat every pull.
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
&& cargo install cargo-ndk --locked \
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo ndk --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
# the job container — no node, no checkout (exit 127; same lesson flatpak.yml documents for
# fedora:43). A separate trailing layer on purpose: appending here keeps the fat SDK/NDK
# layers above cache-valid instead of invalidating the whole build.
RUN apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& node --version
-66
View File
@@ -1,66 +0,0 @@
# Arch CI builder: base-devel + every dependency arch.yml's two makepkg legs used to
# pacman-install per run (~1 GB of mirror traffic each time) + bun + sccache + nodejs
# (JS actions exec node INSIDE the job container — the same lesson as android-ci).
# Content-keyed and rebuilt only when the ci/ tree changes (docker.yml `builders`).
#
# docker build -f ci/arch-ci.Dockerfile -t punktfunk-arch-ci ci
#
# ROLLING-RELEASE TRADEOFF, on purpose: packages now build against the Arch snapshot
# from the last image rebuild instead of a fresh -Syu per run. That is the same staleness
# the gamescope cache already embraces ("a stale binary against newer system libs is the
# same risk the distro's own package carries between rebuilds"), and any ci/ edit — or
# bumping the date in this line (refreshed: 2026-08-08) — re-keys and re-snapshots it.
#
# ⚠ That staleness has a sharp edge, and 2026-08-08 is why the date above moved: this snapshot is
# what decides which FFmpeg the HOST links, and arch.yml deliberately runs no -Syu, so the builder
# stayed frozen on ffmpeg 8 (libavcodec 62) even after Arch shipped 2:9.0-5 (libavcodec 63) to
# every user. A canary built from the old snapshot therefore CANNOT satisfy the soname dep that
# packaging/arch/PKGBUILD now derives from the link (libavcodec.so=62-64 against a box that has
# 63-64), so it would simply refuse to install rather than start. Re-keying this image is the step
# that makes the ffmpeg-9 bump actually reach the package — a Cargo.toml bump alone does nothing
# here. Whenever Arch moves to an FFmpeg major, bump the date in the same commit.
#
# ⚠ AND KNOW WHY THAT WAS NOT ENOUGH: bumping this date only helps once docker.yml has actually
# republished the image, and nothing sequences the two workflows. v0.25.0 was tagged four minutes
# after the ffmpeg-9 merge, so the release build still pulled the FFmpeg-8 `:latest` and published
# a punktfunk-host that no up-to-date Arch box could install — which blocks the user's ENTIRE
# `pacman -Syu`, not just our package. arch.yml therefore no longer trusts this image on that one
# axis: it compares the builder's libav sonames against the repos before building (and `-Syu`s
# itself if they differ), and refuses to publish anything a pristine-db `pacman -U --print` says
# is unsatisfiable. This file staying current is still the CHEAP path — those guards are the
# backstop, not the plan.
FROM docker.io/library/archlinux:base-devel
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
# deps (second list) — both copied verbatim from what arch.yml installed in-job, where
# they now no-op as `--needed` guards.
# vulkan-headers rides the first list only because arch.yml's copy does; the package it actually
# serves is the gamescope companion (packaging/gamescope/PKGBUILD makedepends). punktfunk itself
# needs no system Vulkan headers — pyrowave-sys bindgens its own vendored copy and ash dlopens the
# loader — but arch.yml builds gamescope with `makepkg -d`, so an absent makedepend would not be
# reported as a missing dependency, only as a compile failure. Keep it.
RUN pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive \
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
hwdata luajit seatd sdl2-compat vulkan-icd-loader \
xcb-util-errors xcb-util-wm xorg-xwayland \
meson glm wayland-protocols benchmark libxcursor \
&& pacman -Scc --noconfirm
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
# as their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so
# bootstrap the official binary — once, here, instead of per run.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
&& rm -rf /root/.bun \
&& bun --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
+2 -12
View File
@@ -27,10 +27,8 @@ RUN dnf -y install \
mesa-libGL-devel mesa-libgbm-devel \
# punktfunk-client link deps (GTK4 shell + SDL3 gamepads)
gtk4-devel libadwaita-devel SDL3-devel \
# No vulkan-headers: nothing in the workspace compiles against the system Vulkan headers
# (pyrowave-sys bindgens its own vendored copy; host and client both reach Vulkan through
# ash, which dlopens the loader), and packaging/rpm/punktfunk.spec BuildRequires none.
# rpm.yml's HDR gamescope leg needs them and pulls them with `dnf builddep gamescope`.
# pf-ffvk bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>
vulkan-headers \
&& dnf clean all
# bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
@@ -68,11 +66,3 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
-45
View File
@@ -1,45 +0,0 @@
#!/bin/bash
# Host-side C compiler wrapper for the aarch64 cross image (ci/rust-ci-arm64cross.Dockerfile).
#
# Why this exists: ffmpeg-sys-next's build script compiles a probe it intends to RUN — it
# executes the binary to read the libav* version macros — so it forces `.target(HOST)` with
# the comment "don't cross-compile this", but still hands that host compile the TARGET's
# pkg-config include paths. `-I/usr/include/aarch64-linux-gnu` then shadows the host's own
# multiarch libc headers and the x86 compiler dies inside bits/math-vector.h on NEON/SVE
# types it has never heard of.
#
# Prepending the host's multiarch dir does NOT fix it: GCC drops a `-I` that duplicates a
# directory already on its system include path (keeping it in the original, later position),
# so the arm64 dir stays in front. The reliable fix is to remove the target include dirs from
# the host compile entirely — the probe only wants FFmpeg's version macros, and the amd64
# libav*-dev headers are installed and on the default search path, at the same version (both
# come from this Ubuntu release).
#
# Scope: only ever invoked as CC for the HOST triple (CC_x86_64_unknown_linux_gnu). Target
# compiles go to aarch64-linux-gnu-gcc and never pass through here.
set -euo pipefail
declare -a out=()
while (($#)); do
case "$1" in
# `-I dir` as two arguments — the form cc's Command building and ffmpeg-sys both emit.
-I)
if [[ ${2-} == *aarch64-linux-gnu* ]]; then
shift 2
continue
fi
out+=("$1" "${2-}")
shift 2
;;
# `-Idir` glued into one argument.
-I*aarch64-linux-gnu*)
shift
;;
*)
out+=("$1")
shift
;;
esac
done
exec /usr/bin/cc "${out[@]}"
-82
View File
@@ -1,82 +0,0 @@
# Cross-compiling CI builder: amd64 host toolchain + an arm64 multiarch sysroot, for the
# aarch64 Linux CLIENT artifacts (punktfunk-client + punktfunk-session).
#
# docker build -f ci/rust-ci-arm64cross.Dockerfile -t punktfunk-rust-ci-arm64cross .
#
# Derived from punktfunk-rust-ci so the Rust toolchain, clang, and CMake are byte-identical
# to the amd64 legs — this image only adds the target side. Kept as a SEPARATE image rather
# than folded into the base because the :arm64 dev libs are ~1 GB that every other CI job
# would otherwise pull for nothing.
#
# Client only: the Linux HOST stays amd64 (its encode stack is NVENC/QSV/AMF), so none of the
# host's CUDA/GBM link deps are mirrored here.
#
# Ubuntu splits archives by architecture: amd64 lives on archive.ubuntu.com, every port
# (arm64 included) on ports.ubuntu.com. Both stanzas therefore have to be pinned with an
# explicit `Architectures:` or apt tries to fetch arm64 from the amd64 mirror and 404s.
#
# Built from the REPO ROOT context (not ci/) — see the rust-toolchain.toml copy below.
FROM 192.168.1.58:5010/punktfunk-rust-ci:latest
ENV DEBIAN_FRONTEND=noninteractive
# 1. Pin the stock sources to amd64, add ports.ubuntu.com for arm64.
RUN sed -i 's|^Types: deb$|Types: deb\nArchitectures: amd64|' /etc/apt/sources.list.d/ubuntu.sources \
&& . /etc/os-release \
&& printf 'Types: deb\nArchitectures: arm64\nURIs: http://ports.ubuntu.com/ubuntu-ports/\nSuites: %s %s-updates %s-backports %s-security\nComponents: main universe restricted multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' \
"$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" \
> /etc/apt/sources.list.d/ubuntu-ports-arm64.sources \
&& dpkg --add-architecture arm64
# 2. The cross toolchain + every arm64 dev lib the client links. Mirrors the client half of
# rust-ci.Dockerfile's list (FFmpeg, PipeWire, Opus, SDL3, GTK4/libadwaita, xkbcommon). No
# Vulkan dev package: nothing compiles or links against Vulkan — ash dlopens the loader, and
# pyrowave-sys bindgens its own vendored headers.
RUN apt-get update && apt-get install -y --no-install-recommends \
crossbuild-essential-arm64 \
libavcodec-dev:arm64 libavformat-dev:arm64 libavutil-dev:arm64 libswscale-dev:arm64 \
libavfilter-dev:arm64 libavdevice-dev:arm64 \
libpipewire-0.3-dev:arm64 libopus-dev:arm64 \
libsdl3-dev:arm64 libgtk-4-dev:arm64 libadwaita-1-dev:arm64 \
libwayland-dev:arm64 libxkbcommon-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
# 3. The Rust target — installed against the toolchain the WORKSPACE pins, not the image's
# default. The base image bakes whatever `stable` was at its build time, while every build
# in the repo switches to the exact channel in rust-toolchain.toml; adding the target to
# the default toolchain instead leaves the pinned one without an aarch64 std, and the build
# dies on `can't find crate for core` a few hundred crates in. Running rustup from a
# directory that contains the pin file resolves the right toolchain (and pre-downloads it,
# which every workspace job would otherwise pay for on first use).
COPY rust-toolchain.toml /opt/pf-toolchain/
WORKDIR /opt/pf-toolchain
RUN rustup target add aarch64-unknown-linux-gnu && rustup show
WORKDIR /
# 4. Cross wiring. Everything in this image is a cross build, so the plain (un-suffixed)
# variables are safe and cover the crates that roll their own pkg-config/bindgen calls
# instead of going through the target-scoped lookups.
# * PKG_CONFIG uses Debian's multiarch wrapper, which resolves the arm64 .pc files and
# rewrites -I/-L into the sysroot without per-crate cooperation.
# * BINDGEN_EXTRA_CLANG_ARGS: clang defaults to the host triple, so bindgen would parse
# arm64 headers with amd64 type layouts (silently wrong, not a build error) — the
# explicit --target plus the multiarch include dir is what keeps the layouts honest.
# * CC_x86_64_unknown_linux_gnu routes HOST-targeted compiles through a wrapper that
# strips the arm64 include dirs — see ci/pf-host-cc for the ffmpeg-sys-next probe it
# exists for.
COPY ci/pf-host-cc /usr/local/bin/pf-host-cc
RUN chmod 0755 /usr/local/bin/pf-host-cc
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \
CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc \
PKG_CONFIG=aarch64-linux-gnu-pkg-config \
PKG_CONFIG_ALLOW_CROSS=1 \
BINDGEN_EXTRA_CLANG_ARGS="--target=aarch64-unknown-linux-gnu -I/usr/include/aarch64-linux-gnu"
# Fail the BUILD, not some later CI job, if the wrapper or a sysroot .pc is missing.
RUN command -v aarch64-linux-gnu-pkg-config \
&& aarch64-linux-gnu-pkg-config --cflags libavcodec sdl3 gtk4 libpipewire-0.3 \
&& aarch64-linux-gnu-gcc -dumpmachine | grep -q aarch64
-16
View File
@@ -45,14 +45,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
#
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
# — and this .deb is the one package with NO exposure to the soname break that motivated the bump: it
# BUNDLES these libs into /usr/lib/punktfunk-host behind an rpath and strips the libav* sonames from
# its Depends, so nothing the user's apt does can move them underneath it. Bumping this tag would
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
ARG FFMPEG_TAG=n8.0
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
@@ -91,11 +83,3 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
--component rustfmt,clippy \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo clippy --version && cargo fmt --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
+3 -14
View File
@@ -13,9 +13,7 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
# ffmpeg-next 9, built against whatever libav* 26.04 ships (FFmpeg 8 / libavcodec 62 today).
# The crate major is a CEILING — ffmpeg-sys-next 9 spans libavcodec 56..63 — so this image does
# not need to move in lockstep with Arch's FFmpeg 9; it just links what the distro has.
# ffmpeg-next 8 (system FFmpeg 8 / libavcodec 62 on 26.04)
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev \
libavdevice-dev \
# capture / audio / display stacks (+xkbcommon for the wlr input backend)
@@ -24,9 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libgl-dev libegl-dev libgbm-dev \
# punktfunk-client-linux (GTK4/libadwaita shell, SDL3 gamepads)
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
# No libvulkan-dev: nothing in the workspace compiles or links against Vulkan (pyrowave-sys
# bindgens its own vendored headers, and both host and client reach Vulkan through ash, which
# dlopens the loader), so neither the build nor deb.yml's dpkg-shlibdeps ever asks for it.
# pf-ffvk (bindgen over libavutil/hwcontext_vulkan.h needs <vulkan/vulkan.h>)
libvulkan-dev \
&& rm -rf /var/lib/apt/lists/*
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
@@ -53,11 +50,3 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
--component rustfmt,clippy \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo clippy --version && cargo fmt --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
@@ -90,21 +90,6 @@
<!-- TV launcher entry. -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- punktfunk:// deep links (design/client-deep-links.md §2): an external tool, an OS
shortcut or a wiki page opens a stream on a host this device already trusts. The
URL carries only REFERENCES to things that exist here (a host record, a settings
profile, a library id) — never resolution/bitrate/codec values, and never a
pairing route; MainActivity's router enforces the rest. BROWSABLE is what lets a
browser hand it over (behind its own "Open Punktfunk?" prompt).
NOTE: launchMode deliberately stays `standard` and the configChanges set above is
untouched — its `keyboard` entry is what keeps an SC2 claim from killing a running
stream, and neither has anything to gain from this filter. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="punktfunk" />
</intent-filter>
</activity>
</application>
</manifest>
File diff suppressed because it is too large Load Diff
@@ -31,10 +31,8 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -44,30 +42,15 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import android.widget.Toast
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.Tab
@Composable
fun App(forceGamepadUi: Boolean = false) {
val context = LocalContext.current
val activity = context as? MainActivity
val settingsStore = remember { SettingsStore(context) }
var settings by remember { mutableStateOf(settingsStore.load()) }
// The active session (null = not streaming). It carries the settings the connect resolved,
// so the stream screen never re-reads the store behind its own connect's back.
var session by remember { mutableStateOf<ActiveSession?>(null) }
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
var tab by remember { mutableStateOf(Tab.Connect) }
// Set when a session ends because its game exited and it began as a library launch: the host
// whose library the console shell should come back to. Held HERE because the shell's own
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
// later manual Back out of the library is not undone by a stale value.
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
@@ -75,77 +58,21 @@ fun App(forceGamepadUi: Boolean = false) {
val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
// destroyed mid-stream doesn't leave a ghost that blocks every future link.
DisposableEffect(session) {
MainActivity.liveStream = session?.let { MainActivity.LiveStream(it.hostId) }
onDispose { MainActivity.liveStream = null }
}
// The same rule for the rare in-instance case (a caller that set FLAG_ACTIVITY_SINGLE_TOP, so
// the link reached `onNewIntent` on the streaming activity itself). Pointing at the host
// already being streamed is the one exception, and its right answer is to do nothing — the
// intent has already brought the app forward, which is exactly what "focus it" means here.
val pendingLink = activity?.pendingDeepLink
LaunchedEffect(pendingLink, session) {
val url = pendingLink ?: return@LaunchedEffect
val live = session ?: return@LaunchedEffect // not streaming: ConnectScreen routes it
activity.pendingDeepLink = null
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all())
val sameHost = target is HostResolution.Known && target.host.id == live.hostId
if (!sameHost) {
Toast.makeText(
context,
"Already streaming — end this session first.",
Toast.LENGTH_LONG,
).show()
}
}
// The console backdrop's colour family, published once from the live settings rather than
// threaded through every screen that draws a backdrop. Because it is read from the SAME
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
// the field behind that very row.
val palette = GamepadPalette.named(settings.uiPalette)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
) {
AnimatedContent(
targetState = session,
targetState = streamHandle != 0L,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "StreamTransition"
) { active ->
if (active != null) {
) { isStreaming ->
if (isStreaming) {
// Immersive: the stream takes the whole screen, no bottom bar.
StreamScreen(active) { reason ->
// A game launched from a library exiting is a normal finish, and the player is
// almost certainly after the next title — so send them back to that library rather
// than all the way out to host selection. The console shell's own screen state does
// not survive the stream (StreamScreen replaces it in the composition, discarding
// its `remember`s), so the intent is hoisted here and handed back on the way in.
reopenLibraryHostId =
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
active.hostId
} else {
null
}
session = null
}
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
} else if (gamepadUi) {
GamepadShell(
settings = settings,
onSettingsChange = { settings = it; settingsStore.save(it) },
onConnected = { session = it },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
reopenLibraryHostId = reopenLibraryHostId,
onReopenLibraryHandled = { reopenLibraryHostId = null },
onConnected = { streamHandle = it },
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -176,13 +103,7 @@ fun App(forceGamepadUi: Boolean = false) {
label = "TabTransition"
) { targetTab ->
when (targetTab) {
Tab.Connect -> ConnectScreen(
settings = settings,
onConnected = { session = it },
onSettingsChange = { settings = it; settingsStore.save(it) },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
)
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
Tab.Settings -> SettingsScreen(
initial = settings,
onChange = { settings = it; settingsStore.save(it) },
@@ -232,16 +153,8 @@ fun App(forceGamepadUi: Boolean = false) {
}
}
}
}
}
/**
* The console backdrop's colour family for everything under [App] — provided from the live
* settings so a change on the gamepad settings screen recolours every backdrop at once. Defaults
* to the brand violet, which is also what a preview or a test composition gets.
*/
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
/** Which console screen the gamepad shell is showing. */
private enum class GamepadScreen { Home, Settings, Library }
@@ -254,35 +167,12 @@ private enum class GamepadScreen { Home, Settings, Library }
fun GamepadShell(
settings: Settings,
onSettingsChange: (Settings) -> Unit,
onConnected: (ActiveSession) -> Unit,
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
/**
* Open this saved host's library instead of Home on the way in — set when a game launched from
* it has just exited. Null (the default) starts on Home exactly as before.
*/
reopenLibraryHostId: String? = null,
onReopenLibraryHandled: () -> Unit = {},
onConnected: (Long) -> Unit,
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
// A host that has since been forgotten simply leaves us on Home rather than failing.
LaunchedEffect(reopenLibraryHostId) {
val id = reopenLibraryHostId ?: return@LaunchedEffect
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
// either order happens to work — but this one cannot be broken by a later edit that adds a
// suspending call. A host that has since been forgotten just leaves us on Home.
KnownHostStore(context).all()
.firstOrNull { it.id == id }
?.let { libraryHost = it; screen = GamepadScreen.Library }
onReopenLibraryHandled()
}
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
@@ -304,9 +194,6 @@ fun GamepadShell(
GamepadScreen.Home -> ConnectScreen(
settings = settings,
onConnected = onConnected,
onSettingsChange = onSettingsChange,
deepLink = deepLink,
onDeepLinkHandled = onDeepLinkHandled,
gamepadUi = true,
onOpenSettings = { screen = GamepadScreen.Settings },
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
@@ -1,107 +0,0 @@
package io.unom.punktfunk
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import io.unom.punktfunk.kit.NativeBridge
/**
* Text clipboard sync for the active session (the desktop-client model, text-only v1):
* * **Device host**: a local copy (the primary-clip listener, plus one probe at start) is
* announced as a lazy offer the text crosses only when the host actually pastes (a
* `fetch:` event, answered with the clipboard's current content).
* * **Host device**: a host copy arrives as an `offer:` event and is fetched eagerly into
* the system clipboard (Android apps can't lazily materialize a paste from the network
* without a content-provider round-trip that isn't worth it here).
*
* Loop guard: text set from a host fetch is remembered ([lastFromHost]) so the resulting
* primary-clip-changed callback doesn't bounce it straight back as a new offer. Clipboard reads
* happen while the stream is foreground (Android only allows focused-app reads). The native
* events are drained on a dedicated thread and applied on the main thread; [stop] joins it.
*/
class ClipboardSync(
private val context: Context,
private val handle: Long,
) {
private val main = Handler(Looper.getMainLooper())
private val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@Volatile private var running = true
private var seq = 0
private var lastOffered: String? = null
private var lastFromHost: String? = null
private var pendingFetch = -1
private var thread: Thread? = null
private val clipListener = ClipboardManager.OnPrimaryClipChangedListener { offerLocal() }
fun start() {
NativeBridge.nativeClipControl(handle, true)
cm.addPrimaryClipChangedListener(clipListener)
thread = Thread({ pollLoop() }, "pf-clipboard").also { it.start() }
offerLocal() // whatever is already on the clipboard is pasteable host-side right away
}
fun stop() {
running = false
cm.removePrimaryClipChangedListener(clipListener)
thread?.join(600) // one poll timeout (250 ms) + slack
thread = null
}
/** Announce the current local text (if it's new and not an echo of a host copy). */
private fun offerLocal() {
if (!running) return
val text = currentClipText() ?: return
if (text == lastOffered || text == lastFromHost) return
lastOffered = text
seq += 1
NativeBridge.nativeClipOfferText(handle, seq)
}
private fun currentClipText(): String? = runCatching {
cm.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)
?.coerceToText(context)?.toString()?.takeIf { it.isNotEmpty() }
}.getOrNull()
private fun pollLoop() {
while (running) {
val ev = NativeBridge.nativeNextClip(handle) ?: continue
if (ev == "closed") return
main.post { handleEvent(ev) }
}
}
private fun handleEvent(ev: String) {
if (!running) return
val parts = ev.split(":", limit = 3)
when (parts[0]) {
"offer" -> {
val offerSeq = parts.getOrNull(1)?.toIntOrNull() ?: return
if (parts.getOrNull(2) == "1") {
pendingFetch = NativeBridge.nativeClipFetchText(handle, offerSeq)
}
}
"fetch" -> {
val req = parts.getOrNull(1)?.toIntOrNull() ?: return
val text = currentClipText()
if (text != null) {
NativeBridge.nativeClipServeText(handle, req, text)
} else {
NativeBridge.nativeClipCancel(handle, req)
}
}
"data" -> {
val xfer = parts.getOrNull(1)?.toIntOrNull() ?: return
if (xfer != pendingFetch) return // stale/unknown transfer
pendingFetch = -1
val text = parts.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: return
lastFromHost = text
runCatching { cm.setPrimaryClip(ClipData.newPlainText("Punktfunk", text)) }
}
// "state"/"cancel"/"error": nothing to drive in the text-only v1.
}
}
}
@@ -19,7 +19,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
@@ -168,9 +167,9 @@ internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onD
title = { Text("Allow local network access") },
text = {
Text(
"Android blocks Punktfunk from talking to devices on your network, so it can't " +
"Android blocks punktfunk from talking to devices on your network, so it can't " +
"find or reach any host until you allow it. If no prompt appears when you tap " +
"Allow, enable “Nearby devices” for Punktfunk in system settings.",
"Allow, enable “Nearby devices” for punktfunk in system settings.",
)
},
confirmButton = {
@@ -354,18 +353,16 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
}
/**
* Edit a saved host: name, address, port, the Wake-on-LAN MAC, and the per-host settings the record
* owns shared clipboard (a trust decision about THIS machine, so it was never really a global).
* The MAC is auto-learned from the host's mDNS advert while it's online, but this is where you can
* enter or correct it (e.g. to wake a host you've only ever reached by address). [suggestedMacs]
* prefills the field from the live advert when nothing's been learned yet. Keyed by the host so
* reopening resets the fields. Mirrors the Apple client's edit form.
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
* host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a
* host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert
* when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the
* Apple client's edit form.
*/
@Composable
internal fun EditHostDialog(
target: KnownHost,
suggestedMacs: List<String>,
profiles: List<StreamProfile>,
onSave: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
@@ -375,13 +372,6 @@ internal fun EditHostDialog(
var mac by remember(target) {
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
}
var clipboard by remember(target) { mutableStateOf(target.clipboardSync) }
// A binding whose profile was deleted reads as "Default settings" (which is what it already
// resolves to) and is cleaned off the record on the next save — never an error state.
var boundId by remember(target, profiles) {
mutableStateOf(target.profileId?.takeIf { id -> profiles.any { it.id == id } })
}
var pins by remember(target) { mutableStateOf(target.pinnedProfileIds) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit host") },
@@ -417,31 +407,6 @@ internal fun EditHostDialog(
placeholder = { Text("auto-filled when the host is seen") },
singleLine = true,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("Shared clipboard", style = MaterialTheme.typography.bodyLarge)
Text(
"Text copied here pastes on this host and vice versa",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = clipboard, onCheckedChange = { clipboard = it })
}
if (profiles.isNotEmpty()) {
HostProfileBinding(
profiles = profiles,
boundId = boundId,
onBind = { boundId = it },
pins = pins,
onTogglePin = { id ->
pins = if (id in pins) pins - id else pins + id
},
)
}
}
},
confirmButton = {
@@ -454,9 +419,6 @@ internal fun EditHostDialog(
address = address.trim(),
port = port.toIntOrNull() ?: target.port,
mac = KnownHostStore.parseMacs(mac),
clipboardSync = clipboard,
profileId = boundId,
pinnedProfileIds = pins,
),
)
},
@@ -467,103 +429,3 @@ internal fun EditHostDialog(
},
)
}
/**
* The network speed test, as a dialog: it narrates while it measures, then offers to apply the
* recommendation to the layer the tested host actually reads bitrate from see [SpeedTestTarget]
* for why that is the interesting part. The apply buttons name their destination, so the write is
* never a surprise.
*/
@Composable
internal fun SpeedTestDialog(
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val done = phase as? SpeedTestPhase.Done
AlertDialog(
// Measuring can't be cancelled mid-burst (the host is already sending), so a stray tap
// outside shouldn't look like it did something.
onDismissRequest = { if (done != null || phase is SpeedTestPhase.Failed) onDismiss() },
title = { Text("Network speed test") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(hostName, style = MaterialTheme.typography.titleMedium)
when (phase) {
SpeedTestPhase.Connecting, SpeedTestPhase.Measuring -> Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text(
if (phase == SpeedTestPhase.Connecting) {
"Connecting…"
} else {
"Measuring — the host is bursting test traffic for two seconds."
},
)
}
is SpeedTestPhase.Failed -> Text(
phase.message,
color = MaterialTheme.colorScheme.error,
)
is SpeedTestPhase.Done -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"%.0f Mbit/s measured · %.1f %% loss".format(
phase.measuredMbps,
phase.lossPct,
),
style = MaterialTheme.typography.bodyLarge,
)
Text(
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
style = MaterialTheme.typography.bodyLarge,
)
Text(
speedTestTargetNote(target),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
confirmButton = {
if (done != null) {
TextButton(onClick = { onApply(true) }) {
Text(
when (target) {
SpeedTestTarget.Global -> "Apply"
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}"
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}"
},
)
}
}
},
dismissButton = {
Row {
// The both-are-defensible case: the user picks the layer, we don't guess.
if (done != null && target is SpeedTestTarget.Ask) {
TextButton(onClick = { onApply(false) }) { Text("Set as default") }
}
TextButton(onClick = onDismiss) { Text("Close") }
}
},
)
}
/** One line saying which layer an Apply will write to, and why that one. */
private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) {
SpeedTestTarget.Global ->
"This host uses the default settings, so the bitrate goes there."
is SpeedTestTarget.Profile ->
"This host streams with “${target.profile.name}”, which sets its own bitrate — " +
"that override is what it actually reads."
is SpeedTestTarget.Ask ->
"This host streams with “${target.profile.name}”, which currently inherits the default " +
"bitrate. Setting it in the profile affects only this host's profile; setting it as " +
"the default affects everything that inherits it."
}
@@ -191,7 +191,6 @@ internal fun ConnectTakeover(
onCancel: () -> Unit,
onRetry: () -> Unit,
) {
val ink = LocalGamepadInk.current
val copy = connectCopy(phase)
val timedOut = phase is ConnectPhase.WakeTimedOut
@@ -213,7 +212,7 @@ internal fun ConnectTakeover(
Icon(
Icons.Filled.Bedtime,
contentDescription = null,
tint = ink.fg(0.9f),
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(46.dp),
)
}
@@ -222,14 +221,14 @@ internal fun ConnectTakeover(
}
Text(
copy.title,
color = ink.fg,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
textAlign = TextAlign.Center,
)
Text(
copy.subtitle,
color = ink.fg(0.65f),
color = Color.White.copy(alpha = 0.65f),
fontSize = 14.sp,
textAlign = TextAlign.Center,
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
@@ -250,7 +249,6 @@ internal fun ConnectTakeover(
*/
@Composable
private fun PulsingSpinner() {
val ink = LocalGamepadInk.current
val transition = rememberInfiniteTransition(label = "connectPulse")
val pulse by transition.animateFloat(
initialValue = 0f,
@@ -264,14 +262,14 @@ private fun PulsingSpinner() {
for (i in 0..1) {
val p = (pulse + i * 0.5f) % 1f
drawCircle(
color = ink.accent.copy(alpha = (1f - p) * 0.35f),
color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
radius = maxR * (0.42f + p * 0.58f),
style = Stroke(width = 2.dp.toPx()),
)
}
}
CircularProgressIndicator(
color = ink.fg,
color = Color.White,
strokeWidth = 3.dp,
modifier = Modifier.size(54.dp),
)
@@ -1,14 +1,11 @@
package io.unom.punktfunk
import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
@@ -56,23 +53,16 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.components.EmptyHostsState
import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.HostMenuItem
import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.discovery.HostDiscovery
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.link.LinkError
import io.unom.punktfunk.kit.link.LinkRoute
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.HostStatus
import io.unom.punktfunk.models.PendingTrust
import java.util.concurrent.atomic.AtomicBoolean
@@ -111,10 +101,7 @@ private class ConnectAttempt(val hostName: String) {
@Composable
fun ConnectScreen(
settings: Settings,
onConnected: (ActiveSession) -> Unit,
// Writes the global defaults back. Only the speed test uses it — that is the one action on this
// screen that can land in the defaults layer (design/client-settings-profiles.md §5.3).
onSettingsChange: (Settings) -> Unit = {},
onConnected: (Long) -> Unit,
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
@@ -122,11 +109,6 @@ fun ConnectScreen(
onOpenSettings: () -> Unit = {},
onOpenLibrary: (KnownHost) -> Unit = {},
navGate: Boolean = true, // false while the console home is cross-fading out
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
// link must go through all of them, not around them.
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -135,10 +117,6 @@ fun ConnectScreen(
var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
// A confirmation, as opposed to [status]'s failures — "75 Mbit/s set in “Travel”". Separate
// state because the two read completely differently: an error banner is red on purpose, and a
// successful write dressed as one is a small lie every time it appears.
var notice by remember { mutableStateOf<String?>(null) }
// A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null
// when idle or when the request-access / wake flows own the screen instead.
var attempt by remember { mutableStateOf<ConnectAttempt?>(null) }
@@ -171,7 +149,8 @@ fun ConnectScreen(
lnpPrompt = false
// The browse started while blocked (its sockets failed or received nothing) — restart it
// now that the grant makes them work.
discovery.restart()
discovery.stop()
discovery.start()
} else {
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
}
@@ -193,27 +172,12 @@ fun ConnectScreen(
// or otherwise notify the app — this observer is what turns the grant into a live discovery.
DisposableEffect(Unit) {
val lifecycle = (context as? LifecycleOwner)?.lifecycle
// Whether we've actually been away. ON_RESUME also fires on first entry, right after the
// effect below starts the browse — restarting it there would be pure churn.
var wasPaused = false
val obs = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> wasPaused = true
Lifecycle.Event.ON_RESUME -> {
if (!lnpGranted && hasLocalNetworkPermission(context)) {
lnpGranted = true
lnpPrompt = false
discovery.restart()
} else if (wasPaused) {
// Coming back from the background: the browse may have been sitting idle
// (or had its multicast socket torn out from under it) while we were away,
// and its own re-query interval has kept doubling. Re-arm and ask again,
// so returning to the screen is enough — no app restart.
discovery.restart()
}
wasPaused = false
}
else -> {}
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
lnpGranted = true
lnpPrompt = false
discovery.stop()
discovery.start()
}
}
lifecycle?.addObserver(obs)
@@ -231,11 +195,6 @@ fun ConnectScreen(
val identityStore = remember { IdentityStore(context) }
val knownHostStore = remember { KnownHostStore(context) }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
// The settings-profile catalog. Read here (not in the settings screen's copy) because this is
// where profiles are USED: to resolve what a tap connects with, to offer the one-offs, and to
// render the pinned cards. Re-read on entry, since Settings may have changed it in between.
val profileStore = remember { ProfileStore(context) }
var profiles by remember { mutableStateOf(profileStore.all()) }
// Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay
// rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can
// take a minute-plus to advertise again.
@@ -254,13 +213,6 @@ fun ConnectScreen(
knownHostStore.learnMac(dh.host, dh.port, dh.mac)
any = true
}
// Same for the OS-identity chain, so the card's icon survives the host sleeping.
if (dh.os.isNotEmpty() &&
knownHostStore.get(dh.host, dh.port)?.let { it.os != dh.os } == true
) {
knownHostStore.learnOs(dh.host, dh.port, dh.os)
any = true
}
}
any
}
@@ -306,7 +258,7 @@ fun ConnectScreen(
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
// A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the
// carousel (the console counterpart of the touch host card's overflow menu).
var optionsTarget by remember { mutableStateOf<HostCardEntry?>(null) }
var optionsTarget by remember { mutableStateOf<KnownHost?>(null) }
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
@@ -315,44 +267,15 @@ fun ConnectScreen(
// Issue the native connect (shared by the normal connect and the request-access path). A plain
// desktop connect (no library launch) — the library launcher calls [connectToHost] with an id.
suspend fun connectNative(
id: ClientIdentity,
targetHost: String,
targetPort: Int,
pinHex: String,
timeoutMs: Int,
profile: StreamProfile?,
launch: String?,
): Long = connectToHost(
context, settings.effectiveFor(profile), id, targetHost, targetPort, pinHex,
launch = launch, timeoutMs = timeoutMs,
)
// What the stream screen is handed: the settings this connect actually used, plus the HOST's
// clipboard decision (a property of the record, not a global). A host we never saved — a
// connect that failed to pin — falls back to the on default the setting always had.
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?) = ActiveSession(
handle,
settings.effectiveFor(profile),
clipboardSync = record?.clipboardSync ?: true,
profileName = profile?.name,
hostId = record?.id,
)
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long =
connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs)
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// the host presented (as an unpaired known host) so the next connect goes straight through and it
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
// fallback) instead of the error status line — discovery is already restarted when it runs, so
// the wait can observe the host reappear.
fun doConnectDirect(
targetHost: String,
targetPort: Int,
name: String,
pinHex: String?,
profile: StreamProfile?,
launch: String? = null,
onFailure: (() -> Unit)? = null,
) {
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?, onFailure: (() -> Unit)? = null) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
@@ -361,11 +284,9 @@ fun ConnectScreen(
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
connecting = true
status = null
notice = null
discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch {
val handle =
connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS, profile, launch)
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
// Cancelled mid-dial: the UI's already been returned (and discovery restarted) by
// cancelConnect — drop the just-opened session silently rather than navigating into it.
if (thisAttempt.cancelled.get()) {
@@ -375,14 +296,13 @@ fun ConnectScreen(
attempt = null
connecting = false
if (handle != 0L) {
var record = knownHostStore.get(targetHost, targetPort)
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
val fp = NativeBridge.nativeHostFingerprint(handle)
if (fp.isNotEmpty()) {
record = knownHostStore.trust(targetHost, targetPort, name, fp, paired = false)
knownHostStore.save(KnownHost(targetHost, targetPort, name, fp, paired = false))
}
}
onConnected(session(handle, record, profile))
onConnected(handle)
} else {
discovery.start()
val token = NativeBridge.nativeTakeLastError()
@@ -419,22 +339,12 @@ fun ConnectScreen(
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
// seen live) dial straight through.
fun doConnect(
targetHost: String,
targetPort: Int,
name: String,
pinHex: String?,
oneOffProfile: String?,
launch: String? = null,
) {
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
if (identity == null) {
status = "Identity not ready yet — try again in a moment"
return
}
val kh = knownHostStore.get(targetHost, targetPort)
// Latched here, not per dial attempt: a wake-and-redial must stream with the same profile
// the user asked for, and the "applies from the next session" footers stay truthful.
val profile = profileStore.resolveFor(kh, oneOffProfile)
val macs = kh?.mac ?: emptyList()
// "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP
// address change on a cold boot), else by address:port. Returns the CURRENT advert so we can
@@ -445,7 +355,7 @@ fun ConnectScreen(
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch, onFailure = {
doConnectDirect(targetHost, targetPort, name, pinHex, onFailure = {
waker.start(
hostName = name,
connectsAfter = true,
@@ -458,18 +368,15 @@ fun ConnectScreen(
// connects) point at the live one, then dial there (no fallback on this
// redial — a second failure surfaces as the plain error).
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
knownHostStore.save(kh.copy(address = live.host, port = live.port))
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
savedHosts = knownHostStore.all()
}
doConnectDirect(
live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex,
profile, launch,
)
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
},
)
})
} else {
doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch)
doConnectDirect(targetHost, targetPort, name, pinHex)
}
}
@@ -494,12 +401,7 @@ fun ConnectScreen(
// Pin the advertised fingerprint for a discovered host (defence against an impostor while
// we wait); a manually-typed host has none, so trust-on-first-use.
val pinHex = target.advertisedFp ?: ""
// A host being trusted for the first time can't have a binding yet, so this is always
// the plain defaults — a profile only ever enters via a later, deliberate choice.
val handle = connectNative(
id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS,
profile = null, launch = target.launch,
)
val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS)
// Cancelled while we were parked: tear the (possibly just-approved) session down and
// don't touch UI a fresh action may now own.
if (req.cancelled.get()) {
@@ -512,12 +414,11 @@ fun ConnectScreen(
// Approved — save the host as PAIRED, pinning the fingerprint it presented, so
// future connects are silent (exactly like after a PIN ceremony).
val fp = NativeBridge.nativeHostFingerprint(handle)
var record = knownHostStore.get(target.host, target.port)
if (fp.isNotEmpty()) {
record = knownHostStore.trust(target.host, target.port, target.name, fp, paired = true)
knownHostStore.save(KnownHost(target.host, target.port, target.name, fp, paired = true))
savedHosts = knownHostStore.all()
}
onConnected(session(handle, record, profile = null))
onConnected(handle)
} else {
// Cause-specific: an operator denial, an approval timeout, and a request that
// never reached the host are different problems with different fixes.
@@ -540,12 +441,6 @@ fun ConnectScreen(
targetPort: Int,
dh: DiscoveredHost? = null,
manualName: String? = null,
// A one-off "Connect with ▸" pick. `null` = follow the host's binding (a plain tap);
// `""` = force the global defaults, which is a real choice on a bound host and must
// therefore survive as a value rather than collapsing into "unset". NEVER rebinds.
oneOffProfile: String? = null,
// A library id the host should boot straight into (`launch=` on a link).
launch: String? = null,
) {
// Every dial/pair path funnels through here — with local network access denied the connect
// can only EPERM its way to a 10 s timeout, so ask instead of pretending to try.
@@ -561,222 +456,18 @@ fun ConnectScreen(
when {
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
known != null && (adv == null || adv == known.fpHex) ->
doConnect(targetHost, targetPort, known.name, known.fpHex, oneOffProfile, launch)
doConnect(targetHost, targetPort, known.name, known.fpHex)
// Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
known != null -> pendingTrust = PendingTrust(
targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED,
oneOffProfile, launch,
)
known != null -> pendingTrust =
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
dh?.pairingRequired == false -> pendingTrust = PendingTrust(
targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW,
oneOffProfile, launch,
)
dh?.pairingRequired == false -> pendingTrust =
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
// pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN
// "request access" (approve in the console) or the SPAKE2 PIN ceremony.
else -> pendingTrust = PendingTrust(
targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS,
oneOffProfile, launch,
)
}
}
// A speed test in flight: which host+profile it is measuring, and how far it has got. The
// measurement is over a real connect, so it takes the same `connecting` gate every dial does.
var speedTest by remember { mutableStateOf<HostCardEntry?>(null) }
var speedTestPhase by remember { mutableStateOf<SpeedTestPhase>(SpeedTestPhase.Connecting) }
fun startSpeedTest(entry: HostCardEntry) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
}
// The magic packet isn't the only thing LNP blocks: without the grant this would EPERM its
// way to a timeout and report a dead link on a perfectly good one.
if (!lnpGranted) {
lnpPrompt = true
return
}
speedTest = entry
speedTestPhase = SpeedTestPhase.Connecting
notice = null
connecting = true
discovery.stop() // a browse running through the burst would measure itself
scope.launch {
runSpeedTest(context, id, entry.host.address, entry.host.port, entry.host.fpHex) { p ->
// A dismissed dialog abandons the run; don't drag it back onto the screen.
if (speedTest != null) speedTestPhase = p
}
connecting = false
discovery.start()
}
}
// Toggle a host+profile pin. Presentation only: it never touches the profile itself and never
// changes the host's default binding.
fun togglePin(kh: KnownHost, profile: StreamProfile) {
val pins = if (profile.id in kh.pinnedProfileIds) {
kh.pinnedProfileIds - profile.id
} else {
kh.pinnedProfileIds + profile.id
}
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
savedHosts = knownHostStore.all()
}
// "Copy link" — the self-emitted form every other client already hands out
// (design/client-deep-links.md §4): the host's STABLE id first, with `host=` and `fp=` alongside,
// so a link written today still lands on the right box after the host changes address or this
// client is reinstalled. A PINNED card copies its own profile with it, because that combination
// is the thing being copied; a host card copies no profile at all and so keeps honouring the
// host's binding, exactly like a tap on it does.
fun copyLink(kh: KnownHost, pin: StreamProfile?) {
val url = DeepLinks.forHost(kh, profile = pin?.id).toUrl()
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val copied = clipboard != null && runCatching {
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
}.isSuccess
// Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is
// the platform's own documented anti-pattern. Below it nothing visible happens at all unless
// we say so — a silent menu item reads as a broken one.
if (copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
val message = if (copied) "Link copied." else "Couldn't copy the link to the clipboard."
// The console home renders neither the notice nor the status banner, so there it has to be a
// toast; the touch grid has both, and a success dressed as an error banner is a small lie.
when {
gamepadUi -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
copied -> notice = message
else -> status = message
}
}
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
// lives in the Edit sheet instead.
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
if (pin == null) {
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
}
add(HostMenuItem("Copy link") { copyLink(kh, pin) })
if (profiles.isEmpty()) return@buildList
if (pin != null) {
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
}
add(
HostMenuItem("Connect with: Default settings", startsSection = true) {
// The empty reference is "force the defaults", not "unset" — on a bound host that
// is a real, different action from a plain tap.
connect(kh.address, kh.port, oneOffProfile = "")
},
)
profiles.forEach { p ->
add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) })
}
if (pin == null) {
profiles.forEachIndexed { i, p ->
val pinned = p.id in kh.pinnedProfileIds
add(
HostMenuItem(
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
startsSection = i == 0,
) { togglePin(kh, p) },
)
}
}
}
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
// pinned combination is a plain one-click connect instead of a trip through a menu.
val savedCards = savedHosts.flatMap { kh ->
listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) }
}
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
// profiles ever sees the gap.
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
//
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
// decisions. So it never pairs, never trusts on its own, and carries references rather than
// values. Everything below is either "do exactly what the card does" or "refuse and say why" —
// a shortcut that can't honour its reference must say so, because streaming with the wrong
// settings is worse than an explanatory notice.
LaunchedEffect(deepLink, identity, savedHosts) {
val url = deepLink ?: return@LaunchedEffect
// Wait for the identity rather than refusing: it arrives a beat after first composition and
// the effect re-runs when it does.
if (identity == null) return@LaunchedEffect
onDeepLinkHandled()
val parsed = DeepLinks.parse(url)
if (parsed is DeepLinkResult.Refused) {
// A link for someone else's scheme is not our business to complain about.
if (parsed.error != LinkError.NOT_OUR_SCHEME) status = parsed.message()
return@LaunchedEffect
}
val link = (parsed as DeepLinkResult.Parsed).link
if (link.route != LinkRoute.CONNECT) {
// `wake` and `browse` are reserved in the grammar and parse today; a front-end that
// hasn't implemented them refuses with a notice rather than silently connecting.
status = "Punktfunk on Android can't do “${link.route.word}” links yet."
return@LaunchedEffect
}
// A profile reference that can't be honoured refuses: a "Work" shortcut streaming with the
// wrong settings is worse than an error naming what failed.
val profileRef = link.profile
if (profileRef != null) {
val (_, resolution) = profileStore.resolve(profileRef)
if (resolution != ProfileResolution.FOUND) {
status = if (resolution == ProfileResolution.AMBIGUOUS) {
"More than one profile is called “$profileRef” — rename one and try again."
} else {
"That link asks for a profile called “$profileRef”, which isn't on this device."
}
return@LaunchedEffect
}
}
when (val resolved = DeepLinks.resolveHost(link, savedHosts)) {
// Known AND pinned is the one-click contract: do exactly what tapping its card does.
is HostResolution.Known -> {
// A pin that contradicts the stored one is the link being stale or lying. Hard
// refusal: this is the one case where doing what the card does would be wrong.
if (link.pinConflict(resolved.host)) {
status = "That link's fingerprint doesn't match the one pinned for " +
"${resolved.host.name} — it's out of date, or it isn't that host."
return@LaunchedEffect
}
if (resolved.host.fpHex.isEmpty()) {
// Saved but never pinned (nothing writes such a record today, but the rule is
// absolute): a link may not establish trust, so this is a confirmation.
pendingTrust = PendingTrust(
resolved.host.address, resolved.host.port, resolved.host.name,
link.fp, PendingTrust.Kind.REQUEST_ACCESS, profileRef, link.launch,
)
return@LaunchedEffect
}
connect(
resolved.host.address, resolved.host.port,
oneOffProfile = profileRef, launch = link.launch,
)
}
// Unknown, or known only by address: the confirmation sheet, from which the normal
// pairing flow proceeds under the user's eyes. Never a silent trust.
is HostResolution.Unknown -> pendingTrust = PendingTrust(
resolved.address,
resolved.port,
link.name ?: resolved.address,
resolved.fp,
PendingTrust.Kind.REQUEST_ACCESS,
profileRef,
link.launch,
)
HostResolution.Ambiguous ->
status = "More than one saved host is called “${link.hostRef}” — " +
"rename one, or use its address."
HostResolution.Unresolvable ->
status = "That link points at a host this device doesn't know."
else -> pendingTrust =
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS)
}
}
@@ -787,15 +478,11 @@ fun ConnectScreen(
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
val tiles = buildList {
savedHosts.forEach { kh ->
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
add(
HomeTile(
id = "saved-${kh.id}",
id = "saved-${kh.address}:${kh.port}",
title = kh.name,
// The binding is what a press will actually do, so the tile says so — the
// console can't edit profiles, but it must never lie about which one it uses.
subtitle = bound?.let { "${kh.address}:${kh.port} · ${it.name}" }
?: "${kh.address}:${kh.port}",
subtitle = "${kh.address}:${kh.port}",
filled = true,
online = kh.isOnline(discovered, reachable),
paired = kh.paired,
@@ -803,23 +490,6 @@ fun ConnectScreen(
activate = { connect(kh.address, kh.port) },
),
)
// Pinned host+profile combinations, right after their host: one focus-and-press
// each, which is the affordance a controller surface does well (menus are not).
profileStore.pinsFor(kh).forEach { p ->
add(
HomeTile(
id = "pin-${kh.id}-${p.id}",
title = kh.name,
subtitle = p.name,
filled = true,
online = kh.isOnline(discovered, reachable),
paired = kh.paired,
knownHost = kh,
pinnedProfileId = p.id,
activate = { connect(kh.address, kh.port, oneOffProfile = p.id) },
),
)
}
}
discoveredUnsaved.forEach { dh ->
add(
@@ -856,11 +526,7 @@ fun ConnectScreen(
onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenSettings = onOpenSettings,
onOptions = { tile ->
tile.knownHost?.let { kh ->
optionsTarget = HostCardEntry(kh, tile.pinnedProfileId?.let(profileStore::byId))
}
},
onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } },
)
} else {
Box(Modifier.fillMaxSize()) {
@@ -882,23 +548,6 @@ fun ConnectScreen(
)
Spacer(Modifier.height(24.dp))
notice?.let {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(16.dp))
}
status?.let {
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
// job now, so `status` only ever carries a result/error here — a filled error
@@ -941,7 +590,7 @@ fun ConnectScreen(
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
"Android blocks punktfunk from finding or reaching hosts until you allow it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
@@ -963,45 +612,24 @@ fun ConnectScreen(
item(span = { GridItemSpan(maxLineSpan) }) {
SectionLabel("Saved hosts")
}
items(savedCards, key = { it.key }) { entry ->
val kh = entry.host
val pin = entry.pin
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
items(savedHosts, key = { "saved-${it.address}-${it.port}" }) { kh ->
HostCard(
name = kh.name,
address = "${kh.address}:${kh.port}",
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
online = kh.isOnline(discovered, reachable),
// Live advert preferred (the store lags a discovery tick), else stored.
os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os
?: kh.os,
enabled = !connecting,
// A pinned card connects with ITS profile; the host's own card follows the
// binding, which is exactly what its chip says it will do.
onConnect = {
if (pin != null) {
connect(kh.address, kh.port, oneOffProfile = pin.id)
} else {
connect(kh.address, kh.port)
}
onConnect = { connect(kh.address, kh.port) },
onForget = {
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
},
// Edit / Forget / Wake live on the host's own card only: a pinned card is a
// shortcut, not a second host, and offering destructive host actions on it
// would blur exactly that.
onForget = if (pin != null) {
null
} else {
{
knownHostStore.remove(kh)
savedHosts = knownHostStore.all()
}
},
onEdit = if (pin != null) null else ({ editTarget = kh }),
onEdit = { editTarget = kh },
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
// through the WakeController so it shows the "Waking…" overlay and waits for
// the host to come online (matched by fingerprint, so a new DHCP address on a
// cold boot still counts as "up") rather than firing a single silent packet.
onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
onWake = if (kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) {
{
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
@@ -1020,11 +648,6 @@ fun ConnectScreen(
} else {
null
},
profileLabel = pin?.name ?: bound?.name,
profileProminent = pin != null,
accent = accentColor(pin?.accent ?: bound?.accent),
menuItems = hostMenu(kh, pin),
reserveProfileSlot = anyProfileChip,
)
}
}
@@ -1040,7 +663,6 @@ fun ConnectScreen(
address = "${dh.host}:${dh.port}",
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
online = true, // in the discovered list ⇒ live on mDNS right now
os = dh.os,
enabled = !connecting,
onConnect = { connect(dh.host, dh.port, dh) },
onForget = null,
@@ -1053,28 +675,20 @@ fun ConnectScreen(
// rather than looking idle/empty. Suppressed while local network access is denied —
// a spinner would be a lie there (the browse can't receive anything); the banner above
// owns that state.
// Scan again is offered whether or not anything turned up: the case that sends people
// here is ONE expected host missing, not an empty list, and a browse that quietly went
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
// exactly like a network without that host on it.
if (lnpGranted && !connecting) {
if (lnpGranted && !connecting && discovered.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (discovered.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
TextButton(onClick = { discovery.restart() }) { Text("Scan again") }
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -1127,15 +741,15 @@ fun ConnectScreen(
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
val onSavePaired = { fp: String ->
knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true)
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
doConnect(pt.host, pt.port, pt.name, fp)
}
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW ->
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
PendingTrust.Kind.FP_CHANGED ->
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
@@ -1160,9 +774,7 @@ fun ConnectScreen(
}
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
optionsTarget?.let { entry ->
val kh = entry.host
val pin = entry.pin
optionsTarget?.let { kh ->
val offline = !kh.isOnline(discovered, reachable)
GamepadHostOptionsDialog(
hostName = kh.name,
@@ -1182,57 +794,27 @@ fun ConnectScreen(
},
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button.
onLibrary = if (settings.libraryEnabled && pin == null) {
onLibrary = if (settings.libraryEnabled) {
{ optionsTarget = null; onOpenLibrary(kh) }
} else {
null
},
onSpeedTest = if (pin == null) {
{ optionsTarget = null; startSpeedTest(HostCardEntry(kh, null)) }
} else {
null
},
onCopyLink = { optionsTarget = null; copyLink(kh, pin) },
onEdit = { optionsTarget = null; editTarget = kh },
onForget = {
knownHostStore.remove(kh)
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
optionsTarget = null
},
onDismiss = { optionsTarget = null },
// A pin's only action: unpinning touches neither the host nor the profile.
onUnpin = pin?.let { p -> { togglePin(kh, p); optionsTarget = null } },
profileName = pin?.name,
)
}
speedTest?.let { entry ->
val target = SpeedTestTarget.resolve(entry.host, entry.pin?.id, profileStore)
val dismiss = { speedTest = null }
val apply: (Boolean) -> Unit = { toProfile ->
val done = speedTestPhase as? SpeedTestPhase.Done
if (done != null) {
val where = applySpeedTestResult(
done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange,
)
profiles = profileStore.all()
notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where)
}
speedTest = null
}
if (gamepadUi) {
GamepadSpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
} else {
SpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
}
}
editTarget?.let { kh ->
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
val onSaveHost: (KnownHost) -> Unit = { updated ->
knownHostStore.save(updated)
knownHostStore.update(kh.address, kh.port, updated)
savedHosts = knownHostStore.all()
editTarget = null
}
@@ -1250,7 +832,6 @@ fun ConnectScreen(
EditHostDialog(
target = kh,
suggestedMacs = suggested,
profiles = profiles,
onSave = onSaveHost,
onDismiss = { editTarget = null },
)
@@ -1291,15 +872,6 @@ fun ConnectScreen(
)
}
/**
* One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned
* host+profile cards. Pins are additive presentation state on the host record never duplicated
* host entries, which would fork pairing, trust and renames (design §5.2a).
*/
private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
}
/**
* Whether NEARBY_WIFI_DEVICES is held (API 33+; not applicable below). We request it opportunistically
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
@@ -44,7 +44,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
@@ -150,14 +149,13 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
) {
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
// the USB device list + bonded BLE; a Sony pad IS an InputDevice until claimed, so its
// row supplements the PadRow below with the capture status + the USB grant.
var usbGeneration by remember { mutableIntStateOf(0) }
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
// capture claims even those away), so it's enumerated on the capture side — USB device
// list + bonded BLE — and re-checked on USB hot-plug.
var sc2Generation by remember { mutableIntStateOf(0) }
DisposableEffect(Unit) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
}
val filter = android.content.IntentFilter().apply {
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
@@ -172,26 +170,19 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
val sc2Probe = remember { Sc2Capture(context) }
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(usbGeneration) {
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(sc2Generation) {
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) sc2Probe.pairedBleAddress() else null
}
val sc2Present = sc2Usb != null || sc2Ble != null
val dsUsb = remember(usbGeneration) {
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
}
Group("Gamepads") {
if (sc2Present) Sc2Row(sc2Usb, activity)
dsUsb?.let { DsRow(it) }
if (pads.isEmpty() && !sc2Present) {
Text(
"No controller detected. Punktfunk can only forward devices Android " +
"No controller detected. punktfunk can only forward devices Android " +
"classifies as a gamepad or joystick — a pad connected through an adapter " +
"or hub may show up under \"Other input devices\" below with the adapter's " +
"identity, or not at all.",
@@ -328,155 +319,6 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi
}
}
/**
* Broadcast action for the Sony-pad USB grants fired by both the menu-time auto-ask
* ([MainActivity.maybeAskDsPermission]) and [DsRow]'s explicit button, so an open card
* refreshes whichever dialog was answered.
*/
internal const val DS_USB_PERMISSION_ACTION = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION"
/**
* The Sony USB pad card capture status + the USB grant. The grant normally arrives via the
* menu-time auto-ask the moment the pad attaches ([MainActivity.maybeAskDsPermission]); the
* button here is the recovery path after a deny (the auto-ask fires once per attach). Shown
* ALONGSIDE the pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture
* itself only runs inside a stream, so at menu time this card is pure status.
*/
@Composable
private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
val context = LocalContext.current
val settingOn = remember { SettingsStore(context).load().dsCapture }
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
var permitted by remember(usbDev) { mutableStateOf(usbManager.hasPermission(usbDev)) }
val model = DsDevice.modelFor(usbDev.productId)
val label = when (model) {
DsDevice.Model.DUALSENSE -> "DualSense"
DsDevice.Model.DUALSENSE_EDGE -> "DualSense Edge"
DsDevice.Model.DUALSHOCK4 -> "DualShock 4"
null -> return
}
// Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded;
// this receiver only updates the card).
val action = DS_USB_PERMISSION_ACTION
DisposableEffect(usbDev) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) {
if (i?.action == action) permitted = usbManager.hasPermission(usbDev)
}
}
androidx.core.content.ContextCompat.registerReceiver(
context,
receiver,
android.content.IntentFilter(action),
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
)
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("$label passthrough", style = MaterialTheme.typography.bodyLarge)
Text(
"Wired (USB)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when {
!settingOn -> Text(
"Passthrough is disabled in Settings — enable \"DualSense / DualShock " +
"passthrough (USB)\" to capture it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
!permitted -> {
Text(
"Needs USB access — grant it now and streams capture the pad silently.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = {
usbManager.requestPermission(
usbDev,
android.app.PendingIntent.getBroadcast(
context, 3, // requestCode 3 — 0/1/2 are the SC2/stream grants
android.content.Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
android.app.PendingIntent.FLAG_MUTABLE,
),
)
}) {
Text("Grant USB access")
}
}
else -> {
Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
// answer "can this phone drive this pad's audio endpoint at all", and gating
// that behind a live session would make it depend on the very thing one wants
// to rule out when a session misbehaves. DualSense only — the DS4 has no
// 4-channel haptics device.
if (model != DsDevice.Model.DUALSHOCK4) {
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(null) }
result?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(
enabled = !testing,
onClick = {
testing = true
result = null
Thread({
// Its OWN connection: the renderer's descriptor must never be
// shared with another transfer engine, and that applies to
// this test as much as to the real path.
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
val fd = conn?.fileDescriptor ?: -1
val r = if (fd >= 0) {
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
} else {
-1
}
conn?.close()
val msg = when {
r > 0 -> "Haptics test passed — $r frames to the pad."
r == -1 -> "Could not open the pad's audio interface. " +
"Some kernels refuse it; the pad still works normally."
r == -2 -> "The audio stream stopped part-way."
else -> "The stream opened but no audio reached the pad."
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
result = msg
testing = false
}
}, "pf-pad-selftest-ui").start()
},
) {
Text(if (testing) "Testing…" else "Test haptics")
}
}
}
}
}
}
}
/** One detected gamepad: identity, what it streams as, and a rumble test. */
@Composable
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
@@ -79,7 +79,6 @@ fun GamepadAddHostScreen(
suggestedMacs: List<String> = emptyList(),
onSave: ((KnownHost) -> Unit)? = null,
) {
val ink = LocalGamepadInk.current
val context = LocalContext.current
val isTv = remember { isTvDevice(context) }
val isEdit = editHost != null
@@ -246,7 +245,7 @@ fun GamepadAddHostScreen(
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
color = Color.White.copy(alpha = 0.55f),
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
)
}
@@ -307,7 +306,6 @@ private fun TvAddHostForm(
onAdd: () -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onDismiss)
val firstFocus = remember { FocusRequester() }
Box(Modifier.fillMaxSize()) {
@@ -321,11 +319,11 @@ private fun TvAddHostForm(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg)
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
color = Color.White.copy(alpha = 0.55f),
)
OutlinedTextField(
value = name, onValueChange = onName, singleLine = true,
@@ -364,7 +362,6 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val shape = RoundedCornerShape(14.dp)
Row(
@@ -378,26 +375,25 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg)
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(Modifier.weight(1f))
Text(
f.value.ifEmpty { f.placeholder },
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg,
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (editing) Text(" |", color = ink.accent)
if (editing) Text(" |", color = Color(0xFF8678F5))
}
}
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
val labelColor by animateColorAsState(
if (enabled) ink.accent else ink.fg(0.35f),
if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
tween(160),
label = "addLabel",
)
@@ -429,7 +425,6 @@ private fun KeyboardGrid(
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
onKey: (Int, Int) -> Unit,
) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(20.dp)
val gap = if (compact) 5.dp else 7.dp
Column(
@@ -438,7 +433,7 @@ private fun KeyboardGrid(
.widthIn(max = 640.dp)
.clip(shape)
.background(Color(0x1FFFFFFF))
.border(1.dp, ink.fg(0.12f), shape)
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
verticalArrangement = Arrangement.spacedBy(gap),
) {
@@ -459,15 +454,14 @@ private fun KeyboardGrid(
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
val bg by animateColorAsState(
if (focused) ink.accent else ink.glass,
if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF),
tween(90),
label = "keyBg",
)
val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg")
val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
@@ -14,8 +14,6 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -25,9 +23,6 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -36,9 +31,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -72,12 +65,9 @@ import kotlin.math.sin
// connected-controller status chip. One look across every screen is what makes the console UI read
// as a coherent mode rather than a set of themed pages.
/**
* One drifting blob of the aurora field: where it sits, how far it wanders, and how fast. Integer
* [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at
* draw time, so the field always shows several of that palette's tones at once.
*/
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
private class AuroraBlob(
val color: Color,
val baseX: Float,
val baseY: Float,
val driftX: Float,
@@ -90,80 +80,50 @@ private class AuroraBlob(
)
private val auroraBlobs = listOf(
AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f),
AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f),
AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f),
AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f),
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
)
/**
* The living console backdrop: soft blobs from the palette's ramp drifting over its ground on
* slow, seamless loops, finished with a centre-pooling vignette and top/bottom legibility scrims.
* A Compose approximation of the Apple client's MeshGradient aurora same colour families, same
* "ambience, never content" role, and the same [GamepadPalette] setting recolours both.
*
* [calm] is what the FORM screens wear: the pools dim onto the ground so the glass rows keep real
* colour and luminance without the launcher's contrast. Motion is identical either way on purpose
* only the contrast differs, so moving between screens can't make the field jump.
*
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
* same courtesy the Apple client pays Reduce Motion.
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
* of the Apple client's MeshGradient aurora same brand family, same "ambience, never content" role.
*/
@Composable
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
val ink = LocalGamepadInk.current
val palette = LocalGamepadPalette.current
val animated = animationsEnabled()
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "aurora")
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the
// wrap so the field never visibly jumps when the animation restarts.
val swept by transition.animateFloat(
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
// so the field never visibly jumps when the animation restarts.
val angle by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
label = "angle",
)
val angle = if (animated) swept else 0f
val tones = palette.blobColors
val ground = palette.groundColor
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's
// strength bleaches the chroma straight out of the gradient, so a pale palette gets under
// half — the same scrim strength the desktop console's shader carries.
val scrim = if (palette.light) ink.fg else Color.Black
val strength = if (palette.light) 0.45f else 1f
Canvas(modifier) {
drawRect(ground)
drawRect(Color.Black)
val span = max(size.width, size.height)
for ((i, b) in auroraBlobs.withIndex()) {
for (b in auroraBlobs) {
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
val r = span * b.radiusFrac
// Calm scales each blob's contribution rather than dimming the whole canvas: the
// ground stays put and only the pools come down to meet it, which is the same "lower
// the contrast, keep the colour" the desktop console's `calm` uniform does.
val alpha = if (calm) b.alpha * 0.62f else b.alpha
drawCircle(
brush = Brush.radialGradient(
colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent),
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
center = Offset(cx, cy),
radius = r,
),
center = Offset(cx, cy),
radius = r,
// Additive only works over a DARK ground; over a pale one every blob
// saturates to white and the field turns grey. Pale palettes tint instead.
blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus,
blendMode = BlendMode.Plus,
)
}
// Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under
// calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out
// toward the edges, where crushing them just eats the list.
// Cinematic vignette: pool light centre, sink the corners.
drawRect(
Brush.radialGradient(
colors = listOf(
Color.Transparent,
scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength),
),
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
center = Offset(size.width / 2, size.height / 2),
radius = span * 0.92f,
),
@@ -171,108 +131,43 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false
// Top/bottom legibility scrim for the pinned title + hint bar.
drawRect(
Brush.verticalGradient(
0.0f to scrim.copy(alpha = 0.40f * strength),
0.30f to scrim.copy(alpha = 0.05f * strength),
0.70f to scrim.copy(alpha = 0.06f * strength),
1.0f to scrim.copy(alpha = 0.42f * strength),
0.0f to Color.Black.copy(alpha = 0.40f),
0.30f to Color.Black.copy(alpha = 0.05f),
0.70f to Color.Black.copy(alpha = 0.06f),
1.0f to Color.Black.copy(alpha = 0.42f),
),
)
}
}
/**
* `false` when the user has turned animations off system-wide (Developer options' animator duration
* scale, or the accessibility "Remove animations" switch, which sets the same global). Read once
* per composition it needs a settings trip to the system, and it changes about never.
*/
@Composable
private fun animationsEnabled(): Boolean {
val context = LocalContext.current
return remember {
runCatching {
android.provider.Settings.Global.getFloat(
context.contentResolver,
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) != 0f
}.getOrDefault(true)
}
}
/**
* The backdrop for the console FORM screens (settings, add-host). It used to be a STILL deep-indigo
* base with two soft glows; it is now the launcher's own living field at `calm`, which keeps that
* colour and luminance under the glass rows, honours the palette setting on every screen rather
* than only the launcher, and leaves nothing in the console UI backed by a static image. Mirrors
* the Apple client's GamepadFormBackground, which made the same substitution.
* The calm backdrop for the console FORM screens (settings, add-host) deliberately still and quiet
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
*/
@Composable
fun GamepadFormBackground(modifier: Modifier = Modifier) {
GamepadAuroraBackground(modifier, calm = true)
}
/**
* The horizontal section switcher above a console list. Purely presentational the SCREEN owns
* which tab is selected and what the shoulders do. Scrollable so a narrow phone in landscape never
* has to squeeze the pills, and the selected one is always brought into view whether it was reached
* by shoulder button or tap.
*/
@Composable
fun ConsoleTabStrip(
titles: List<String>,
selected: Int,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
/**
* The strip itself holds the cursor (the caller moved focus UP out of its list). Draws a ring
* on the selected pill so it's clear left/right now walks sections rather than values the
* route a D-pad remote, which has no shoulder buttons, needs.
*/
focused: Boolean = false,
) {
val ink = LocalGamepadInk.current
val listState = rememberLazyListState()
LaunchedEffect(selected) {
runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) }
}
LazyRow(
state = listState,
modifier = modifier,
contentPadding = PaddingValues(horizontal = ConsoleEdgeInset),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
itemsIndexed(titles) { i, title ->
val active = i == selected
val background by animateColorAsState(
if (active) ink.accent(0.85f) else ink.glass,
tween(180),
label = "tabBg",
)
// Not `ink` — that name is the palette's, and shadowing it here cost a compile.
val labelColor by animateColorAsState(
if (active) ink.onAccent else ink.fg(0.55f),
tween(180),
label = "tabInk",
)
val ring by animateColorAsState(
ink.fg(if (active && focused) 0.85f else 0f),
tween(180),
label = "tabRing",
)
Text(
title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = labelColor,
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(background)
.border(1.5.dp, ring, RoundedCornerShape(50))
.clickable { onSelect(i) }
.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
Canvas(modifier) {
val span = max(size.width, size.height)
drawRect(Color(0xFF131126))
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color(0xE6635AAE), Color.Transparent),
center = Offset(size.width * 0.24f, size.height * 0.12f),
radius = span * 0.7f,
),
center = Offset(size.width * 0.24f, size.height * 0.12f),
radius = span * 0.7f,
)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color(0xBF343E96), Color.Transparent),
center = Offset(size.width * 0.82f, size.height * 0.9f),
radius = span * 0.7f,
),
center = Offset(size.width * 0.82f, size.height * 0.9f),
radius = span * 0.7f,
)
}
}
@@ -281,7 +176,7 @@ fun ConsoleTabStrip(
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
* it cross-fades between screens.
*/
val ConsoleLegendInset = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp)
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
val ConsoleEdgeInset = 24.dp
@@ -292,7 +187,6 @@ val ConsoleEdgeInset = 24.dp
*/
@Composable
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
val ink = LocalGamepadInk.current
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
@@ -300,7 +194,7 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset:
title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = ink.fg,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
@@ -357,22 +251,21 @@ class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: C
*/
@Composable
fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals {
val ink = LocalGamepadInk.current
val scale by animateFloatAsState(
targetValue = if (active) 1f else 0.98f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "consoleScale",
)
val background by animateColorAsState(
if (active) ink.accent(0.20f) else ink.glass,
if (active) Color(0x336656F2) else Color(0x14FFFFFF),
tween(160),
label = "consoleBg",
)
val border by animateColorAsState(
when {
editing -> ink.accent(0.70f)
active -> ink.fg(0.28f)
else -> ink.fg(0.06f)
editing -> Color(0xB38678F5)
active -> Color.White.copy(alpha = 0.28f)
else -> Color.White.copy(alpha = 0.06f)
},
tween(160),
label = "consoleBorder",
@@ -387,19 +280,18 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus
*/
@Composable
fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val travel by animateFloatAsState(
targetValue = if (on) 1f else 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f),
label = "switchKnob",
)
val track by animateColorAsState(
if (on) ink.accent else Color(0x26FFFFFF),
if (on) Color(0xFF6656F2) else Color(0x26FFFFFF),
tween(200),
label = "switchTrack",
)
val outline by animateColorAsState(
ink.fg(if (focused) 0.45f else 0.15f),
Color.White.copy(alpha = if (focused) 0.45f else 0.15f),
tween(160),
label = "switchOutline",
)
@@ -421,7 +313,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
.offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) }
.size(knob)
.clip(CircleShape)
.background(ink.fg),
.background(Color.White),
)
}
}
@@ -429,7 +321,6 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier)
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
@Composable
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
modifier = Modifier
.size(size)
@@ -439,7 +330,7 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
) {
Text(
glyph.toString(),
color = ink.fg,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.52f).sp,
textAlign = TextAlign.Center,
@@ -450,12 +341,11 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
@Composable
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, ink.fg, CircleShape))
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
}
}
@@ -520,7 +410,6 @@ internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp)
*/
@Composable
internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) {
val ink = LocalGamepadInk.current
Box(
Modifier.size(size).clip(CircleShape).background(PadButtonFace),
contentAlignment = Alignment.Center,
@@ -532,17 +421,17 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
val corner = RoundedCornerShape(2.dp)
Box(
Modifier.size(size * 0.32f).align(Alignment.TopEnd)
.border(1.4.dp, ink.fg(0.9f), corner),
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
Box(
Modifier.size(size * 0.32f).align(Alignment.BottomStart)
.clip(corner).background(PadButtonFace)
.border(1.4.dp, ink.fg(0.9f), corner),
.border(1.4.dp, Color.White.copy(alpha = 0.9f), corner),
)
}
Gamepad.PadStyle.NINTENDO -> Text(
"",
color = ink.fg,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.62f).sp,
textAlign = TextAlign.Center,
@@ -551,7 +440,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
Modifier
.size(width = size * 0.58f, height = size * 0.30f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, ink.fg(0.9f), RoundedCornerShape(50)),
.border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)),
)
}
}
@@ -563,7 +452,6 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u
*/
@Composable
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
val ink = LocalGamepadInk.current
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
// home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters /
@@ -576,19 +464,14 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
val frosted = if (hazeState != null) {
modifier.clip(shape).hazeEffect(hazeState).background(ink.shade(0.25f))
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
} else {
modifier.clip(shape).background(ink.shade(0.55f))
modifier.clip(shape).background(Color(0x8C14122A))
}
Row(
modifier = frosted
.border(1.dp, ink.fg(0.14f), shape)
.padding(horizontal = 16.dp, vertical = 10.dp)
// The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a
// screen whose legend grew a cell) it scrolls rather than running off the edge and
// silently eating the last hint — which is exactly what the settings screen's new
// Section cell did on a 360 dp phone.
.horizontalScroll(rememberScrollState()),
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(11.dp),
) {
@@ -614,7 +497,7 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
Text(
h.text,
style = MaterialTheme.typography.labelLarge,
color = ink.fg(0.9f),
color = Color.White.copy(alpha = 0.9f),
maxLines = 1,
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
)
@@ -626,25 +509,24 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
@Composable
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
Row(
modifier = modifier
.clip(RoundedCornerShape(50))
.background(ink.fg(0.08f))
.background(Color.White.copy(alpha = 0.08f))
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.SportsEsports,
contentDescription = null,
tint = ink.fg(0.75f),
tint = Color.White.copy(alpha = 0.75f),
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(7.dp))
Text(
name,
style = MaterialTheme.typography.labelMedium,
color = ink.fg(0.75f),
color = Color.White.copy(alpha = 0.75f),
maxLines = 1,
)
}
@@ -50,12 +50,10 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -85,7 +83,6 @@ fun GamepadDialog(
actions: List<DialogAction>,
body: @Composable ColumnScope.() -> Unit,
) {
val ink = LocalGamepadInk.current
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
@@ -118,11 +115,11 @@ fun GamepadDialog(
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
@@ -140,7 +137,6 @@ fun GamepadDialog(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val scale by animateFloatAsState(
if (focused) 1.02f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
@@ -154,19 +150,19 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
focused -> ink.accent
primary -> ink.accent(0.20f)
else -> ink.glass
focused -> Color(0xFF6656F2)
primary -> Color(0x336656F2)
else -> Color(0x14FFFFFF)
},
tween(160),
label = "btnBg",
)
val fg by animateColorAsState(
when {
!enabled -> ink.fg(0.35f)
focused -> ink.fg
primary -> ink.accent
else -> ink.fg(0.85f)
!enabled -> Color.White.copy(alpha = 0.35f)
focused -> Color.White
primary -> Color(0xFF8678F5)
else -> Color.White.copy(alpha = 0.85f)
},
tween(160),
label = "btnFg",
@@ -200,14 +196,13 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
/** Body text helper — a dimmed paragraph. */
@Composable
private fun DialogText(text: String) {
val ink = LocalGamepadInk.current
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f))
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
}
/**
* Console host options for a saved tile Wake (offered only when offline + a MAC is known), Copy
* link, Edit, Forget. Reached by pressing Up on a focused saved host in the carousel; the console
* counterpart of the touch host card's overflow menu.
* Console host options for a saved tile Wake (offered only when offline + a MAC is known), Edit,
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
* the touch host card's overflow menu.
*/
@Composable
fun GamepadHostOptionsDialog(
@@ -217,236 +212,20 @@ fun GamepadHostOptionsDialog(
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
onEdit: () -> Unit,
onForget: () -> Unit,
/**
* Copy this tile's `punktfunk://` link. Offered on a pinned tile too — unlike the host's other
* actions it says nothing about the host, it hands out the shortcut this very tile already is
* (profile included), which is exactly what a pin is for.
*/
onCopyLink: () -> Unit,
onDismiss: () -> Unit,
onSpeedTest: (() -> Unit)? = null,
/**
* Non-null when this is a PINNED host+profile tile, whose only action is to unpin. A pin is a
* shortcut, not a second host offering the host's destructive actions on it would blur
* exactly that, and the touch grid withholds them for the same reason.
*/
onUnpin: (() -> Unit)? = null,
profileName: String? = null,
) {
GamepadDialog(
title = if (profileName != null) "$hostName · $profileName" else hostName,
title = hostName,
onDismiss = onDismiss,
actions = buildList {
if (onUnpin != null) {
add(DialogAction("Unpin card", primary = true, onClick = onUnpin))
add(DialogAction("Copy link", onClick = onCopyLink))
add(DialogAction("Cancel", onClick = onDismiss))
return@buildList
}
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
if (canWake) add(DialogAction("Wake host", onClick = onWake))
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
add(DialogAction("Copy link", onClick = onCopyLink))
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
add(DialogAction("Forget", onClick = onForget))
add(DialogAction("Cancel", onClick = onDismiss))
},
) {
DialogText(
if (onUnpin != null) {
"This card is a shortcut to this host with one profile. Unpinning it changes " +
"nothing about the host or the profile."
} else {
"Manage this saved host."
},
)
}
}
/**
* The pin-to-hosts picker the settings screen's Profiles section opens the Android mirror of the
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
* carousel's unpin uses, never the profile itself and never the host's default binding.
*
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
* always what the store holds the row can't disagree with the carousel it feeds.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GamepadPinHostsDialog(
profileName: String,
hosts: List<KnownHost>,
pinned: (KnownHost) -> Boolean,
onToggle: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
// Done, so it starts focused).
var focus by remember { mutableIntStateOf(0) }
BackHandler(onBack = onDismiss)
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < hosts.size) focus++
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
// direction can't oscillate; asking for the state it's already in is a no-op.
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
}
},
onActivate = {
val kh = hosts.getOrNull(focus)
if (kh != null) onToggle(kh) else onDismiss()
},
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Pin “$profileName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (hosts.isEmpty()) {
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
} else {
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
hosts.forEachIndexed { i, kh ->
PinHostRow(
label = kh.name,
on = pinned(kh),
focused = i == focus,
onClick = { onToggle(kh) },
)
}
}
Spacer(Modifier.size(4.dp))
DialogButton(
"Done",
focused = focus == hosts.size,
primary = true,
enabled = true,
onClick = onDismiss,
)
}
}
}
}
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
// landscape window pulls itself into view.
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
Row(
Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.weight(1f))
ConsoleSwitch(on = on, focused = focused)
}
}
/**
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule a TV box on a
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
* couch surface too, even though profile EDITING doesn't.
*/
@Composable
fun GamepadSpeedTestDialog(
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val done = phase as? SpeedTestPhase.Done
GamepadDialog(
title = "Network speed test",
onDismiss = onDismiss,
actions = buildList {
if (done != null) {
add(
DialogAction(
when (target) {
SpeedTestTarget.Global -> "Apply"
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}"
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}"
},
primary = true,
) { onApply(true) },
)
if (target is SpeedTestTarget.Ask) {
add(DialogAction("Set as default") { onApply(false) })
}
}
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
},
) {
DialogText(hostName)
when (phase) {
SpeedTestPhase.Connecting -> DialogText("Connecting…")
SpeedTestPhase.Measuring ->
DialogText("Measuring — the host is bursting test traffic for two seconds.")
is SpeedTestPhase.Failed -> DialogText(phase.message)
is SpeedTestPhase.Done -> {
DialogText(
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
)
DialogText("Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps))
}
}
DialogText("Manage this saved host.")
}
}
@@ -463,11 +242,11 @@ fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDis
),
) {
DialogText(
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
"Android blocks punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
)
DialogText(
"If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " +
"If no prompt appears after Allow, enable “Nearby devices” for punktfunk in " +
"system settings.",
)
}
@@ -531,7 +310,6 @@ fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, on
@Composable
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
val ink = LocalGamepadInk.current
GamepadDialog(
title = "Waiting for approval",
onDismiss = onCancel,
@@ -539,8 +317,8 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
) {
val deviceName = Build.MODEL ?: "this device"
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg)
Text("Approve this device on $hostLabel.", color = ink.fg)
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
Text("Approve this device on $hostLabel.", color = Color.White)
}
DialogText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
@@ -556,7 +334,6 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
*/
@Composable
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
val ink = LocalGamepadInk.current
val scope = rememberCoroutineScope()
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
@@ -602,16 +379,16 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
Column(
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.verticalScroll(rememberScrollState())
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
Text(
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
@@ -630,14 +407,13 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
@Composable
private fun PinSlot(value: Int, focused: Boolean) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(12.dp)
Box(
Modifier.size(54.dp, 66.dp).clip(shape)
.background(if (focused) ink.accent(0.20f) else ink.glass)
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
contentAlignment = Alignment.Center,
) {
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
}
}
@@ -73,17 +73,11 @@ class HomeTile(
val connecting: Boolean = false,
val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram)
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
/**
* Set when this tile is a PINNED host+profile combination rather than the host's own tile.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget, library)
* belong to the host's own tile, and this one offers only Unpin.
*/
val pinnedProfileId: String? = null,
val activate: () -> Unit,
) {
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
// first" message if the host hasn't authorized this device for its management API.
val hasLibrary: Boolean get() = knownHost != null && pinnedProfileId == null
val hasLibrary: Boolean get() = knownHost != null
}
/**
@@ -247,10 +241,9 @@ fun GamepadHome(
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
@Composable
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(26.dp)
val wash = if (tile.filled) {
Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A)))
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
} else {
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
}
@@ -259,7 +252,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
.fillMaxWidth()
.clip(shape)
.background(wash)
.border(1.dp, ink.fg(0.16f), shape)
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
.padding(22.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
@@ -270,7 +263,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
Icon(
Icons.Filled.Lock,
contentDescription = "Paired",
tint = ink.fg(0.7f),
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.padding(end = 6.dp).size(15.dp),
)
}
@@ -287,14 +280,14 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
tile.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = ink.fg,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
tile.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
color = Color.White.copy(alpha = 0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -303,10 +296,9 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
@Composable
private fun MonogramBadge(tile: HomeTile) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(15.dp)
val fill = if (tile.filled) {
Brush.verticalGradient(listOf(ink.accent, ink.accent))
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
} else {
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
}
@@ -318,18 +310,18 @@ private fun MonogramBadge(tile: HomeTile) {
tile.connecting -> CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = ink.fg,
color = Color.White,
)
tile.isAdd -> Icon(
Icons.Filled.Add,
contentDescription = null,
tint = if (tile.filled) ink.fg else ink.accent,
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
)
else -> Text(
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = if (tile.filled) ink.fg else ink.accent,
color = if (tile.filled) Color.White else Color(0xFF8678F5),
)
}
}
@@ -1,89 +0,0 @@
package io.unom.punktfunk
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
// The ink the console (gamepad) UI draws with under the chosen background palette.
//
// The console screens were white-on-dark throughout with the brand violet hardcoded as the accent.
// Both had to become palette-derived at once: a pale field needs dark text or it is unreadable,
// and a violet focus wash on a copper field is exactly the clash this exists to fix.
//
// Published as a CompositionLocal rather than passed down, so a leaf (a row, a hint pill, a card)
// can ask for the right colour without every caller in between knowing about palettes. The Apple
// client uses an environment value and `pf-console-ui` a thread-local for the same reason.
/** Everything about the console's look that follows the chosen palette. */
class GamepadInk(
/** Primary text/glyph colour. */
val fg: Color,
/** Focus wash, selected tab pill, switch track — the palette's own accent. */
val accent: Color,
/** What reads ON the accent (a filled pill's label, a switch knob). */
val onAccent: Color,
/** The base fill every glass surface starts from, at its resting opacity. */
val glass: Color,
/** What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. */
val shade: Color,
/**
* How hard those washes go. A pale field needs far less mixing toward white at the dark
* field's strength bleaches the chroma straight out of the gradient.
*/
val shadeScale: Float,
/** True when the field is pale, for the few places that branch rather than blend. */
val isLight: Boolean,
) {
/** The foreground at [alpha]. */
fun fg(alpha: Float): Color = fg.copy(alpha = alpha)
/** The accent at [alpha]. */
fun accent(alpha: Float): Color = accent.copy(alpha = alpha)
/** A wash under text: [alpha] is the dark-field strength, scaled for a pale one. */
fun shade(alpha: Float): Color = shade.copy(alpha = alpha * shadeScale)
companion object {
fun of(p: GamepadPalette): GamepadInk {
val accent = p.accentColor
// Chosen by luminance, not by `light`: an accent is picked for contrast against the
// GLASS, not against the field.
val accentLuma =
0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third
val onAccent = if (accentLuma > 0.55) Color.Black else Color.White
if (!p.light) {
return GamepadInk(
fg = Color.White,
accent = accent,
onAccent = onAccent,
glass = Color.White.copy(alpha = 0.08f),
shade = Color.Black,
shadeScale = 1f,
isLight = false,
)
}
val (gr, gg, gb) = p.ground
return GamepadInk(
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()),
accent = accent,
onAccent = onAccent,
// More body than the dark glass carries: white frost over a bright gradient has
// far less separating it from its backdrop than dark glass over a dark one.
glass = Color.White.copy(alpha = 0.55f),
shade = Color.White,
shadeScale = 0.45f,
isLight = true,
)
}
/** The shipped dark look — what a preview or a test composition gets. */
val DARK = of(GamepadPalette.named("violet"))
}
}
/**
* The ink of the palette currently drawing, for everything under [App]. Provided from the live
* settings alongside [LocalGamepadPalette], so a change on the gamepad settings screen re-inks
* every console surface at once.
*/
val LocalGamepadInk = compositionLocalOf { GamepadInk.DARK }
@@ -152,9 +152,8 @@ fun GamepadNavEffect(
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes the dominant
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
* [onSecondary] is Y, and [onShoulder] is L1 (-1) / R1 (+1) a step SIDEWAYS out of the list, which
* the settings screen uses for its section tabs. B is left to MainActivity's BACK remap the
* screen's BackHandler (so B "peels one layer": close the keyboard, then the screen).
* [onSecondary] is Y. B is left to MainActivity's BACK remap the screen's BackHandler (so B "peels
* one layer": close the keyboard, then the screen).
*/
@Composable
fun GamepadNavEffect2D(
@@ -163,7 +162,6 @@ fun GamepadNavEffect2D(
onActivate: () -> Unit,
onTertiary: () -> Unit = {},
onSecondary: () -> Unit = {},
onShoulder: (Int) -> Unit = {},
) {
val activity = LocalContext.current as? MainActivity ?: return
val state = remember { NavInputState() }
@@ -171,7 +169,6 @@ fun GamepadNavEffect2D(
val currentOnActivate by rememberUpdatedState(onActivate)
val currentOnTertiary by rememberUpdatedState(onTertiary)
val currentOnSecondary by rememberUpdatedState(onSecondary)
val currentOnShoulder by rememberUpdatedState(onShoulder)
DisposableEffect(active) {
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
@@ -199,10 +196,7 @@ fun GamepadNavEffect2D(
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true }
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true }
else -> false // B → MainActivity (remapped to BACK → BackHandler)
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
}
}
if (active) {
@@ -1,218 +0,0 @@
package io.unom.punktfunk
import androidx.compose.ui.graphics.Color
// The console (gamepad) UI's background colour families, and the ink each one calls for.
//
// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The
// field samples that ramp so several tones show at once and pool into each other, the way a real
// gradient poster does. An earlier version rotated ONE field's hue per palette, which is why every
// non-default palette read flat and monotone.
//
// A palette also owns the UI sitting on it: [accent] is the focus wash / selected pill / switch
// colour, and [light] flips the ink so a pale field gets dark text instead of white.
//
// The table, [ramp] and [CELL_RAMP] are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
// Apple client's `GamepadPalette.swift` under the same ids, so one `ui_palette` value is one look
// on every client. Keep the three copies in step: a palette added here without the others is a
// value the other clients silently render as Violet.
/** One background colour family. */
class GamepadPalette(
/** The stored `ui_palette` value ([Settings.uiPalette]). */
val id: String,
/** What the settings row shows. */
val name: String,
/**
* The colour ramp, dark end first. Empty = the brand default's explicit field, kept
* bit-identical to what every install already sees.
*/
val stops: List<Triple<Double, Double, Double>>,
/** The field's ground — what it settles onto and what the calm mix lifts toward. */
val ground: Triple<Double, Double, Double>,
/** The UI accent: focus wash, selected tab pill, switch track. */
val accent: Triple<Double, Double, Double>,
/** A pale field: the UI flips to dark ink and the legibility scrims go white. */
val light: Boolean,
) {
/** Four drifting blob colours, spread across the ramp so the field shows several hues. */
val blobColors: List<Color> by lazy {
val s = stops.ifEmpty { VIOLET_BLOBS }
(0..3).map { color(ramp(s, 0.15 + 0.25 * it)) }
}
/** The field's ground as a Compose colour. */
val groundColor: Color by lazy { color(ground) }
/** The accent as a Compose colour. */
val accentColor: Color by lazy { color(accent) }
companion object {
/**
* Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept
* here so the three ports stay one table even though this client approximates the field
* with blobs.
*/
val CELL_RAMP = listOf(
0.10, -0.06, 0.04, -0.12,
-0.08, 0.14, -0.10, 0.06,
0.06, -0.12, 0.16, -0.04,
-0.10, 0.08, -0.06, 0.12,
)
/** The brand default's blob ramp — the colours the pre-palette field used. */
private val VIOLET_BLOBS = listOf(
Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80),
Triple(0.22, 0.38, 0.86), Triple(0.53, 0.47, 0.96),
)
/**
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
* ones. Cycling order runs dark light, so stepping the row walks the range one way.
*/
val ALL = listOf(
// --- dark fields (white ink) ---
GamepadPalette(
"violet", "Violet", emptyList(),
ground = Triple(0.075, 0.060, 0.160),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
"nebula", "Nebula",
listOf(
Triple(0.07, 0.05, 0.20), Triple(0.26, 0.14, 0.54), Triple(0.52, 0.20, 0.72),
Triple(0.82, 0.26, 0.62), Triple(0.98, 0.46, 0.68),
),
ground = Triple(0.055, 0.040, 0.135),
accent = Triple(0.95, 0.42, 0.72), light = false,
),
GamepadPalette(
// Ink-blue water: teal → cerulean → a violet undertow.
"abyss", "Abyss",
listOf(
Triple(0.02, 0.10, 0.17), Triple(0.04, 0.28, 0.42), Triple(0.07, 0.46, 0.63),
Triple(0.16, 0.38, 0.78), Triple(0.26, 0.22, 0.58),
),
ground = Triple(0.018, 0.070, 0.130),
accent = Triple(0.26, 0.76, 0.92), light = false,
),
GamepadPalette(
// Banked coals: plum embers → crimson → burnt orange → gold.
"ember", "Ember",
listOf(
Triple(0.16, 0.03, 0.10), Triple(0.45, 0.06, 0.12), Triple(0.72, 0.18, 0.06),
Triple(0.90, 0.42, 0.08), Triple(0.95, 0.68, 0.18),
),
ground = Triple(0.090, 0.035, 0.040),
accent = Triple(0.98, 0.62, 0.26), light = false,
),
GamepadPalette(
// Forest floor into moss and a lime break.
"moss", "Moss",
listOf(
Triple(0.03, 0.11, 0.09), Triple(0.06, 0.27, 0.20), Triple(0.09, 0.45, 0.31),
Triple(0.28, 0.61, 0.28), Triple(0.58, 0.77, 0.31),
),
ground = Triple(0.025, 0.085, 0.070),
accent = Triple(0.48, 0.86, 0.46), light = false,
),
GamepadPalette(
// Neutral, but never flat: barely-there saturation that still travels from a cool
// charcoal to a warm stone.
"graphite", "Graphite",
listOf(
Triple(0.06, 0.07, 0.11), Triple(0.15, 0.18, 0.25), Triple(0.30, 0.31, 0.35),
Triple(0.45, 0.42, 0.38), Triple(0.60, 0.56, 0.49),
),
ground = Triple(0.055, 0.055, 0.070),
accent = Triple(0.78, 0.80, 0.86), light = false,
),
// --- pale fields (dark ink) ---
GamepadPalette(
// The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom.
"holo", "Holo",
listOf(
Triple(0.99, 0.72, 0.90), Triple(0.80, 0.60, 0.98), Triple(0.58, 0.62, 0.99),
Triple(0.55, 0.86, 0.98), Triple(0.94, 0.98, 1.00),
),
ground = Triple(0.96, 0.92, 0.99),
accent = Triple(0.42, 0.28, 0.86), light = true,
),
GamepadPalette(
// The poster sunset: periwinkle → magenta → scarlet → tangerine → gold.
"sunset", "Sunset",
listOf(
Triple(0.55, 0.45, 0.92), Triple(0.86, 0.31, 0.66), Triple(0.97, 0.26, 0.34),
Triple(0.99, 0.51, 0.18), Triple(1.00, 0.80, 0.22),
),
ground = Triple(0.98, 0.74, 0.34),
accent = Triple(0.64, 0.13, 0.44), light = true,
),
GamepadPalette(
// Peach into blush and lilac — the softest of the set.
"bloom", "Bloom",
listOf(
Triple(1.00, 0.86, 0.72), Triple(0.99, 0.73, 0.79), Triple(0.95, 0.65, 0.89),
Triple(0.82, 0.68, 0.96), Triple(0.73, 0.79, 0.99),
),
ground = Triple(0.99, 0.90, 0.89),
accent = Triple(0.72, 0.24, 0.55), light = true,
),
GamepadPalette(
// First light: pale gold → coral → lilac.
"dawn", "Dawn",
listOf(
Triple(1.00, 0.92, 0.70), Triple(1.00, 0.80, 0.62), Triple(0.99, 0.66, 0.62),
Triple(0.90, 0.62, 0.78), Triple(0.77, 0.69, 0.95),
),
ground = Triple(1.00, 0.93, 0.82),
accent = Triple(0.82, 0.33, 0.28), light = true,
),
GamepadPalette(
// Sea glass: mint → aqua → a pale sky.
"mint", "Mint",
listOf(
Triple(0.82, 0.98, 0.90), Triple(0.62, 0.94, 0.88), Triple(0.55, 0.88, 0.95),
Triple(0.63, 0.82, 0.99), Triple(0.82, 0.87, 1.00),
),
ground = Triple(0.90, 0.98, 0.96),
accent = Triple(0.04, 0.42, 0.40), light = true,
),
GamepadPalette(
// Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn.
"opal", "Opal",
listOf(
Triple(0.98, 0.92, 0.96), Triple(0.87, 0.93, 0.99), Triple(0.91, 0.99, 0.95),
Triple(0.99, 0.96, 0.88), Triple(0.94, 0.90, 0.99),
),
ground = Triple(0.97, 0.96, 0.99),
accent = Triple(0.36, 0.32, 0.44), light = true,
),
)
/**
* The palette stored under [id], falling back to the brand default an unknown name is a
* palette a newer client shipped, not a reason to draw nothing.
*/
fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0]
/** Sample an ordered colour ramp at [t] ∈ [0, 1] (linear between neighbouring stops). */
fun ramp(
stops: List<Triple<Double, Double, Double>>,
t: Double,
): Triple<Double, Double, Double> {
if (stops.isEmpty()) return Triple(0.0, 0.0, 0.0)
if (stops.size == 1) return stops[0]
val x = t.coerceIn(0.0, 1.0) * (stops.size - 1)
val i = x.toInt().coerceAtMost(stops.size - 2)
val f = x - i
val (ar, ag, ab) = stops[i]
val (br, bg, bb) = stops[i + 1]
return Triple(ar + (br - ar) * f, ag + (bg - ag) * f, ab + (bb - ab) * f)
}
fun color(c: Triple<Double, Double, Double>): Color =
Color(c.first.toFloat(), c.second.toFloat(), c.third.toFloat())
}
}
@@ -39,7 +39,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -57,43 +56,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.DeviceGyro
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with
// the touch settings.
//
// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone.
// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant
// walking past Display and Audio to reach the controller settings. The tab names match the desktop
// console's and the Apple client's, so a setting is found under the same word wherever you look.
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
/**
* The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
* match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`.
*/
enum class GpTab(val title: String) {
STREAM("Stream"),
VIDEO("Video"),
AUDIO("Audio"),
CONTROLLER("Controller"),
INTERFACE("Interface"),
PROFILES("Profiles"),
}
internal class GpRow(
private class GpRow(
val id: String,
val tab: GpTab,
/**
* A sub-heading above this row, for the few tabs that hold more than one group. Most rows have
* none: the tab pill already names the section, and repeating it would be a second label
* saying the same word.
*/
val header: String?,
val label: String,
val value: String,
@@ -101,19 +72,8 @@ internal class GpRow(
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
)
/**
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
* already-focused row) cannot drift apart before this, `enabled` dimmed the label and nothing
* else, and every dimmed row still stepped its setting.
*/
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
rows.getOrNull(index)?.takeIf { it.enabled }
@Composable
fun GamepadSettingsScreen(
initial: Settings,
@@ -127,69 +87,9 @@ fun GamepadSettingsScreen(
val context = LocalContext.current
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
// Gates "Gyro from this phone" the same way — a TV box has no gyroscope to mirror from.
val hasGyroscope = remember { DeviceGyro.available(context) }
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
// The catalog is read once per screen entry: this screen can't create or edit profiles
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
// on each toggle, keeping the "Pinned to N hosts" counts honest.
val knownHostStore = remember { KnownHostStore(context) }
val profileStore = remember { ProfileStore(context) }
val profiles = remember { profileStore.all() }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
// only: pin appends at the end (card order), unpin removes, and the host's default binding
// (profileId) is never touched.
fun togglePin(kh: KnownHost, profile: StreamProfile) {
val pins = if (profile.id in kh.pinnedProfileIds) {
kh.pinnedProfileIds - profile.id
} else {
kh.pinnedProfileIds + profile.id
}
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
savedHosts = knownHostStore.all()
}
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
// interface remote-navigably. The strings branch on it.
val tv = remember { isTvDevice(context) }
val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) +
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
// Which section is showing, and where each one's focus was when it was last left — a detour
// into another tab shouldn't lose your place.
var tab by remember { mutableStateOf(GpTab.STREAM) }
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
// shoulder buttons at all (and is exactly what a TV box ships with).
var tabFocused by remember { mutableStateOf(false) }
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
val rows = allRows.filter { it.tab == tab }
val rows = buildSettingsRows(s, hasBodyVibrator, ::update)
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
fun selectTab(next: GpTab) {
if (next == tab) return
tabFocus[tab] = focus
tab = next
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
// outlive the row it pointed at.
focus = (tabFocus[next] ?: 0)
.coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0))
}
fun stepTab(delta: Int) {
val all = GpTab.entries
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
}
if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
// value text slides in its AnimatedContent, so the motion matches the button press.
var adjustDir by remember { mutableIntStateOf(1) }
@@ -199,33 +99,21 @@ fun GamepadSettingsScreen(
BackHandler(onBack = onBack)
GamepadNavEffect2D(
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
// drops its probes — the pattern ConnectScreen's dialogs use.
active = navActive && pinProfile == null,
active = navActive,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus-- else tabFocused = true
NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++
// On the strip, left/right walks sections; on a row it steps the value. A disabled
// row is INERT, not just dim — the step is refused instead of writing a setting
// that has nothing to act on (see `liveRow`).
NavDir.LEFT ->
if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
NavDir.RIGHT ->
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
}
},
// A on the strip drops into the section you picked, which is what "confirm" means there.
onActivate = {
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
},
// The shoulders work from either place — a real pad never has to visit the strip.
onShoulder = { delta -> stepTab(delta) },
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
// +1 accounts for the heading being item 0.
LaunchedEffect(focus, tab) {
LaunchedEffect(focus) {
runCatching {
val itemIndex = focus + 1
val info = listState.layoutInfo
@@ -244,44 +132,19 @@ fun GamepadSettingsScreen(
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadFormBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().systemBarsPadding()) {
// The strip is PINNED while the rows scroll under it: it is this screen's primary
// navigation now, and a switcher you have to scroll back up to find isn't one. The
// title stays in the scrolling list (landscape has no height to spare, and the
// selected pill already says which section you are in).
ConsoleTabStrip(
titles = GpTab.entries.map { it.title },
selected = GpTab.entries.indexOf(tab),
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
focused = tabFocused,
)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().systemBarsPadding(),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
item(key = "__title") {
// "Default settings", not "Settings": this screen edits the base layer only. The
// console honours a host's profile but doesn't edit profiles (design §5.4), so a
// bare "Settings" would quietly imply it changes whatever that host streams with.
ConsoleHeader("Default settings", horizontalInset = false)
ConsoleHeader("Settings", horizontalInset = false)
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(
row,
focused = index == focus && !tabFocused,
adjustDir = adjustDir,
onClick = {
// Same inertness as the pad path above — tapping a dimmed row focuses it
// (so its detail explains itself) but never flips it.
tabFocused = false
if (focus != index) focus = index
else if (row.enabled) { adjustDir = 1; row.activate() }
},
)
}
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
})
}
}
}
@@ -294,74 +157,28 @@ fun GamepadSettingsScreen(
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
// The legend follows the focused row (the desktop console's hints() does the same):
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
val focused = rows.getOrNull(focus)
// The shoulders always change section, so that cell leads on every row. Tappable too,
// like the others — a user without a working pad can still reach every tab.
// Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up
// into the strip) and a touch user taps a pill, so on those the cell would be both a
// lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
val sections = listOfNotNull(
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
.takeIf { padIsGamepad },
)
GamepadHintBar(
if (tabFocused) listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Section"),
PadGlyph.hint('A', "Open") { tabFocused = false },
listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
) else sections + when {
focused != null && !focused.enabled -> listOf(
PadGlyph.hint('B', "Done", onClick = onBack),
)
focused != null && !focused.adjustable -> listOf(
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
else -> listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
},
),
hazeState = hazeState,
)
}
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
// touch UI's per-profile pin toggles in the host edit sheet.
pinProfile?.let { p ->
GamepadPinHostsDialog(
profileName = p.name,
hosts = savedHosts,
pinned = { kh -> p.id in kh.pinnedProfileIds },
onToggle = { kh -> togglePin(kh, p) },
onDismiss = { pinProfile = null },
)
}
}
}
@Composable
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
val chevronAlpha by animateFloatAsState(
if (focused && row.adjustable) 0.6f else 0f,
tween(160),
label = "chevrons",
)
// focus arrives; the value colour cross-fades with them.
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
val valueColor by animateColorAsState(
ink.fg(if (focused) 1f else 0.6f),
Color.White.copy(alpha = if (focused) 1f else 0.6f),
tween(160),
label = "valueColor",
)
@@ -370,7 +187,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
Text(
row.header.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = ink.fg(0.45f),
color = Color.White.copy(alpha = 0.45f),
letterSpacing = 1.4.sp,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
)
@@ -394,9 +211,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
// so its detail line can still explain what would go here.
color = ink.fg(if (row.enabled) 1f else 0.45f),
color = Color.White,
maxLines = 1,
)
Spacer(Modifier.weight(1f))
@@ -404,7 +219,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
// The value slides in the direction it was stepped and its width animates, so
// cycling a choice reads as motion through a list rather than a text swap.
AnimatedContent(
@@ -425,7 +240,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
overflow = TextOverflow.Ellipsis,
)
}
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
Text(" ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
}
}
// The focused row carries its own one-line description — no dedicated (space-eating)
@@ -438,7 +253,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
color = ink.fg(0.6f),
color = Color.White.copy(alpha = 0.6f),
maxLines = 2,
modifier = Modifier.padding(top = 6.dp),
)
@@ -448,26 +263,21 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
}
/** Build the console settings rows from the current [Settings], writing through [update].
* [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this
* phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see
* `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */
internal fun buildSettingsRows(
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs). */
private fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
hasGyroscope: Boolean,
av1Capable: Boolean,
update: (Settings) -> Unit,
): List<GpRow> {
fun <T> choice(
id: String, tab: GpTab, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
): GpRow {
val idx = options.indexOfFirst { it.first == current }
return GpRow(
id, tab, header, label,
id, header, label,
value = options.getOrNull(idx)?.second ?: "",
detail = detail,
enabled = enabled,
adjust = { delta ->
if (idx < 0) {
options.firstOrNull()?.let { write(it.first) } != null
@@ -483,25 +293,20 @@ internal fun buildSettingsRows(
)
}
fun toggle(
id: String, tab: GpTab, header: String?, label: String, detail: String,
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
id: String, header: String?, label: String, detail: String,
value: Boolean, write: (Boolean) -> Unit,
): GpRow = GpRow(
id, tab, header, label,
id, header, label,
value = if (value) "On" else "Off",
detail = detail,
enabled = enabled,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
toggled = value,
)
// Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface /
// Profiles), so a setting sits under the same word whichever client you found it on. The ROWS
// stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for
// the sake of symmetry would be parity in name only.
return listOf(
choice(
"resolution", GpTab.STREAM, null, "Resolution",
"resolution", "Stream", "Resolution",
"The host creates a virtual display at exactly this size — no scaling. " +
"Custom sizes are typed in the touch settings.",
// A custom size (typed in the touch settings) leads the list so it stays visible and
@@ -515,86 +320,54 @@ internal fun buildSettingsRows(
s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) },
choice(
"refresh", GpTab.STREAM, null, "Refresh rate",
"Frame rate the host renders and streams at.",
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
REFRESH_OPTIONS, s.hz,
) { update(s.copy(hz = it)) },
choice(
"bitrate", GpTab.STREAM, null, "Bitrate",
"Automatic uses the host's default. A host's options (Up on its tile) can measure the " +
"link and set an informed value.",
"bitrate", null, "Bitrate",
"Automatic uses the host's default. Run a speed test from the touch UI for an informed value.",
BITRATE_OPTIONS, s.bitrateKbps,
) { update(s.copy(bitrateKbps = it)) },
choice(
"compositor", GpTab.STREAM, "Host output", "Compositor",
"compositor", null, "Compositor",
"Which compositor drives the virtual output — honored only if available on the host.",
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
) { update(s.copy(compositor = it)) },
choice(
"codec", GpTab.VIDEO, null, "Video codec",
"codec", "Video", "Video codec",
"A preference — the host falls back if it can't encode this one.",
codecOptionsFor(s.codec, av1Capable), s.codec,
CODEC_OPTIONS, s.codec,
) { update(s.copy(codec = it)) },
toggle(
"hdr", GpTab.VIDEO, null, "10-bit HDR",
"hdr", null, "10-bit HDR",
"HDR10 — engages when the host sends HDR content and this display supports it.",
s.hdrEnabled,
) { update(s.copy(hdrEnabled = it)) },
toggle(
"lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode",
"lowLatency", null, "Low-latency mode",
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
s.lowLatencyMode,
) { update(s.copy(lowLatencyMode = it)) },
choice(
"audio", GpTab.AUDIO, null, "Audio channels",
"The speaker layout requested from the host.",
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
) { update(s.copy(audioChannels = it)) },
toggle(
"mic", GpTab.AUDIO, null, "Microphone",
"Send this device's microphone to the host's virtual mic.",
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
s.micEnabled,
) { update(s.copy(micEnabled = it)) },
toggle(
"echoCancel", GpTab.AUDIO, null, "Echo cancellation",
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
s.echoCancel,
) { update(s.copy(echoCancel = it)) },
toggle(
"padForward", GpTab.CONTROLLER, null, "Forward controllers",
"Send this device's controllers to the host. Turn it off when your controller " +
"already reaches the host another way — USB passthrough such as VirtualHere — " +
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
// Everything below the master switch follows it — dim and inert while nothing is being
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
// the pad rows kept stepping settings that had nothing to act on.
choice(
"padType", GpTab.CONTROLLER, null, "Controller type",
"padType", "Controller", "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
) { update(s.copy(gamepad = it)) },
choice(
"systemButtons", GpTab.CONTROLLER, null, "Guide button",
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
"sends them to the host whenever this device delivers them.",
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
) { update(s.copy(systemButtons = it)) },
choice(
"guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide",
"Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
) { update(s.copy(guideGesture = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
"phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone",
"phoneRumble", null, "Rumble on this phone",
"Also play controller 1's rumble on this phone's own vibration motor — " +
"for clip-on pads without rumble motors.",
s.rumbleOnPhone,
@@ -602,130 +375,27 @@ internal fun buildSettingsRows(
} else {
null
},
// The rumble mirror's sibling, data flowing the other way — needs a gyroscope to
// mirror FROM, which a TV box lacks.
if (hasGyroscope) {
toggle(
"phoneGyro", GpTab.CONTROLLER, null, "Gyro from this phone",
"When the controller has no gyro of its own, send this phone's motion " +
"sensors as controller 1's — for clip-on pads without one.",
s.gyroOnPhone,
) { update(s.copy(gyroOnPhone = it)) }
} else {
null
},
) + listOf(
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
// nothing to do with this device's motor, and a TV box is where it matters most.
toggle(
"sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough",
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
"it as-is — Steam on the host drives it like the physical pad.",
s.sc2Capture, enabled = s.gamepadForwarding,
) { update(s.copy(sc2Capture = it)) },
// The SC2 row's twin, and missing here until now: the touch settings have carried both
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
toggle(
"dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)",
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
"triggers, lightbar and gyro.",
s.dsCapture, enabled = s.gamepadForwarding,
) { update(s.copy(dsCapture = it)) },
// The palette leads Interface: it is the one row whose effect you can see while you step
// it (the backdrop behind this very list recolours), so it wants to be the first thing
// found in the section.
choice(
"palette", GpTab.INTERFACE, null, "Background",
"The colour family this backdrop drifts through — it changes as you step, so pick by " +
"looking. Appearance only.",
GamepadPalette.ALL.map { it.id to it.name },
GamepadPalette.named(s.uiPalette).id,
) { update(s.copy(uiPalette = it)) },
choice(
"hud", GpTab.INTERFACE, null, "Statistics overlay",
"hud", "Interface", "Statistics overlay",
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
"A 3-finger tap cycles the tiers live.",
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle(
"autoWake", GpTab.INTERFACE, null, "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle(
"library", GpTab.INTERFACE, null, "Game library",
"library", null, "Game library",
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI",
"autoWake", null, "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle(
"gamepadUI", null, "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
)
}
/**
* The trailing Profiles section the Android mirror of the desktop console's (design §5.2a, §5.4):
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
* instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points
* nowhere useful on a touchless device, so the strings name the actual route the
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
*/
private fun buildProfileRows(
profiles: List<StreamProfile>,
savedHosts: List<KnownHost>,
tv: Boolean,
openPinPicker: (StreamProfile) -> Unit,
): List<GpRow> {
val createHint = if (tv) {
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
"and use the standard interface."
} else {
"Profiles are created and edited in the touch interface."
}
if (profiles.isEmpty()) {
return listOf(
GpRow(
id = "noProfiles",
tab = GpTab.PROFILES,
header = null,
label = "No profiles yet",
value = "",
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
"one-press connect cards here. " + createHint,
adjust = { false },
activate = {},
adjustable = false,
enabled = false,
),
)
}
return profiles.map { p ->
// Counted straight off the host records, so it agrees with what the carousel renders.
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
GpRow(
id = "profile:${p.id}",
tab = GpTab.PROFILES,
header = null,
label = p.name,
value = when (pins) {
0 -> "Not pinned"
1 -> "Pinned to 1 host"
else -> "Pinned to $pins hosts"
},
detail = "Pin this profile to a host and it appears as its own card — one press " +
"connects with it. " + createHint,
adjust = { false },
activate = { openPinPicker(p) },
adjustable = false,
)
}
}
@@ -1,8 +1,6 @@
package io.unom.punktfunk
import android.content.Context
import android.os.Build
import android.util.Log
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders
@@ -46,47 +44,15 @@ suspend fun connectToHost(
// Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media
// sockets) — must be applied before connect, since sockets are tagged at creation.
NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode)
val multiSlice = VideoDecoders.multiSliceTolerant()
val partialFrame = VideoDecoders.partialFrameCapable()
// Slice-progressive delivery: decoder truth AND the async decode loop — the legacy
// sync loop feeds whole AUs only, so parts must never arrive when it is selected.
val frameParts = settings.lowLatencyMode && partialFrame
val codecBits = VideoDecoders.decodableCodecBits()
// Automatic codec (P5, measured NP3 ↔ RTX 4090): AV1 beat HEVC by ~1.2 ms end-to-end at
// identical conditions, so under "Automatic" this device prefers AV1 when it hardware-
// decodes it (the AV1 bit is only ever set for a real, non-blocked hardware decoder) AND
// it lacks FEATURE_PartialFrame — a partial-frame device keeps HEVC, whose slice overlap
// AV1 cannot ride (AV1 has no slices; the host's chunked poll never arms). The host
// honors the preference only inside the probed shared codec set, so an AV1-less encoder
// still resolves HEVC. An explicit user choice always wins unchanged.
val preferredCodec = settings.preferredCodec().takeIf { it != 0 }
?: if (codecBits and 4 != 0 && !partialFrame) 4 else 0
// The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline
// is client-inert unless BOTH probes pass — this line says which decoder failed one.
Log.i(
"pf.caps",
VideoDecoders.capsReport() +
" → multiSlice=$multiSlice parts=$frameParts prefer=$preferredCodec" +
" (lowLatency=${settings.lowLatencyMode})",
)
NativeBridge.nativeConnect(
host, port, w, h, hz,
identity.certPem, identity.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, multiSlice,
frameParts,
settings.audioChannels,
hdrEnabled, settings.audioChannels,
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
// the soft codec preference (user choice, or the Automatic AV1 rule above) — the
// host resolves the emitted codec from both.
codecBits, preferredCodec, timeoutMs,
// the user's soft codec preference — the host resolves the emitted codec from both.
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
launch,
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
Build.MODEL ?: "Android",
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
)
}
}
@@ -63,7 +63,6 @@ import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import kotlin.math.PI
import kotlin.math.absoluteValue
import kotlin.math.cos
@@ -86,11 +85,10 @@ private sealed class LibState {
fun LibraryScreen(
host: KnownHost,
settings: Settings,
onLaunched: (ActiveSession) -> Unit,
onLaunched: (Long) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onBack)
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -144,18 +142,7 @@ fun LibraryScreen(
host.address, host.port, host.fpHex, launch = game.id,
)
launching = false
if (handle != 0L) {
onLaunched(
ActiveSession(
handle,
settings,
host.clipboardSync,
hostId = host.id,
// Where to come back to when this game exits.
launchedFromLibrary = true,
),
)
}
if (handle != 0L) onLaunched(handle)
else Toast.makeText(
context,
"Launch failed — check the host and try again.",
@@ -178,8 +165,8 @@ fun LibraryScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
CircularProgressIndicator(color = ink.fg)
Text("Launching…", color = ink.fg, style = MaterialTheme.typography.bodyLarge)
CircularProgressIndicator(color = Color.White)
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
}
}
}
@@ -203,19 +190,17 @@ fun LibraryScreen(
@Composable
private fun LoadingState() {
val ink = LocalGamepadInk.current
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
CircularProgressIndicator(color = ink.fg)
Text("Loading library…", color = ink.fg(0.7f), style = MaterialTheme.typography.bodyLarge)
CircularProgressIndicator(color = Color.White)
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun MessageState(text: String) {
val ink = LocalGamepadInk.current
Text(
text,
color = ink.fg(0.75f),
color = Color.White.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
@@ -229,7 +214,6 @@ private fun Coverflow(
navActive: Boolean,
onLaunch: (GameEntry) -> Unit,
) {
val ink = LocalGamepadInk.current
BoxWithConstraints(Modifier.fillMaxSize()) {
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
@@ -252,22 +236,7 @@ private fun Coverflow(
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
)
// Design D4: the launcher entries lead the strip (the client groups them at parse time).
// A coverflow is one-dimensional, so instead of a second focus rail the heading names the
// group the cursor is in and changes as it crosses the boundary. Only drawn when the
// library actually has both groups — otherwise the screen is exactly what it was.
val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
if (bothGroups) {
Text(
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.45f),
letterSpacing = 2.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
)
}
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(coverWidth),
@@ -325,16 +294,15 @@ private fun Coverflow(
current?.title ?: " ",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = ink.fg,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (current != null) {
Text(
if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER"
else current.storeLabel.uppercase(),
if (current.isCustom) "CUSTOM" else "STEAM",
style = MaterialTheme.typography.labelMedium,
color = ink.fg(0.5f),
color = Color.White.copy(alpha = 0.5f),
letterSpacing = 2.sp,
)
}
@@ -346,7 +314,6 @@ private fun Coverflow(
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
@Composable
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val candidates = game.art.posterCandidates
var idx by remember(game.id) { mutableStateOf(0) }
val shape = RoundedCornerShape(16.dp)
@@ -354,7 +321,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
modifier = modifier
.clip(shape)
.background(Color(0xFF241F3D))
.border(1.dp, ink.fg(0.12f), shape),
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
contentAlignment = Alignment.Center,
) {
if (idx < candidates.size) {
@@ -367,29 +334,24 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
)
} else {
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
// would read as "a game whose cover failed to load".
Text(
if (game.isLauncher) game.storeLabel else game.title,
game.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = ink.fg(0.75f),
color = Color.White.copy(alpha = 0.75f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(12.dp),
)
}
// Store badge, top-start — brand-filled for a launcher entry (design D4).
// Store badge, top-start.
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
Text(
game.storeLabel,
if (game.isCustom) "Custom" else "Steam",
style = MaterialTheme.typography.labelSmall,
color = ink.fg,
color = Color.White,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(
if (game.isLauncher) MaterialTheme.colorScheme.primary
else Color.Black.copy(alpha = 0.5f),
)
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
@@ -13,70 +13,24 @@ import android.view.InputDevice
import android.view.KeyCharacterMap
import android.view.KeyEvent
import android.view.MotionEvent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.security.KnownHostStore
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
/**
* Keeps ONE window-insets reader alive for as long as the app's UI exists the fix for the menus
* coming back from a stream laid out against the WRONG safe area.
*
* Compose attaches its `OnApplyWindowInsets` and `WindowInsetsAnimation` callbacks when the first
* composable reads an inset, and removes them again when the last reader goes away
* (`WindowInsetsHolder.increment/decrementAccessors`). [StreamScreen] reads no insets at all it's
* a bare full-screen surface so a stream drops the reader count to zero for its whole duration.
*
* That alone is survivable; what isn't is a session that ends while the app is BACKGROUNDED, which
* is the common case (leaving the app ends the session see StreamScreen's ON_STOP observer). The
* whole window restore `show(systemBars())`, releasing the landscape lock then runs on a stopped
* activity, and the corrected insets that follow arrive while Compose has no listener attached. When
* the menus recompose, `incrementAccessors` re-attaches and asks for a fresh pass, but a stopped
* window produces no dispatch, and on resume nothing has *changed* any more, so none ever comes.
* Compose keeps serving what it last saw: the landscape, bars-hidden values.
*
* That's exactly what the reporter's phone showed (on-glass 2026-07-29, verified by dump): the
* platform reported `bars=[0,162,0,72] cutout=[0,162,0,0]` for the window while the layout was still
* using the landscape immersive set cutout `left=162` (Material3 lays out against
* `systemBars.union(displayCutout)`), bars all zero. Content shoved right by the landscape cutout,
* nothing kept clear of the status bar or the gesture pill, and no rotation or IME animation could
* shake it loose. A/B'd over eight runs of the real teardown sequence: 3 of 4 wrong without this,
* 4 of 4 correct with it.
*
* Reading an inset here holds the count above zero for the activity's whole life, so the listeners
* survive the stream and every dispatch lands. It subscribes to no inset VALUE (only the holder
* object), so it triggers no recomposition the cost is one DisposableEffect.
*/
@Composable
private fun HoldWindowInsetsListeners() {
// The read itself IS the registration (the accessor is scoped to this composable, which never
// leaves the composition); `remember` is only what keeps it from being a value nobody uses.
remember(WindowInsets.systemBars) {}
}
class MainActivity : ComponentActivity() {
/**
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
@@ -100,21 +54,6 @@ class MainActivity : ComponentActivity() {
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
/**
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
* [gamepadRouter]): uncaptured hover/click/wheel forwards as absolute cursor input, captured
* ([android.view.View.requestPointerCapture]) raw deltas as relative mouse-look. The dispatch
* overrides below route every SOURCE_MOUSE event here while streaming. Null while not streaming.
*/
var mouseForwarder: MouseForwarder? = null
/**
* TV remote-as-pointer for the active session (StreamScreen builds it on TV devices only):
* hold SELECT to toggle, then the D-pad glides the host cursor. Consulted first for
* non-gamepad keys while streaming. Null while not streaming or not a TV.
*/
var remotePointer: RemotePointer? = null
/**
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
* couch user with no keyboard/Back can always leave a stream.
@@ -142,17 +81,6 @@ class MainActivity : ComponentActivity() {
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
private set
/**
* A `punktfunk://` URL waiting to be routed — set from the VIEW intent that started (or
* re-entered) this activity, cleared by whoever handles it. Compose observes it.
*
* Read in BOTH [onCreate] and [onNewIntent] on purpose: `launchMode` is `standard`, so a second
* link usually arrives as a fresh activity instance (onCreate) and only sometimes as a new
* intent on this one (a caller that set `FLAG_ACTIVITY_SINGLE_TOP`). A link arriving while an
* earlier one is still unhandled replaces it the user's latest intent is the live one.
*/
var pendingDeepLink by mutableStateOf<String?>(null)
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
private var highRefreshModeId = 0
@@ -170,10 +98,6 @@ class MainActivity : ComponentActivity() {
private var sc2Receiver: BroadcastReceiver? = null
private var sc2PermissionAsked = false
/** Sony-pad USB grant asked this attach a deny doesn't re-nag until a fresh attach (or the
* Controllers screen's explicit button). */
private var dsPermissionAsked = false
/**
* Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A
* synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches
@@ -186,28 +110,6 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// A URL may never preempt a live session (design/client-deep-links.md §3.2). With
// `launchMode = standard` a link normally arrives as a NEW activity instance in a new task
// — the streaming one gets backgrounded, and backgrounding ends a session — so the refusal
// has to happen HERE, before this instance is resumed, not inside the composition (which
// only ever sees the rare `onNewIntent` case). Finishing now leaves the streaming task in
// front, untouched.
val live = liveStream
if (live != null && deepLinkFrom(intent) != null) {
// Pointing at the host already being streamed is the one exception, and its right
// answer is to do nothing: the intent has already brought the app forward, which is
// what "focus it" means here.
if (!targetsHost(intent, live)) {
Toast.makeText(
this,
"Already streaming — end this session first.",
Toast.LENGTH_LONG,
).show()
}
finish()
return
}
pendingDeepLink = deepLinkFrom(intent)
lastPadIsGamepad = !isTvDevice(this)
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
resolveHighRefreshMode()
@@ -230,8 +132,6 @@ class MainActivity : ComponentActivity() {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
sc2PermissionAsked = false // a fresh attach may ask once again
startSc2MenuNav()
dsPermissionAsked = false
maybeAskDsPermission()
}
SC2_MENU_PERMISSION -> {
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
@@ -254,7 +154,6 @@ class MainActivity : ComponentActivity() {
}
setContent {
PunktfunkTheme {
HoldWindowInsetsListeners()
// Focus hook for the SC2's synthetic navigation (see [sc2MoveFocus]). `Next` is
// the bootstrap: directional moves need an already-focused node, while one-
// dimensional traversal assigns initial focus when there is none.
@@ -271,24 +170,9 @@ class MainActivity : ComponentActivity() {
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Keep `getIntent()` truthful for anything that reads it later (the gamepad-UI dev flag).
setIntent(intent)
deepLinkFrom(intent)?.let { pendingDeepLink = it }
}
/**
* The `punktfunk://` URL of a VIEW intent, or null. Only VIEW: the launcher's MAIN intent
* carries no data, and nothing else may inject a URL into the router.
*/
private fun deepLinkFrom(intent: Intent?): String? =
intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data?.toString()
override fun onResume() {
super.onResume()
startSc2MenuNav()
maybeAskDsPermission()
}
override fun onPause() {
@@ -349,37 +233,6 @@ class MainActivity : ComponentActivity() {
sc2MenuActive = false
}
/**
* Ask for USB access to an attached Sony pad the moment it appears a fresh attach while
* the app is open, or the app coming to the foreground with one already plugged in at most
* once per attach, so the stream-mode capture ([io.unom.punktfunk.kit.DsCapture]) engages
* silently instead of interrupting stream start with the dialog. Unlike the SC2's menu flow
* there is nothing to START on the grant: an uncaptured Sony pad is an ordinary InputDevice
* at menu time, so the grant is simply recorded (Android keeps it while the pad stays
* attached). The broadcast only refreshes the Controllers screen's card if it happens to be
* open; a deny leaves that card's explicit button as the re-ask.
*/
private fun maybeAskDsPermission() {
if (streamHandle != 0L) return // StreamScreen owns its own permission flow while streaming
if (dsPermissionAsked) return
if (!SettingsStore(this).load().dsCapture) return
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val dev = usbManager.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
} ?: return
if (usbManager.hasPermission(dev)) return
dsPermissionAsked = true
usbManager.requestPermission(
dev,
PendingIntent.getBroadcast(
this, 4, // requestCode 4 — 0..3 are the SC2 stream/menu + DS stream/card grants
Intent(DS_USB_PERMISSION_ACTION).setPackage(packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
/**
* One SC2 navigation key transition from the menu-time capture (main thread) routed the
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
@@ -444,8 +297,8 @@ class MainActivity : ComponentActivity() {
/**
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] replaces this with
* [setStreamDisplayMode] while streaming (matched to the video, not to the panel maximum).
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
*/
fun setConsoleHighRefreshRate(high: Boolean) {
if (highRefreshModeId == 0) return
@@ -454,64 +307,6 @@ class MainActivity : ComponentActivity() {
}
}
/**
* Pin the panel to a display mode matching the STREAM's refresh for the session's duration
* exact rate first, else the smallest integer multiple (120 for a 60 stream: judder-free 2:1
* pulldown), else the highest available. Same-resolution modes only.
*
* The window-level mode pin is the belt to the decoder's `ANativeWindow_setFrameRate` braces:
* the surface hint alone is advisory, and several OEM refresh governors (Nothing OS's LTPO
* logic among them) ignore it entirely for third-party apps leaving a 120 Hz session
* presenting on a 60/90 Hz panel, which reads as judder + a refresh of extra latency. The
* preferredDisplayModeId is the one signal they all honor. [hz] 0 falls back to releasing
* the pin (the pre-pin behaviour).
*/
fun setStreamDisplayMode(hz: Int) {
if (hz <= 0) {
setConsoleHighRefreshRate(false)
return
}
val target = streamModeFor(hz) ?: return
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
}
/**
* The panel refresh rate a [hz] stream runs against [streamModeFor]'s pick, from the mode
* TABLE rather than `display.refreshRate`. The distinction matters: under a per-uid frame
* rate override (games get a 60 fps default on Android 15+) `refreshRate` reports the
* override, not the panel observed on-glass as a 120 Hz panel reading back as 60. The
* supported-modes list is not override-filtered. `0` when unresolvable.
*/
fun streamPanelFps(hz: Int): Int =
streamModeFor(hz)?.refreshRate?.let { kotlin.math.round(it).toInt() } ?: 0
/** The same-resolution display mode [setStreamDisplayMode] pins for a [hz] stream. */
private fun streamModeFor(hz: Int): android.view.Display.Mode? {
if (hz <= 0) return null
@Suppress("DEPRECATION")
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
val current = disp?.mode ?: return null
val sameRes = disp.supportedModes.filter {
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
}
fun multiple(rate: Float): Int {
val k = (rate / hz).toInt()
return if (k >= 2 && kotlin.math.abs(rate - hz * k) < 1f) k else 0
}
return sameRes.minWithOrNull(
compareBy(
{
when {
kotlin.math.abs(it.refreshRate - hz) < 1f -> 0 // exact
multiple(it.refreshRate) > 0 -> 1 // integer multiple — prefer smallest
else -> 2 // no relation — prefer highest so at least nothing is halved
}
},
{ if (multiple(it.refreshRate) > 0) it.refreshRate else -it.refreshRate },
),
)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handle = streamHandle
if (handle != 0L) {
@@ -529,47 +324,9 @@ class MainActivity : ComponentActivity() {
return true // consumed
}
}
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
mouseSideButton(event)?.let { back ->
when (event.action) {
KeyEvent.ACTION_DOWN ->
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
}
return true
}
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
remotePointer?.let { if (it.onKey(event)) return true }
}
// Ctrl+Alt+Shift+Q — the cross-client pointer-capture toggle chord. Swallow both
// edges of the Q (the modifiers already went over the wire, exactly like desktop).
if (event.keyCode == KeyEvent.KEYCODE_Q &&
event.isCtrlPressed && event.isAltPressed && event.isShiftPressed
) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
mouseForwarder?.toggleCapture()
}
return true
}
when (event.keyCode) {
// Whatever [mouseSideButton] didn't claim. A view-level FALLBACK BACK appears when
// a BUTTON_* press goes unconsumed, and an air-mouse remote stamps its own BACK
// SOURCE_MOUSE; both are duplicates of something already handled, and letting
// either through doubles as Android navigation and yanks the user out of the
// stream. A remote/keyboard BACK is never mouse-sourced, so it still falls through
// to the BackHandler and exits.
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
event.flags and KeyEvent.FLAG_FALLBACK != 0
) {
return true
}
// Leave these to the system even while streaming.
// (BACK above → BackHandler leaves the stream.)
KeyEvent.KEYCODE_BACK, // → BackHandler leaves the stream
KeyEvent.KEYCODE_VOLUME_UP,
KeyEvent.KEYCODE_VOLUME_DOWN,
KeyEvent.KEYCODE_VOLUME_MUTE,
@@ -631,44 +388,12 @@ class MainActivity : ComponentActivity() {
return super.dispatchKeyEvent(event)
}
/**
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
* anything else including a remote's or keyboard's BACK, which must keep exiting the stream.
*
* A mouse that carries its side buttons on the HID consumer page (AC Back / AC Forward) reaches
* us only as `KEYCODE_BACK`/`KEYCODE_FORWARD`, with no `BUTTON_BACK`/`BUTTON_FORWARD` motion
* edge behind it on those, the motion path alone leaves the side buttons dead. The event may
* even be stamped SOURCE_KEYBOARD rather than SOURCE_MOUSE, because the consumer-page collection
* is a separate sub-device, so the DEVICE is what we ask: it has to be able to be a mouse.
*
* A D-pad-capable device is excluded even when it also reports a pointer: that is an air-mouse
* remote, whose BACK is the couch user's way out of the stream and must stay navigation.
* FLAG_FALLBACK events are excluded too those are a duplicate the framework raises after an
* unconsumed BUTTON_* press, i.e. one the motion path already forwarded.
*/
private fun mouseSideButton(event: KeyEvent): Boolean? {
val back = when (event.keyCode) {
KeyEvent.KEYCODE_BACK -> true
KeyEvent.KEYCODE_FORWARD -> false
else -> return null
}
if (event.flags and KeyEvent.FLAG_FALLBACK != 0) return null
val device = event.device ?: return null
if (!device.supportsSource(InputDevice.SOURCE_MOUSE)) return null
if (device.supportsSource(InputDevice.SOURCE_DPAD)) return null
return back
}
/** Last D-pad direction synthesised from a stick/HAT — edge detection (one focus move per push). */
private var lastNavDir = 0
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (streamHandle != 0L) {
if (gamepadRouter?.onMotion(event) == true) return true
// Physical mouse (uncaptured): hover motion, wheel, button edges.
if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
mouseForwarder?.let { if (it.onGenericMotion(event)) return true }
}
return super.dispatchGenericMotionEvent(event)
}
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
@@ -706,24 +431,6 @@ class MainActivity : ComponentActivity() {
return super.dispatchGenericMotionEvent(event)
}
/**
* Mouse clicks/drags ride the TOUCH stream (the pointer is "down"). While streaming they
* belong to the mouse forwarder, never to the Compose touch-gesture layer a physical
* mouse click must be a real click at the cursor, not a synthesized trackpad tap.
*/
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (streamHandle != 0L && ev.isFromSource(InputDevice.SOURCE_MOUSE)) {
mouseForwarder?.let { if (it.onTouchEvent(ev)) return true }
}
return super.dispatchTouchEvent(ev)
}
/** The OS is the source of truth for pointer capture (it releases on focus loss). */
override fun onPointerCaptureChanged(hasCapture: Boolean) {
super.onPointerCaptureChanged(hasCapture)
mouseForwarder?.onCaptureChanged(hasCapture)
}
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
@@ -731,29 +438,4 @@ class MainActivity : ComponentActivity() {
-> true
else -> KeyEvent.isGamepadButton(kc)
}
/** Does [intent]'s link resolve to the host [live] is already streaming? */
private fun targetsHost(intent: Intent?, live: LiveStream): Boolean {
val url = deepLinkFrom(intent) ?: return false
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return false
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(this).all())
return target is HostResolution.Known && target.host.id == live.hostId
}
/** The host a live stream is on — see [liveStream]. */
data class LiveStream(val hostId: String?)
companion object {
/**
* The live stream, PROCESS-wide (null = not streaming), published by the composition that
* owns it.
*
* Deliberately not per-instance state: `launchMode` is `standard`, so a `punktfunk://`
* link arrives as a second activity instance that knows nothing about the first and the
* one thing it must know is that a session is already running. Static state is what
* crosses that gap; the process dying resets it, which is also correct.
*/
@Volatile
var liveStream: LiveStream? = null
}
}
@@ -1,238 +0,0 @@
package io.unom.punktfunk
import android.view.InputDevice
import android.view.MotionEvent
import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.roundToInt
/** True when any connected input device is a pointer (USB/BT mouse, or a touchpad driving one). */
fun hasPhysicalMouse(): Boolean = InputDevice.getDeviceIds().any { id ->
InputDevice.getDevice(id)?.supportsSource(InputDevice.SOURCE_MOUSE) == true
}
/**
* Physical mouse wire, in two modes (the iPadOS/desktop model):
* * **uncaptured** (default): hover/drag positions forward as absolute cursor moves
* (`MouseMoveAbs`, host-normalized against the window size) desktop-style pointing. The
* local cursor is hidden over the stream (StreamScreen sets a TYPE_NULL pointer icon); the
* host's own cursor, composited into the video, is the one you see.
* * **captured**: the OS pointer is grabbed ([android.view.View.requestPointerCapture]) and raw
* relative deltas forward as `MouseMove` FPS mouse-look. Engaged at stream start / by
* clicking into the stream when the "Capture pointer for games" setting is on, and toggled
* any time by Ctrl+Alt+Shift+Q (the cross-client chord). Focus loss releases it (the OS
* guarantees that); a click re-engages.
*
* Buttons ride [MotionEvent.ACTION_BUTTON_PRESS]/RELEASE edges (left/middle/right/back/forward
* wire 1/2/3/4/5), the wheel rides [MotionEvent.ACTION_SCROLL] with fractional accumulation so
* high-resolution wheels don't lose sub-notch travel. Held buttons are tracked and flushed on
* capture loss / stream exit so nothing sticks on the host. Events reach this class from
* MainActivity's dispatch overrides (uncaptured) and the capture view's captured-pointer listener.
*/
class MouseForwarder(
private val handle: Long,
private val invertScroll: Boolean,
private val captureWanted: Boolean,
/**
* The picture's rect in WINDOW coordinates where the letterboxed video actually sits, which is
* the frame absolute positions must be measured against. Events arrive from the activity's
* dispatch overrides in window coordinates, so a stream narrower than the panel needs the origin
* subtracted as well as the size divided; `null` while the surface isn't laid out yet.
*/
private val videoRect: () -> android.graphics.Rect?,
) {
/** Capture plumbing, owned by StreamScreen (the focusable capture view). */
var onRequestCapture: (() -> Unit)? = null
var onReleaseCapture: (() -> Unit)? = null
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
var captured = false
private set
/** Chord-released: no auto re-engage (start / click) until the user opts back in. */
private var userReleased = false
private val heldButtons = mutableSetOf<Int>()
private var scrollAccV = 0f
private var scrollAccH = 0f
private var moveAccX = 0f
private var moveAccY = 0f
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (captureWanted && !captured && !userReleased) {
// The engaging click: grab the pointer and swallow the click (desktop
// parity — the click that captures never reaches the host). The paired
// BUTTON_RELEASE is dropped by the held-set guard in [button].
onRequestCapture?.invoke()
return true
}
sendAbs(ev)
}
MotionEvent.ACTION_MOVE -> sendAbs(ev)
// Button edges are documented on the generic stream, but be robust to either.
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
}
return true
}
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
fun onGenericMotion(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
MotionEvent.ACTION_SCROLL -> wheel(ev)
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_EXIT -> {}
else -> return false
}
return true
}
/**
* Captured-pointer events (the view holds [android.view.View.requestPointerCapture]): x/y ARE
* the relative deltas ([InputDevice.SOURCE_MOUSE_RELATIVE]), batched samples included. A
* captured touchpad reports absolute finger coordinates instead not handled (the touch
* gesture layer is the touchpad story); returning false leaves those to the framework.
*/
fun onCapturedPointer(ev: MotionEvent): Boolean {
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
when (ev.actionMasked) {
MotionEvent.ACTION_MOVE -> {
var dx = 0f
var dy = 0f
for (i in 0 until ev.historySize) {
dx += ev.getHistoricalX(i)
dy += ev.getHistoricalY(i)
}
dx += ev.x
dy += ev.y
moveAccX += dx
moveAccY += dy
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept w/ sign
val oy = moveAccY.toInt()
if (ox != 0 || oy != 0) {
NativeBridge.nativeSendPointerMove(handle, ox, oy)
moveAccX -= ox
moveAccY -= oy
}
}
MotionEvent.ACTION_BUTTON_PRESS -> button(ev.actionButton, true)
MotionEvent.ACTION_BUTTON_RELEASE -> button(ev.actionButton, false)
MotionEvent.ACTION_SCROLL -> wheel(ev)
}
return true
}
/** Ctrl+Alt+Shift+Q: release the grab, or (re-)engage it — works even when auto-capture is off. */
fun toggleCapture() {
if (captured) {
userReleased = true
onReleaseCapture?.invoke()
} else {
userReleased = false
onRequestCapture?.invoke()
}
}
/** Auto-engage at stream start (setting on + a mouse actually present). */
fun engageFromStart() {
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
onRequestCapture?.invoke()
}
}
/** From [android.app.Activity.onPointerCaptureChanged] — the OS is the source of truth. */
fun onCaptureChanged(has: Boolean) {
captured = has
// Losing the grab (focus loss, chord) must not leave buttons held on the host.
if (!has) flushButtons()
}
/** Stream teardown: lift anything held and let the grab go. */
fun release() {
flushButtons()
if (captured) onReleaseCapture?.invoke()
}
private fun sendAbs(ev: MotionEvent) {
val r = videoRect() ?: return
val w = r.width()
val h = r.height()
if (w <= 0 || h <= 0) return
// Clamped into the picture: a pointer out on a letterbox bar has no host position of its
// own, and the edge is the honest answer for it.
NativeBridge.nativeSendPointerAbs(
handle,
(ev.x - r.left).roundToInt().coerceIn(0, w - 1),
(ev.y - r.top).roundToInt().coerceIn(0, h - 1),
w,
h,
)
}
private fun wheel(ev: MotionEvent) {
val dir = if (invertScroll) -1f else 1f
// Android: AXIS_VSCROLL + = up/away, AXIS_HSCROLL + = right — the wire's convention too.
scrollAccV += ev.getAxisValue(MotionEvent.AXIS_VSCROLL) * 120f * dir
scrollAccH += ev.getAxisValue(MotionEvent.AXIS_HSCROLL) * 120f * dir
val v = scrollAccV.toInt()
if (v != 0) {
NativeBridge.nativeSendScroll(handle, 0, v)
scrollAccV -= v
}
val h = scrollAccH.toInt()
if (h != 0) {
NativeBridge.nativeSendScroll(handle, 1, h)
scrollAccH -= h
}
}
/**
* A mouse side button that arrived as a KEY event rather than a BUTTON_* motion edge.
*
* Not every mouse reports its side buttons the same way. One that puts them on the HID button
* page (BTN_SIDE/BTN_EXTRA) gets `BUTTON_BACK`/`BUTTON_FORWARD` in the motion button state and
* lands in [button]. One that puts them on the consumer page (AC Back / AC Forward common on
* Bluetooth mice, and the shape Android TV boxes tend to see) produces ONLY synthesized
* `KEYCODE_BACK`/`KEYCODE_FORWARD` key events, so [button] never fires and the side buttons are
* dead on the wire. This is the key-shaped entry point for those.
*
* Devices that report BOTH send the key first and the motion edge second (that is the order the
* input reader synthesizes them in), so both paths funnel into the same held-set and the
* add/remove guard collapses the pair into a single wire press.
*/
fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down)
private fun button(actionButton: Int, down: Boolean) {
val b = when (actionButton) {
MotionEvent.BUTTON_PRIMARY -> 1
MotionEvent.BUTTON_TERTIARY -> 2
MotionEvent.BUTTON_SECONDARY -> 3
MotionEvent.BUTTON_BACK -> 4
MotionEvent.BUTTON_FORWARD -> 5
else -> return
}
press(b, down)
}
private fun press(b: Int, down: Boolean) {
if (down) {
// add() is false when the button is already held — the second delivery of a button
// this device reports on two paths at once. Sending the down again would double-press
// it on the host.
if (heldButtons.add(b)) NativeBridge.nativeSendPointerButton(handle, b, true)
} else if (heldButtons.remove(b)) {
// Only release what we pressed — drops the release of a swallowed engaging click
// and anything that raced a capture transition.
NativeBridge.nativeSendPointerButton(handle, b, false)
}
}
private fun flushButtons() {
heldButtons.forEach { NativeBridge.nativeSendPointerButton(handle, it, false) }
heldButtons.clear()
}
}
@@ -1,495 +0,0 @@
package io.unom.punktfunk
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
/**
* The scope switcher: the one new settings concept. Selecting a profile puts the WHOLE settings
* surface into that profile's scope there is one settings UI, never a second parallel editor
* that drifts from it. "Default settings" is the base layer every profile inherits from.
*
* A chips row rather than a menu, because on touch the scopes are worth seeing at a glance and
* there are rarely more than a handful. Managing a profile lives ON its chip: the selected one
* grows a chevron, and tapping it again opens Edit / Duplicate / Delete anchored under it. That
* replaced a lone overflow button parked after the LAST chip which meant scrolling past every
* profile to reach an action that applied to one of them, with nothing on screen saying which.
*
* With no profiles at all the row is just "Default settings" and a "New profile" chip, which is
* all the clutter a user who never wants this feature ever sees.
*/
@Composable
internal fun ProfileScopeChips(
profiles: List<StreamProfile>,
selectedId: String?,
onSelect: (String?) -> Unit,
onNew: () -> Unit,
onEdit: (StreamProfile) -> Unit,
onDuplicate: (StreamProfile) -> Unit,
onDelete: (StreamProfile) -> Unit,
modifier: Modifier = Modifier,
) {
// Which chip's menu is open — one at a time, and it closes itself when the scope changes.
var menuFor by remember { mutableStateOf<String?>(null) }
Row(
modifier = modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FilterChip(
selected = selectedId == null,
onClick = { onSelect(null) },
label = { Text("Default settings") },
)
profiles.forEach { p ->
val isSelected = selectedId == p.id
Box {
FilterChip(
selected = isSelected,
// Tap to select; tap the selected one — the one wearing the chevron — to manage
// it. The action is on the object it acts on, which is the whole point.
onClick = { if (isSelected) menuFor = p.id else onSelect(p.id) },
// The dot and the chevron ride INSIDE the label, not in the `leadingIcon` /
// `trailingIcon` slots: those reserve an 18dp icon and shrink the chip's padding
// to suit, so a chip with an accent (or with the chevron) would sit differently
// from "Default settings" beside it. In the label every chip keeps the same
// padding and the spacing is ours to set.
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
accentColor(p.accent)?.let { dot ->
AccentDot(dot, size = 8)
Spacer(Modifier.width(8.dp))
}
Text(p.name)
if (isSelected) {
Spacer(Modifier.width(2.dp))
Icon(
Icons.Filled.ArrowDropDown,
contentDescription = "Manage “${p.name}",
modifier = Modifier.size(18.dp),
)
}
}
},
)
DropdownMenu(expanded = menuFor == p.id, onDismissRequest = { menuFor = null }) {
DropdownMenuItem(text = { Text("Edit…") }, onClick = { menuFor = null; onEdit(p) })
DropdownMenuItem(
text = { Text("Duplicate") },
onClick = { menuFor = null; onDuplicate(p) },
)
DropdownMenuItem(text = { Text("Delete…") }, onClick = { menuFor = null; onDelete(p) })
}
}
}
AssistChip(
onClick = onNew,
label = { Text("New profile") },
leadingIcon = {
Icon(Icons.Filled.Add, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize))
},
)
}
}
/**
* Create or edit a profile: its name and its colour, decided together. They were two flows
* a name dialog at creation, "Change colour…" afterwards which meant every profile started
* colourless-looking until the user went hunting for a menu item, and the accent is exactly the
* signal that has to be there from the first moment (it is all a bound host card's chip and a
* pinned card's tint have to go on).
*
* Names must be unique case-insensitively: two "Work" chips in a menu are ambiguous, and a
* `punktfunk://…?profile=Work` link would have to refuse rather than guess. [taken] is the live
* duplicate check, which lets an edit keep its own name (and change only its case).
*/
@Composable
internal fun ProfileEditorDialog(
title: String,
confirmLabel: String,
initialName: String,
initialAccent: String?,
creating: Boolean,
taken: (String) -> Boolean,
onConfirm: (name: String, accent: String?) -> Unit,
onDismiss: () -> Unit,
) {
var name by remember { mutableStateOf(initialName) }
var accent by remember { mutableStateOf(initialAccent) }
val trimmed = name.trim()
val duplicate = trimmed.isNotEmpty() && taken(trimmed)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
ProfileEditorFields(
name = name,
accent = accent,
duplicate = duplicate,
creating = creating,
onNameChange = { name = it },
onAccentChange = { accent = it },
)
},
confirmButton = {
TextButton(
enabled = trimmed.isNotEmpty() && !duplicate,
onClick = { onConfirm(trimmed, accent) },
) { Text(confirmLabel) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/**
* The editor's body. Extracted from [ProfileEditorDialog] so the screenshot harness can render
* exactly these a focused text field inside a Dialog window never reaches idle under Robolectric,
* so the dialog itself is uncapturable, and an eyeballed-only layout is how this shipped once with
* the field and its caption touching.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun ProfileEditorFields(
name: String,
accent: String?,
duplicate: Boolean,
creating: Boolean,
onNameChange: (String) -> Unit,
onAccentChange: (String?) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") },
placeholder = { Text("e.g. Game, Work, Travel") },
singleLine = true,
isError = duplicate,
)
Text(
when {
duplicate -> "A profile called “${name.trim()}” already exists."
creating -> "A profile starts out inheriting every default setting. Whatever you " +
"change while it's selected becomes an override."
else -> "The colour marks this profile on host cards, where its name doesn't fit."
},
style = MaterialTheme.typography.bodySmall,
color = if (duplicate) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Text(
"Colour",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(top = 4.dp),
)
// A fixed 4×2 grid rather than a flow: eight colours wrapping to whatever fits the dialog
// landed 6-then-2, which reads as a mistake. Two even rows read as a palette. The order is
// the hue sweep from PROFILE_ACCENTS, so it looks like a spectrum rather than a bag.
//
// Each row FILLS the width, its swatches sharing it equally, so the palette's edges line up
// with the name field above it and every row is the same length. A fixed swatch size left
// the rows short of the dialog's edge and wrapped unevenly, which read as the grid having
// run out rather than as a deliberate block.
Column(
verticalArrangement = Arrangement.spacedBy(SWATCH_GAP),
modifier = Modifier.fillMaxWidth(),
) {
PROFILE_ACCENTS.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(SWATCH_GAP),
) {
row.forEach { hex ->
Swatch(
colour = accentColor(hex),
selected = accent?.equals(hex, ignoreCase = true) == true,
onClick = { onAccentChange(hex) },
modifier = Modifier.weight(1f),
)
}
}
}
}
// "No colour" is a real choice, not only an initial state — the chip then falls back to the
// theme's own accent, which is what a profile made before colours existed shows. It sits
// apart from the grid and says so in words, rather than hiding as a ninth, colourless
// circle that breaks the palette's rhythm.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable { onAccentChange(null) }
.padding(vertical = 4.dp),
) {
Swatch(colour = null, selected = accent == null, onClick = { onAccentChange(null) })
Text(
"No colour",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
/**
* One colour choice. The selected one keeps its size and grows a ring OUTSIDE the disc with a gap
* between the two, plus a check a border drawn on the disc's own edge reads as a heavier circle
* rather than as a selection, and colour-plus-check survives a reader who can't tell two of these
* hues apart. The ring's space is always reserved, so picking never nudges the grid.
*
* `null` is "no colour": the surface's own variant, outlined so it reads as an empty slot rather
* than a dark swatch.
*/
@Composable
internal fun Swatch(
colour: Color?,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier.size(SWATCH_TOTAL),
) {
val fill = colour ?: MaterialTheme.colorScheme.surfaceVariant
Box(
modifier = modifier
.aspectRatio(1f)
.clip(CircleShape)
.then(
if (selected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
)
.clickable(onClick = onClick)
.semantics { contentDescription = if (colour == null) "No colour" else "Colour" },
contentAlignment = Alignment.Center,
) {
Box(
Modifier
// Padding, not a fixed size: the disc has to scale with a swatch that shares its
// row's width, while the gap that makes the selection ring read stays constant.
.fillMaxSize()
.padding(RING_GAP)
.clip(CircleShape)
.background(fill)
.then(
if (colour == null) {
Modifier.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
} else {
Modifier
},
),
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
// These hues are all light enough that a near-black check is the readable one;
// the empty slot is dark, so it takes the surface's foreground instead.
tint = if (colour == null) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
Color(0xFF1B1633)
},
)
}
}
}
}
/**
* The palette's geometry. [SWATCHES_PER_ROW] divides [PROFILE_ACCENTS] exactly that is the whole
* reason the palette has ten colours so both rows are full. [SWATCH_TOTAL] is only the fallback
* footprint for a swatch outside the grid (the "no colour" one); in the grid a swatch takes an
* equal share of the row instead.
*/
private const val SWATCHES_PER_ROW = 5
private val SWATCH_TOTAL = 44.dp
private val RING_GAP = 5.dp
private val SWATCH_GAP = 10.dp
/**
* Deleting a profile is not destructive to anything but the profile a host bound to it falls
* back to the default settings and a card pinned to it disappears, neither of which is an error.
* The warning counts both so the consequence is stated rather than discovered.
*/
@Composable
internal fun DeleteProfileDialog(
profile: StreamProfile,
boundHosts: Int,
pinnedCards: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete “${profile.name}”?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
val consequences = buildList {
if (boundHosts > 0) {
add("$boundHosts ${plural(boundHosts, "host", "hosts")} will fall back to the default settings")
}
if (pinnedCards > 0) {
add("$pinnedCards pinned ${plural(pinnedCards, "card", "cards")} will disappear")
}
}
Text(
if (consequences.isEmpty()) {
"Nothing uses this profile."
} else {
consequences.joinToString(", and ") + "."
},
)
Text(
"The settings it overrides aren't lost anywhere else — the defaults stay " +
"exactly as they are.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = { TextButton(onClick = onConfirm) { Text("Delete") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
private fun plural(n: Int, one: String, many: String) = if (n == 1) one else many
/**
* The per-host half of profiles, inside the host's Edit sheet: which profile a plain tap uses
* (the binding the one thing that IS sticky; "Connect with ▸" on a card never rebinds), and which
* profiles get their own card in the host list.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun HostProfileBinding(
profiles: List<StreamProfile>,
boundId: String?,
onBind: (String?) -> Unit,
pins: List<String>,
onTogglePin: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val bound = profiles.firstOrNull { it.id == boundId }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = bound?.name ?: "Default settings",
onValueChange = {},
readOnly = true,
label = { Text("Profile") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Default settings") },
onClick = { onBind(null); expanded = false },
)
profiles.forEach { p ->
DropdownMenuItem(
text = { Text(p.name) },
onClick = { onBind(p.id); expanded = false },
)
}
}
}
Text(
"What a tap on this host connects with.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"Pinned cards",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(top = 8.dp),
)
Text(
"A pinned profile gets its own card beside this host — one tap instead of a menu. " +
"Pinning changes nothing about which profile is the default.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
profiles.forEach { p ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(p.name, modifier = Modifier.weight(1f))
Checkbox(checked = p.id in pins, onCheckedChange = { onTogglePin(p.id) })
}
}
}
}
/** The accent marker a profile's chip and its pinned cards wear. */
@Composable
internal fun AccentDot(color: Color, size: Int = 10) {
Box(Modifier.size(size.dp).clip(CircleShape).background(color))
}
/** `#RRGGBB` → a Compose colour, or null when the stored string isn't one (never a crash). */
internal fun accentColor(hex: String?): Color? {
val h = hex?.removePrefix("#") ?: return null
if (h.length != 6 || !h.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) return null
return runCatching { Color(h.toLong(16) or 0xFF000000L) }.getOrNull()
}
@@ -1,439 +0,0 @@
package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.security.KnownHost
import java.security.SecureRandom
import org.json.JSONObject
/**
* Client settings profiles named bundles of setting overrides applied on top of the global
* [Settings] (design/client-settings-profiles.md §4). The Kotlin mirror of
* `crates/pf-client-core/src/profiles.rs`; the model is the same on every client, so get it right
* here rather than re-deciding it.
*
* A profile overrides only the fields the user touched; everything else keeps following the global
* defaults **live**, so fixing a global once fixes it everywhere. That is why an overlay is sparse
* nullable fields rather than a snapshot copy, and why a value is written on touch and cleared only
* on an explicit "reset to default" never by diffing against the current global at save time. A
* stored value that happens to equal today's global is a legitimate *pin*: the profile keeps it
* when the global later moves.
*
* Only tier-P settings are here. Device facts (which pad this device forwards, whether its console
* UI is on) and host facts (clipboard sync, which lives on the host record) are deliberately absent
* see the design's §3 curation.
*
* Values are stored exactly as [SettingsStore] persists them ints for the compositor/gamepad wire
* bytes, enum names for the rest so there is one encoding of a setting on this platform rather
* than two. The catalog is client-local (v1 has no profile sync or export), so nothing else reads
* it.
*/
data class SettingsOverlay(
val width: Int? = null,
val height: Int? = null,
val hz: Int? = null,
val bitrateKbps: Int? = null,
val renderScale: Double? = null,
val codec: String? = null,
val hdrEnabled: Boolean? = null,
val compositor: Int? = null,
val audioChannels: Int? = null,
val micEnabled: Boolean? = null,
val echoCancel: Boolean? = null,
val touchMode: TouchMode? = null,
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val systemButtons: String? = null,
val guideGesture: String? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
* else, but here it is the one knob a marginal link wants turned off per host.
*/
val lowLatencyMode: Boolean? = null,
/** The timeline presenter's intent pair — cross-client keys, see [Settings.presentPriority]. */
val presentPriority: String? = null,
val smoothBuffer: Int? = null,
/**
* Overlay keys a newer build wrote and this one doesn't model carried through a loadsave
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
* must not erase what a newer one stored.
*/
val extra: Map<String, Any> = emptyMap(),
) {
/** The one resolution seam: this overlay on top of [base]. Pure, so it is fully testable. */
fun apply(base: Settings): Settings = base.copy(
width = width ?: base.width,
height = height ?: base.height,
hz = hz ?: base.hz,
bitrateKbps = bitrateKbps ?: base.bitrateKbps,
renderScale = renderScale ?: base.renderScale,
codec = codec ?: base.codec,
hdrEnabled = hdrEnabled ?: base.hdrEnabled,
compositor = compositor ?: base.compositor,
audioChannels = audioChannels ?: base.audioChannels,
micEnabled = micEnabled ?: base.micEnabled,
echoCancel = echoCancel ?: base.echoCancel,
touchMode = touchMode ?: base.touchMode,
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
systemButtons = systemButtons ?: base.systemButtons,
guideGesture = guideGesture ?: base.guideGesture,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
smoothBuffer = smoothBuffer ?: base.smoothBuffer,
)
/**
* Record, as overrides, every tier-P field that differs between two settings snapshots.
*
* The settings UI commits a whole `Settings` per control (`update(s.copy(codec = ))`), so it
* can't hand over a list of touched fields it hands over "what the control was showing" and
* "what it shows now", and the only field that can differ is the one the user just touched.
*
* This is NOT the diff-on-save the design rejects: the comparison is against the EFFECTIVE
* settings the control was displaying, not against the globals, so setting a value back to
* whatever the global happens to be still records an override the pin. It only ever adds
* overrides; removing one is [clear], a different, explicit operation.
*/
fun absorb(before: Settings, after: Settings): SettingsOverlay = copy(
width = if (after.width != before.width) after.width else width,
height = if (after.height != before.height) after.height else height,
hz = if (after.hz != before.hz) after.hz else hz,
bitrateKbps = if (after.bitrateKbps != before.bitrateKbps) after.bitrateKbps else bitrateKbps,
renderScale = if (after.renderScale != before.renderScale) after.renderScale else renderScale,
codec = if (after.codec != before.codec) after.codec else codec,
hdrEnabled = if (after.hdrEnabled != before.hdrEnabled) after.hdrEnabled else hdrEnabled,
compositor = if (after.compositor != before.compositor) after.compositor else compositor,
audioChannels = if (after.audioChannels != before.audioChannels) after.audioChannels else audioChannels,
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
else gamepadForwarding,
systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons,
guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
smoothBuffer = if (after.smoothBuffer != before.smoothBuffer) after.smoothBuffer else smoothBuffer,
)
/**
* Drop one override by its field name, putting the row back to inheriting. [FIELD_RESOLUTION]
* is the one alias, covering the width/height pair a single control drives. An unknown name is
* a no-op.
*/
fun clear(field: String): SettingsOverlay = when (field) {
FIELD_RESOLUTION -> copy(width = null, height = null)
"refresh_hz" -> copy(hz = null)
"bitrate_kbps" -> copy(bitrateKbps = null)
"render_scale" -> copy(renderScale = null)
"codec" -> copy(codec = null)
"hdr_enabled" -> copy(hdrEnabled = null)
"compositor" -> copy(compositor = null)
"audio_channels" -> copy(audioChannels = null)
"mic_enabled" -> copy(micEnabled = null)
"echo_cancel" -> copy(echoCancel = null)
"touch_mode" -> copy(touchMode = null)
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"system_buttons" -> copy(systemButtons = null)
"guide_gesture" -> copy(guideGesture = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
"smooth_buffer" -> copy(smoothBuffer = null)
else -> this
}
/** The field names this overlay overrides — what the settings rows draw their markers from. */
fun overridden(): Set<String> = buildSet {
if (width != null || height != null) add(FIELD_RESOLUTION)
if (hz != null) add("refresh_hz")
if (bitrateKbps != null) add("bitrate_kbps")
if (renderScale != null) add("render_scale")
if (codec != null) add("codec")
if (hdrEnabled != null) add("hdr_enabled")
if (compositor != null) add("compositor")
if (audioChannels != null) add("audio_channels")
if (micEnabled != null) add("mic_enabled")
if (echoCancel != null) add("echo_cancel")
if (touchMode != null) add("touch_mode")
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (systemButtons != null) add("system_buttons")
if (guideGesture != null) add("guide_gesture")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
if (smoothBuffer != null) add("smooth_buffer")
}
/**
* True when the profile overrides nothing "inherits everything", the state a freshly created
* profile starts in. A profile holding only a newer build's field is NOT empty.
*/
fun isEmpty(): Boolean = overridden().isEmpty() && extra.isEmpty()
internal fun toJson(): JSONObject {
val j = JSONObject()
// Unknown keys first, so a modelled field always wins over a stale carried-through one.
extra.forEach { (k, v) -> j.put(k, v) }
width?.let { j.put("width", it) }
height?.let { j.put("height", it) }
hz?.let { j.put("refresh_hz", it) }
bitrateKbps?.let { j.put("bitrate_kbps", it) }
renderScale?.let { j.put("render_scale", it) }
codec?.let { j.put("codec", it) }
hdrEnabled?.let { j.put("hdr_enabled", it) }
compositor?.let { j.put("compositor", it) }
audioChannels?.let { j.put("audio_channels", it) }
micEnabled?.let { j.put("mic_enabled", it) }
echoCancel?.let { j.put("echo_cancel", it) }
touchMode?.let { j.put("touch_mode", it.name) }
mouseMode?.let { j.put("mouse_mode", it.storedName) }
invertScroll?.let { j.put("invert_scroll", it) }
gamepad?.let { j.put("gamepad", it) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
systemButtons?.let { j.put("system_buttons", it) }
guideGesture?.let { j.put("guide_gesture", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
smoothBuffer?.let { j.put("smooth_buffer", it) }
return j
}
companion object {
/** The width/height pair, which one control drives — the reset alias, as on every client. */
const val FIELD_RESOLUTION = "resolution"
/** Keys this build models; everything else in a stored overlay is carried through. */
private val KNOWN = setOf(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
)
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
width = j.optIntOrNull("width"),
height = j.optIntOrNull("height"),
hz = j.optIntOrNull("refresh_hz"),
bitrateKbps = j.optIntOrNull("bitrate_kbps"),
renderScale = if (j.has("render_scale")) j.optDouble("render_scale") else null,
codec = j.optStringOrNull("codec"),
hdrEnabled = j.optBooleanOrNull("hdr_enabled"),
compositor = j.optIntOrNull("compositor"),
audioChannels = j.optIntOrNull("audio_channels"),
micEnabled = j.optBooleanOrNull("mic_enabled"),
echoCancel = j.optBooleanOrNull("echo_cancel"),
touchMode = j.optStringOrNull("touch_mode")
?.let { n -> TouchMode.entries.firstOrNull { it.name == n } },
mouseMode = j.optStringOrNull("mouse_mode")
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
invertScroll = j.optBooleanOrNull("invert_scroll"),
gamepad = j.optIntOrNull("gamepad"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
systemButtons = j.optStringOrNull("system_buttons"),
guideGesture = j.optStringOrNull("guide_gesture"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
presentPriority = j.optStringOrNull("present_priority"),
smoothBuffer = j.optIntOrNull("smooth_buffer"),
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
)
}
}
/**
* One named bundle of overrides. [id] is stable across renames host bindings, pinned cards and
* `punktfunk://` links all point at it, never at the name.
*/
data class StreamProfile(
val id: String,
/** User-facing and editable; unique case-insensitively (menus are ambiguous otherwise). */
val name: String,
/** `#RRGGBB` chip colour. Reserved by the schema; pinned cards tint their subtitle with it. */
val accent: String? = null,
val overrides: SettingsOverlay = SettingsOverlay(),
/** Profile keys a newer build wrote — preserved across a load→save round-trip. */
val extra: Map<String, Any> = emptyMap(),
)
/** What a `profile=` / one-off reference resolved to. Ambiguity is reported, never guessed. */
enum class ProfileResolution { FOUND, NOT_FOUND, AMBIGUOUS }
/**
* The profile catalog client-wide, not per host: "Work" applied to three hosts is one profile,
* and the per-host part is only the binding on the host record ([KnownHost.profileId]).
*
* Stored one JSON string per profile keyed by id in its own `punktfunk_profiles` prefs file the
* `KnownHostStore` pattern, and deliberately not inside the settings file, which is rewritten
* wholesale by several writers.
*/
class ProfileStore(context: Context) {
private val prefs =
context.applicationContext.getSharedPreferences("punktfunk_profiles", Context.MODE_PRIVATE)
/** Every profile, name-sorted — the order the scope switcher and the menus show. */
fun all(): List<StreamProfile> = prefs.all.values
.mapNotNull { (it as? String)?.let(::parse) }
.sortedBy { it.name.lowercase() }
fun byId(id: String): StreamProfile? = prefs.getString(id, null)?.let(::parse)
fun save(profile: StreamProfile) {
prefs.edit().putString(profile.id, encode(profile)).apply()
}
fun delete(id: String) {
prefs.edit().remove(id).apply()
}
/**
* Resolve a reference the way every surface must: exact id first, then a unique
* case-insensitive name. Two profiles sharing a name resolve to [ProfileResolution.AMBIGUOUS]
* a link or a flag naming two profiles must refuse, not pick whichever came first.
*/
fun resolve(reference: String): Pair<StreamProfile?, ProfileResolution> {
if (reference.isEmpty()) return null to ProfileResolution.NOT_FOUND
byId(reference)?.let { return it to ProfileResolution.FOUND }
val hits = all().filter { it.name.equals(reference, ignoreCase = true) }
return when (hits.size) {
1 -> hits[0] to ProfileResolution.FOUND
0 -> null to ProfileResolution.NOT_FOUND
else -> null to ProfileResolution.AMBIGUOUS
}
}
/**
* Is this name already used (case-insensitively) by a *different* profile? The create/rename
* guard [except] is the profile being renamed, so renaming "Work" to "work" is allowed.
*/
fun nameTaken(name: String, except: String? = null): Boolean =
all().any { it.name.equals(name, ignoreCase = true) && it.id != except }
/**
* The profile a connect to [host] should use: the one-off pick, else the host's binding, else
* none. [oneOff] is a reference (id or unique name); the empty string means "force the global
* defaults" — a real choice ("Connect with Default settings" on a bound host), not "unset",
* which is why it must survive as a value all the way down here. A binding whose profile was
* deleted resolves as none: never an error, never a blocked connect.
*/
fun resolveFor(host: KnownHost?, oneOff: String?): StreamProfile? = when {
oneOff != null -> resolve(oneOff).first
else -> host?.profileId?.let(::byId)
}
/** [host]'s pinned profiles, in card order, with duplicates and deleted profiles dropped. */
fun pinsFor(host: KnownHost): List<StreamProfile> =
host.pinnedProfileIds.distinct().mapNotNull(::byId)
private fun parse(s: String): StreamProfile? = runCatching {
val j = JSONObject(s)
StreamProfile(
id = j.getString("id"),
name = j.getString("name"),
accent = j.optStringOrNull("accent"),
overrides = SettingsOverlay.fromJson(j.optJSONObject("overrides") ?: JSONObject()),
extra = j.keys().asSequence()
.filter { it !in setOf("id", "name", "accent", "overrides") }
.associateWith { j.get(it) },
)
}.getOrNull()
private fun encode(p: StreamProfile): String {
val j = JSONObject()
p.extra.forEach { (k, v) -> j.put(k, v) }
j.put("id", p.id)
j.put("name", p.name)
p.accent?.let { j.put("accent", it) }
j.put("overrides", p.overrides.toJson())
return j.toString()
}
}
/**
* Chip colours a profile can wear. Chosen to stay legible on a dark surface and to be
* distinguishable from each other at the size they are actually used a 6dp dot on a chip and a
* tint on a pinned card and held at one saturation and lightness so no single swatch shouts
* over its neighbours. Deliberately NOT the presence green ([HostCard]'s online dot), which means
* something else entirely.
*
* **Ordered by hue**, so the picker reads as one sweep of the colour wheel rather than a bag of
* colours; the degrees are in the comments to keep it that way when one is swapped out. That order
* is also the order [nextAccent] hands them out in, so a user creating profiles one after another
* walks the spectrum instead of getting an arbitrary sequence.
*/
val PROFILE_ACCENTS = listOf(
"#FF8A4C", // orange 21°
"#FBBF24", // amber 45°
"#A3E635", // lime 82°
"#34D399", // green 160°
"#22D3EE", // cyan 187°
"#60A5FA", // blue 213°
"#818CF8", // indigo 239°
"#A78BFA", // violet 258°
"#F472B6", // pink 330°
"#FB7185", // rose 350°
)
/** The first accent no existing profile is using, so two profiles don't look alike by accident. */
fun nextAccent(existing: List<StreamProfile>): String {
val taken = existing.mapNotNull { it.accent?.lowercase() }.toSet()
return PROFILE_ACCENTS.firstOrNull { it.lowercase() !in taken } ?: PROFILE_ACCENTS.first()
}
/**
* A new, empty profile: it inherits everything, which is the right creation default under
* inherit-by-exception (Duplicate covers "start from that other profile"). The id is 12 lowercase
* hex characters the shape the Rust `new_profile_id` mints.
*
* [accent] is presentation, not a setting, so it does NOT inherit a profile with no colour would
* be indistinguishable from the defaults everywhere the accent is the whole signal (a bound card's
* chip, a pinned card's tint). Callers creating a profile from the UI pass [nextAccent].
*/
fun newProfile(name: String, accent: String? = null): StreamProfile =
StreamProfile(id = newProfileId(), name = name, accent = accent)
private val PROFILE_ID_RNG = SecureRandom()
fun newProfileId(): String {
val b = ByteArray(6)
PROFILE_ID_RNG.nextBytes(b)
return b.joinToString("") { "%02x".format(it) }
}
/**
* The settings a connect to [host] should use: the resolved profile's overrides on top of these
* globals, resolved ONCE per connect (matching the latch-at-connect model the "applies from the
* next session" footers promise). See [ProfileStore.resolveFor] for the precedence.
*/
fun Settings.effectiveFor(profile: StreamProfile?): Settings =
profile?.overrides?.apply(this) ?: this
// ---- org.json null-vs-absent helpers (optInt and friends can't tell 0 from "not there") ---------
private fun JSONObject.optIntOrNull(key: String): Int? = if (has(key)) optInt(key) else null
private fun JSONObject.optBooleanOrNull(key: String): Boolean? =
if (has(key)) optBoolean(key) else null
private fun JSONObject.optStringOrNull(key: String): String? =
if (has(key)) optString(key).ifEmpty { null } else null
@@ -1,193 +0,0 @@
package io.unom.punktfunk
import android.os.Handler
import android.os.Looper
import android.view.Choreographer
import android.view.KeyEvent
import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.hypot
// Hold this long on SELECT (pointer-mode toggle) / PLAY-PAUSE (keyboard toggle) for the long-press
// action instead of the tap action.
private const val LONG_PRESS_MS = 800L
// D-pad glide ballistics, in screen-widths per second: start slow enough to hit a close button,
// ramp over RAMP_S seconds of continuous hold so crossing the desktop doesn't take all day.
private const val SPEED_MIN = 0.14f
private const val SPEED_MAX = 0.70f
private const val RAMP_S = 1.2f
/**
* Android TV remote as a pointer the Android analogue of the Apple client's Siri-remote pointer,
* adapted for D-pad-only remotes (most Android TV remotes have no touch surface). For the
* "TV as a desktop client" use case, where a plain remote is often the only thing in hand.
*
* While streaming on a TV, **hold SELECT 0.8 s** to toggle pointer mode. While active:
* * D-pad (held) glides the host cursor with ramping acceleration (relative `MouseMove`,
* Choreographer-paced, diagonal-normalized);
* * SELECT tap = left click; PLAY/PAUSE tap = right click (Siri-remote parity);
* * PLAY/PAUSE held = toggle the on-screen keyboard; BACK = leave pointer mode
* (a second BACK then leaves the stream as usual).
* While inactive, everything except the SELECT long-press passes through untouched (D-pad =
* arrow keys, SELECT tap = Enter synthesized on release, since the down was held back to
* disambiguate the long-press).
*
* Only consulted for non-gamepad key events on TV devices (MainActivity gates the calls); all
* state lives on the main thread.
*/
class RemotePointer(
private val handle: Long,
private val surfaceWidth: () -> Int,
private val onActiveChanged: (Boolean) -> Unit,
private val onKeyboardToggle: () -> Unit,
) {
var active = false
private set
private val handler = Handler(Looper.getMainLooper())
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
private var moveAccX = 0f
private var moveAccY = 0f
private var lastFrameNs = 0L
private var rampSec = 0f
private var tickerRunning = false
private var centerLongFired = false
private var playLongFired = false
private val centerLong = Runnable {
centerLongFired = true
toggle()
}
private val playLong = Runnable {
playLongFired = true
onKeyboardToggle()
}
private val frame = object : Choreographer.FrameCallback {
override fun doFrame(nowNs: Long) {
if (!tickerRunning) return
if (held.isEmpty() || !active) {
tickerRunning = false
return
}
val dt = if (lastFrameNs == 0L) {
1f / 60f
} else {
((nowNs - lastFrameNs) / 1e9f).coerceIn(0.001f, 0.1f)
}
lastFrameNs = nowNs
rampSec += dt
var vx = 0f
var vy = 0f
if (KeyEvent.KEYCODE_DPAD_LEFT in held) vx -= 1f
if (KeyEvent.KEYCODE_DPAD_RIGHT in held) vx += 1f
if (KeyEvent.KEYCODE_DPAD_UP in held) vy -= 1f
if (KeyEvent.KEYCODE_DPAD_DOWN in held) vy += 1f
val mag = hypot(vx, vy)
if (mag > 0f) {
val w = surfaceWidth().coerceAtLeast(640)
val speed = w * (SPEED_MIN + (SPEED_MAX - SPEED_MIN) * (rampSec / RAMP_S).coerceAtMost(1f))
moveAccX += vx / mag * speed * dt
moveAccY += vy / mag * speed * dt
val ox = moveAccX.toInt() // truncate toward zero — sub-pixel remainder kept
val oy = moveAccY.toInt()
if (ox != 0 || oy != 0) {
NativeBridge.nativeSendPointerMove(handle, ox, oy)
moveAccX -= ox
moveAccY -= oy
}
}
Choreographer.getInstance().postFrameCallback(this)
}
}
/** One remote key event; true = consumed. Ignore key repeats — the ticker owns motion. */
fun onKey(event: KeyEvent): Boolean {
val down = event.action == KeyEvent.ACTION_DOWN
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER -> {
if (down) {
if (event.repeatCount == 0) {
centerLongFired = false
handler.postDelayed(centerLong, LONG_PRESS_MS)
}
} else {
handler.removeCallbacks(centerLong)
if (!centerLongFired) {
if (active) {
click(1)
} else {
// The down was held back to disambiguate the long-press, so the
// normal path never saw it — synthesize the Enter here instead.
NativeBridge.nativeSendKey(handle, 0x0D, true, 0)
NativeBridge.nativeSendKey(handle, 0x0D, false, 0)
}
}
}
return true
}
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT,
-> {
if (!active) return false
if (down) {
if (held.add(event.keyCode) && held.size == 1) startTicker()
} else {
held.remove(event.keyCode)
}
return true
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
if (!active) return false // inactive: the media-key VK path owns it
if (down) {
if (event.repeatCount == 0) {
playLongFired = false
handler.postDelayed(playLong, LONG_PRESS_MS)
}
} else {
handler.removeCallbacks(playLong)
if (!playLongFired) click(3)
}
return true
}
KeyEvent.KEYCODE_BACK -> {
if (!active) return false
if (!down) toggle() // leave pointer mode; the next BACK leaves the stream
return true
}
else -> return false
}
}
/** Stream teardown: stop timers/ticker; nothing wire-held to flush (clicks are edges). */
fun release() {
handler.removeCallbacks(centerLong)
handler.removeCallbacks(playLong)
active = false
held.clear()
tickerRunning = false
}
private fun toggle() {
active = !active
if (!active) {
held.clear()
tickerRunning = false
}
onActiveChanged(active)
}
private fun startTicker() {
rampSec = 0f
lastFrameNs = 0L
if (!tickerRunning) {
tickerRunning = true
Choreographer.getInstance().postFrameCallback(frame)
}
}
private fun click(button: Int) {
NativeBridge.nativeSendPointerButton(handle, button, true)
NativeBridge.nativeSendPointerButton(handle, button, false)
}
}
@@ -1,7 +1,6 @@
package io.unom.punktfunk
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
import android.util.Log
import android.view.Display
@@ -34,31 +33,6 @@ data class Settings(
val hdrEnabled: Boolean = true,
val compositor: Int = 0,
val gamepad: Int = 0,
/**
* Forward this device's controllers to the host at all. Default on that was the
* unconditional behaviour before this became a setting.
*
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
* there gives the host two controllers for one pair of hands, and games read both. It also
* stops this device CLAIMING the pad a device held open is one a passthrough tool can't
* bind which is why it gates the USB capture paths, not just the wire sends.
*/
val gamepadForwarding: Boolean = true,
/**
* Where the guide (Xbox/PS) and misc/share presses land while streaming the
* cross-client `system_buttons` key: `"auto"` (forward on Android the press reaches
* the app on most devices) | `"forward"` | `"local"`.
*/
val systemButtons: String = "auto",
/**
* The hold-Select guide gesture the cross-client `guide_gesture` key: `"auto"` (off
* on Android) | `"on"` | `"off"`. On: holding Select alone 350 ms sends the HOST's
* guide, down until release (long hold = the host's long-press a Gaming-Mode host's
* QAM); a Select tap is delivered on release, slightly delayed. For devices whose
* shell intercepts the physical guide button.
*/
val guideGesture: String = "auto",
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
@@ -67,15 +41,6 @@ data class Settings(
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
val codec: String = "auto",
val micEnabled: Boolean = false,
/**
* Cancel acoustic echo on the mic uplink (plus noise suppression): the capture opens under
* the VoiceCommunication preset so the HAL's own AEC/NS process it, with the Java effects
* attached as a backstop where available. On by default a phone/tablet plays the game audio
* out of the same device its mic hears, so without this the host hears its own stream back.
* Turn off for a headset-only setup where the untouched full-band capture sounds better.
* Only meaningful while [micEnabled] is on.
*/
val echoCancel: Boolean = true,
/**
* How much the in-stream stats overlay shows see [StatsVerbosity]. Defaults to
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
@@ -105,16 +70,6 @@ data class Settings(
* client's `libraryEnabled`.
*/
val libraryEnabled: Boolean = true,
/**
* Which colour family the console (gamepad) UI's living backdrop drifts through the
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
* desktop console's and the Apple client's under the same names. Presentation only: nothing
* about a stream depends on it, so it is a device preference and never part of a profile.
* An unknown value reads as the default rather than failing a newer client may have shipped
* a palette this build doesn't know.
*/
val uiPalette: String = "violet",
/**
* "Low-latency mode" the master switch over the latency pipeline: the async decode loop
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
@@ -128,19 +83,6 @@ data class Settings(
* feeds a queue that only grows.
*/
val lowLatencyMode: Boolean = true,
/**
* The timeline presenter's intent the cross-client `present_priority` pair (the Apple
* client's "Prioritize" picker, same stored values): `"latency"` (default) = newest-wins,
* a frame reaches glass the instant the glass budget opens; `"smooth"` = a small FIFO
* drained one frame per vsync, absorbing network/decode jitter at one refresh of added
* display latency per buffered frame. Anything unrecognized resolves to latency.
*/
val presentPriority: String = "latency",
/**
* The smoothness buffer depth (`smooth_buffer`): 0 = Automatic (2 frames), else 1..3.
* Only meaningful when [presentPriority] is `"smooth"`.
*/
val smoothBuffer: Int = 0,
/**
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
@@ -158,16 +100,6 @@ data class Settings(
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
*/
val rumbleOnPhone: Boolean = false,
/**
* Opt-in: use this phone's own gyroscope as controller 1's motion when the forwarded pad has
* none of its own for clip-on gamepads without an IMU, where the phone body moves with the
* player's hands. The rumble mirror's sibling, data flowing the other way. Off by default;
* read once per session by StreamScreen (it starts a [io.unom.punktfunk.kit.DeviceGyro] only
* when set), and the mirror stands down by itself whenever wire pad 0 is fed by a capture
* link (USB DualSense / SC2 pads with a real gyro). The toggle is hidden on devices
* without a gyroscope (TVs), where this would be a silent no-op.
*/
val gyroOnPhone: Boolean = false,
/**
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
@@ -177,75 +109,11 @@ data class Settings(
* setup where the OS-level pad (lizard mode) is preferred.
*/
val sc2Capture: Boolean = true,
/**
* Capture a USB-connected Sony controller (DualSense / DualSense Edge / DualShock 4) and
* drive it directly: the app claims the pad's HID interface and renders the host's feedback
* by writing USB output reports rumble works on every phone (no kernel force-feedback
* driver needed), and adaptive triggers + lightbar + player LEDs work at all (Android has no
* platform API for any of them). ON by default it engages only when such a pad is attached
* over USB at stream start; uncaptured (toggle off / no permission / Bluetooth) the pad stays
* on the ordinary InputDevice path. USB only: Android exposes no raw path to a Bluetooth
* Classic pad, which is also why Sony's own Remote Play has no Android trigger support.
*/
val dsCapture: Boolean = true,
/**
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
*
* The pad's own 4-channel audio device carries them, driven directly over usbfs Android's
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
* ordinary rumble (tier C), which on this client already drives the same actuators.
*/
val padHaptics: Boolean = true,
/**
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics]
* the host sends the two as separate streams and either can play alone. Off by default: the
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
* duplicates audio they are already hearing.
*/
val padSpeaker: Boolean = false,
/**
* How a physical mouse drives the host the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
* to the stream ([android.view.View.requestPointerCapture]) and forwards raw relative motion.
* Read once per session by StreamScreen; Ctrl+Alt+Shift+Q flips the capture live either way.
*/
val mouseMode: MouseMode = MouseMode.DESKTOP,
/**
* Flip scroll direction the mouse wheel and the two-finger touch scroll both. Parity with
* the Apple/GTK clients' "Invert scroll direction".
*/
val invertScroll: Boolean = false,
// NOTE: clipboard sync is NOT here. It is a decision about a HOST, not about this device or
// this stream (design/client-settings-profiles.md §3, tier H), so it lives on the host record
// — see `KnownHost.clipboardSync`. It used to be a global here; `KnownHostStore.migrate`
// copied that value onto every saved host and retired the key.
)
/** [Settings.touchMode] values; persisted by name. */
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
/**
* How a physical mouse drives the host the cross-client mouse model (the Rust `MouseMode`,
* persisted as the same lowercase names). Only meaningful with a mouse attached.
* - [CAPTURE] pointer lock: relative deltas, the local cursor hidden, the host's cursor the only
* one you see. The game model, and the desktop clients' default.
* - [DESKTOP] uncaptured absolute pointing: the cursor enters and leaves the stream freely. The
* remote-desktop model, and Android's default (a phone/TV is far more often driven by touch or a
* pad than by a locked mouse, and this is what the platform did before the setting existed).
*/
enum class MouseMode(val storedName: String, val label: String) {
CAPTURE("capture", "Capture (games)"),
DESKTOP("desktop", "Desktop (absolute)"),
}
/**
* Stats-overlay detail tiers, in cycling order (persisted by name). Each tier is a strict superset
* of the previous one, so toning down never hides a number a lower tier keeps:
@@ -281,13 +149,9 @@ class SettingsStore(context: Context) {
hdrEnabled = prefs.getBoolean(K_HDR, true),
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
@@ -304,24 +168,10 @@ class SettingsStore(context: Context) {
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
gyroOnPhone = prefs.getBoolean(K_GYRO_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
// default was false, which IS `desktop` — so an install that never touched the toggle
// lands where it already was.
?: if (prefs.getBoolean(K_POINTER_CAPTURE, false)) MouseMode.CAPTURE else MouseMode.DESKTOP,
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
)
fun save(s: Settings) {
@@ -334,30 +184,17 @@ class SettingsStore(context: Context) {
.putBoolean(K_HDR, s.hdrEnabled)
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
.putString(K_GUIDE_GESTURE, s.guideGesture)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putString(K_UI_PALETTE, s.uiPalette)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.putString(K_PRESENT_PRIORITY, s.presentPriority)
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_GYRO_ON_PHONE, s.gyroOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
}
@@ -370,13 +207,9 @@ class SettingsStore(context: Context) {
const val K_HDR = "hdr_enabled"
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
const val K_SYSTEM_BUTTONS = "system_buttons"
const val K_GUIDE_GESTURE = "guide_gesture"
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
const val K_ECHO_CANCEL = "echo_cancel"
const val K_STATS_VERBOSITY = "stats_verbosity"
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced read once for migration, never
@@ -385,7 +218,6 @@ class SettingsStore(context: Context) {
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_LIBRARY = "library_enabled"
const val K_UI_PALETTE = "ui_palette"
/**
* Bumped AGAIN to restart every install at the new default (ON). History: the original
@@ -398,51 +230,23 @@ class SettingsStore(context: Context) {
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
*/
const val K_LOW_LATENCY = "low_latency_mode_v2"
const val K_PRESENT_PRIORITY = "present_priority"
const val K_SMOOTH_BUFFER = "smooth_buffer"
const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_GYRO_ON_PHONE = "gyro_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_PAD_HAPTICS = "pad_haptics"
const val K_PAD_SPEAKER = "pad_speaker"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
const val K_POINTER_CAPTURE = "pointer_capture"
const val K_INVERT_SCROLL = "invert_scroll"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode"
}
}
/**
* The display to probe for capability/mode queries: the context's own display when it is already
* associated with one, else the DEFAULT display via [DisplayManager]. A `punktfunk://` deep-link
* COLD start can reach the connect before the activity is attached to its display
* `context.display` then throws, and the old `false`/1080p60 fallbacks silently downgraded the
* whole session (no HDR advertised / non-native mode) with nothing in the log. The default
* display IS the panel on phones and TVs; the activity-display distinction only matters on
* multi-display setups, where the attached path still wins whenever it is available.
*/
private fun probeDisplay(context: Context): Display? =
runCatching { context.display }.getOrNull()
?: runCatching {
context.getSystemService(DisplayManager::class.java)
?.getDisplay(Display.DEFAULT_DISPLAY)
}.getOrNull().also {
if (it != null) Log.i("punktfunk", "display probe: context unattached — using DEFAULT_DISPLAY")
}
/**
* The device's native display mode as a landscape `(width, height, hz)` the long edge is the
* width, since we stream a desktop. Falls back to 1920×1080@60 if no display can be read at all
* (see [probeDisplay] for the cold-start fallback that makes that a last resort).
* width, since we stream a desktop. Falls back to 1920×1080@60 if the display can't be read.
* [context] must be a visual (Activity) context.
*/
fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
val display = probeDisplay(context) ?: return Triple(1920, 1080, 60)
// getDisplay() throws on a non-visual context rather than returning null — guard it.
val display = runCatching { context.display }.getOrNull() ?: return Triple(1920, 1080, 60)
val mode = display.mode
val w = mode.physicalWidth
val h = mode.physicalHeight
@@ -450,96 +254,6 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
return Triple(maxOf(w, h), minOf(w, h), hz)
}
/**
* Sentinel [Settings.width]/[Settings.height] meaning "the native mode, narrowed so the picture
* clears the display cutout and the rounded corners" — resolved at connect by [safeDisplayMode],
* exactly as `0` is resolved by [nativeDisplayMode]. Negative, so it can never collide with a real
* size; distinct from the UI's `-1` "Custom…" sentinel.
*/
const val SAFE_AREA_MODE = -2
/**
* Safe-area stream geometry the pure part, so it is unit-testable without a Display.
*
* The phone clips the picture in HARDWARE: the cutout (notch / punch-hole) and the four rounded
* corners eat whatever the stream draws under them. [StreamScreen] deliberately draws edge-to-edge
* (`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS`) and centres the video at its own aspect ratio
* (`Modifier.aspectRatio`), so which pixels survive is decided purely by the mode's aspect:
*
* * A 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe
* regions which is why the presets have always "just worked".
* * The NATIVE mode has the panel's own aspect, so it fills every pixel, cutout and corners
* included. That is the mode that loses its corners.
*
* So asking the host for a mode narrower by the unsafe inset is the entire fix: the existing
* aspect-fit centres it inside the safe region, and pointer mapping follows for free (MouseInput
* derives the picture rect from the live video size, not from the window).
*/
object SafeArea {
/** The host rejects odd dimensions and anything under 320 px wide (`validate_dimensions`). */
const val MIN_WIDTH = 320
/**
* [nativeWidth] reduced by [perSideInsetPx] on each side, even-floored and clamped to the
* host's floor. Height is deliberately untouched: under aspect-fit only one axis can bind, and
* on a landscape phone that axis is always the horizontal one insetting height as well would
* shrink the picture without uncovering anything.
*/
fun insetWidth(nativeWidth: Int, perSideInsetPx: Int): Int {
val inset = perSideInsetPx.coerceAtLeast(0)
return (nativeWidth - inset * 2).coerceAtLeast(MIN_WIDTH) / 2 * 2
}
}
/**
* The per-side inset, in pixels, that the **landscape** stream must clear on this display.
*
* Two contributions, and the larger wins:
* * **The cutout.** [DisplayCutout] is rotation-aware, so in landscape the housing shows up on
* `left`/`right`. The settings screen may be portrait though, where the very same housing is
* reported on `top`/`bottom` and the horizontal insets read zero which would compute "no inset
* needed" for exactly the devices that need one. The stream is always landscape, so a vertical
* inset now becomes a horizontal one then: fall back to it.
* * **The rounded corners.** These are NOT part of the cutout insets. For a FULL-HEIGHT picture the
* horizontal clearance a corner of radius `r` needs is exactly `r`: at the topmost row the
* display boundary sits at `x = r`, so anything left of that is clipped. Not conservative it is
* the precise requirement for a picture that spans the full height.
*
* `0` when the display has neither, which makes the safe mode identical to the native one.
*/
private fun displaySideInsetPx(context: Context): Int {
val display = probeDisplay(context) ?: return 0
var inset = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
display.cutout?.let { cut ->
val horizontal = maxOf(cut.safeInsetLeft, cut.safeInsetRight)
val vertical = maxOf(cut.safeInsetTop, cut.safeInsetBottom)
inset = maxOf(inset, if (horizontal > 0) horizontal else vertical)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
for (position in intArrayOf(
android.view.RoundedCorner.POSITION_TOP_LEFT,
android.view.RoundedCorner.POSITION_TOP_RIGHT,
android.view.RoundedCorner.POSITION_BOTTOM_LEFT,
android.view.RoundedCorner.POSITION_BOTTOM_RIGHT,
)) {
display.getRoundedCorner(position)?.let { inset = maxOf(inset, it.radius) }
}
}
return inset
}
/**
* The native mode narrowed to clear the cutout and the rounded corners the [SAFE_AREA_MODE]
* resolution, as a landscape `(width, height, hz)`. Same height and refresh as [nativeDisplayMode];
* only the width moves.
*/
fun safeDisplayMode(context: Context): Triple<Int, Int, Int> {
val (w, h, hz) = nativeDisplayMode(context)
return Triple(SafeArea.insetWidth(w, displaySideInsetPx(context)), h, hz)
}
/**
* True when this device's display can actually present HDR10, so we should advertise HDR to the
* host. On an SDR panel we advertise `0` instead the host then sends a proper 8-bit BT.709 stream
@@ -547,12 +261,7 @@ fun safeDisplayMode(context: Context): Triple<Int, Int, Int> {
* capability gate the Apple/Windows clients apply.
*/
fun displaySupportsHdr(context: Context): Boolean {
val display = probeDisplay(context)
if (display == null) {
// Distinguishable from a real SDR verdict — a silent `false` here cost an HDR session.
Log.w("punktfunk", "display HDR probe: no display reachable — advertising SDR")
return false
}
val display = runCatching { context.display }.getOrNull() ?: return false
val types = buildSet {
// API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The
// deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices
@@ -574,21 +283,12 @@ fun displaySupportsHdr(context: Context): Boolean {
return supported
}
/**
* Resolve [Settings] (with its `0`=native and [SAFE_AREA_MODE] placeholders) to the concrete mode to
* request. The safe-area sentinel is checked first because it resolves BOTH axes together it is one
* mode, not an independent width and height, and mixing half of it with a native height would ask
* for a size neither sentinel means.
*/
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
val base = if (width == SAFE_AREA_MODE && height == SAFE_AREA_MODE) {
safeDisplayMode(context)
} else {
nativeDisplayMode(context)
}
val w = if (width > 0) width else base.first
val h = if (height > 0) height else base.second
val hz = if (hz > 0) hz else base.third
val native = nativeDisplayMode(context)
val w = if (width > 0) width else native.first
val h = if (height > 0) height else native.second
val hz = if (hz > 0) hz else native.third
return Triple(w, h, hz)
}
@@ -642,10 +342,9 @@ val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it)
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
/** (width, height, label). `(0,0)` = native display; [SAFE_AREA_MODE] = native minus the cutout. */
/** (width, height, label). `(0,0)` = native display. */
val RESOLUTION_OPTIONS = listOf(
Triple(0, 0, "Native display"),
Triple(SAFE_AREA_MODE, SAFE_AREA_MODE, "Native display (safe area)"),
Triple(1280, 720, "1280 × 720"),
Triple(1920, 1080, "1920 × 1080"),
Triple(2560, 1440, "2560 × 1440"),
@@ -677,56 +376,21 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
8 to "7.1 Surround",
)
/**
* (stored value, label) for the preferred video codec the cross-client table (the Rust
* `CODECS`), so a value another client or a profile stored is always representable here.
* `"auto"` = host decides.
*
* Two rows are capability-gated by [codecOptionsFor] rather than dropped from the table: `"av1"`
* needs a real `video/av01` decoder on this device, and `"pyrowave"` needs a PyroWave decoder,
* which this platform does not have at all (it is a Vulkan-compute codec living in `pf-presenter`;
* the JNI client decodes through MediaCodec and never advertises the bit, so preferring it would
* be a dead setting that silently resolves to HEVC).
*/
/** (stored value, label) for the preferred video codec. `"auto"` = host decides. The `"av1"` row
* only makes sense on a device with a real AV1 decoder SettingsScreen filters it out otherwise. */
val CODEC_OPTIONS = listOf(
"auto" to "Automatic",
"hevc" to "HEVC (H.265)",
"h264" to "H.264 (AVC)",
"av1" to "AV1",
"pyrowave" to "PyroWave (wired LAN)",
)
/**
* [CODEC_OPTIONS] minus the rows this device can't decode a preference the client never
* advertises is a setting that does nothing. [stored] is the currently persisted value, which is
* always kept selectable so the selection can be rendered (the don't-clobber rule: a codec chosen
* on another device, or by a newer build, must survive being looked at here).
*/
fun codecOptionsFor(stored: String, av1Capable: Boolean): List<Pair<String, String>> =
CODEC_OPTIONS.filter { (v, _) ->
when (v) {
"av1" -> av1Capable || stored == "av1"
"pyrowave" -> stored == "pyrowave" // no PyroWave decoder on Android — see above
else -> true
}
}
/** Resolved [Settings.systemButtons]: forward the raw guide/misc presses? Auto = forward on
* Android the press reaches the app on most devices, and where the shell shows its own UI
* for it that's the shell's business. */
fun Settings.systemButtonsForward(): Boolean = systemButtons != "local"
/** Resolved [Settings.guideGesture]: auto = OFF on Android (the raw press already reaches the
* host); "on" is for devices whose shell intercepts the physical guide button. */
fun Settings.guideGestureEnabled(): Boolean = guideGesture == "on"
/** The [Settings.codec] string as a `quic::CODEC_*` preference byte (`0` = auto). H264=1, HEVC=2,
* AV1=4, PyroWave=8 (never decodable here, but the byte is the shared contract). */
* AV1=4. */
fun Settings.preferredCodec(): Int = when (codec) {
"h264" -> 1
"hevc" -> 2
"av1" -> 4
"pyrowave" -> 8
else -> 0
}
@@ -755,29 +419,6 @@ val COMPOSITOR_OPTIONS = listOf(
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
/** [Settings.presentPriority] as the wire int `nativeStartVideo` takes (0 = latency, 1 = smooth).
* Unrecognized values resolve to latency same rule as the Apple client. */
fun Settings.presentPriorityWire(): Int = if (presentPriority == "smooth") 1 else 0
/** (stored value, label) for the presenter-intent picker — the Apple client's table verbatim. */
val PRESENT_PRIORITY_OPTIONS = listOf(
"latency" to "Lowest latency",
"smooth" to "Smoothness",
)
/** (frames, label) for the smoothness-buffer picker; each buffered frame one refresh interval
* of jitter absorbed for one interval of added display latency ([hz] labels the cost). */
fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
val periodMs = 1000.0 / maxOf(24, hz)
fun cost(frames: Int) = "+%.0f ms".format(periodMs * frames)
return listOf(
0 to "Automatic",
1 to "1 frame (${cost(1)})",
2 to "2 frames (${cost(2)})",
3 to "3 frames (${cost(3)})",
)
}
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -785,34 +426,11 @@ val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TOUCH to "Touch passthrough",
)
/** (mode, label) for the physical-mouse model. */
val MOUSE_MODE_OPTIONS = MouseMode.entries.map { it to it.label }
/**
* (GamepadPref wire byte, label) for the emulated pad the host creates. NOT positional: the wire
* bytes are `punktfunk_core::config::GamepadPref` (see `Gamepad.PREF_*`), and Steam Deck is `6`
* with `5` (the classic Steam Controller) deliberately not offered the same subset the desktop
* clients' picker shows.
*/
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
val GAMEPAD_OPTIONS = listOf(
io.unom.punktfunk.kit.Gamepad.PREF_AUTO to "Automatic",
io.unom.punktfunk.kit.Gamepad.PREF_XBOX360 to "Xbox 360",
io.unom.punktfunk.kit.Gamepad.PREF_DUALSENSE to "DualSense",
io.unom.punktfunk.kit.Gamepad.PREF_XBOXONE to "Xbox One",
io.unom.punktfunk.kit.Gamepad.PREF_DUALSHOCK4 to "DualShock 4",
io.unom.punktfunk.kit.Gamepad.PREF_STEAMDECK to "Steam Deck",
)
/** (stored `system_buttons` value, label) — where the guide/share presses land while streaming. */
val SYSTEM_BUTTON_OPTIONS = listOf(
"auto" to "Automatic",
"forward" to "Send to host",
"local" to "This device",
)
/** (stored `guide_gesture` value, label) — the hold-Select guide gesture. */
val GUIDE_GESTURE_OPTIONS = listOf(
"auto" to "Automatic",
"on" to "On",
"off" to "Off",
"Automatic",
"Xbox 360",
"DualSense",
"Xbox One",
"DualShock 4",
)
File diff suppressed because it is too large Load Diff
@@ -1,187 +0,0 @@
package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* The network speed test: measure the path to a host **over the real data plane** connect, ask
* the host to burst filler for two seconds, report goodput and loss, and offer to apply a
* recommended bitrate in one tap.
*
* The measurement is the easy half. The half that was wrong everywhere for a long time is *where
* the answer goes*: a measured bitrate belongs in the layer the tested host actually resolves
* bitrate from (design/client-settings-profiles.md §5.3). Writing it to the global the
* long-standing behaviour meant measuring the slow retro box downstairs quietly re-tuned the
* desktop too. [SpeedTestTarget] is that decision, and because it depends only on the host it is
* known *before* the result lands, so the button can say where it will write.
*/
sealed interface SpeedTestTarget {
/** No profile in play — the global default, i.e. what has always happened. */
data object Global : SpeedTestTarget
/** The profile this host uses already overrides bitrate, so that override is what it reads. */
data class Profile(val profile: StreamProfile) : SpeedTestTarget
/**
* The host uses a profile, but that profile inherits bitrate. Writing either layer is
* defensible, so the user gets both buttons rather than us guessing which they meant.
*/
data class Ask(val profile: StreamProfile) : SpeedTestTarget
companion object {
/**
* Resolved exactly the way a connect resolves it (see [ProfileStore.resolveFor]): the
* one-off pick this test was started from a pinned card carries one else the host's
* binding. A dangling binding resolves as no profile here too.
*/
fun resolve(
host: KnownHost?,
oneOffProfile: String?,
profiles: ProfileStore,
): SpeedTestTarget {
val profile = profiles.resolveFor(host, oneOffProfile) ?: return Global
return if (profile.overrides.bitrateKbps != null) Profile(profile) else Ask(profile)
}
}
}
/** Where the speed test is: it connects, it measures, then it has an answer or a reason. */
sealed interface SpeedTestPhase {
data object Connecting : SpeedTestPhase
data object Measuring : SpeedTestPhase
data class Failed(val message: String) : SpeedTestPhase
/**
* [recommendedKbps] is 70 % of the measured throughput headroom for the FEC overhead and for
* the loss a real stream will meet, the same margin the desktop clients apply.
*/
data class Done(
val throughputKbps: Int,
val lossPct: Double,
val recommendedKbps: Int,
) : SpeedTestPhase {
val measuredMbps: Double get() = throughputKbps / 1000.0
val recommendedMbps: Double get() = recommendedKbps / 1000.0
}
}
/**
* Connect to [host]:[port], run one burst, and report. Blocking-ish (it suspends on IO) call
* from a coroutine; [onPhase] is invoked as it progresses so the dialog can narrate.
*
* The connect is deliberately minimal: 1280×720@60, no launch, host-default bitrate. Nothing here
* presents a frame, and asking a host to spin up a 4K encode for a three-second measurement would
* be rude to it and slower for us.
*/
suspend fun runSpeedTest(
context: Context,
identity: ClientIdentity,
host: String,
port: Int,
pinHex: String,
onPhase: (SpeedTestPhase) -> Unit,
) {
onPhase(SpeedTestPhase.Connecting)
val probeSettings = Settings(
width = 1280,
height = 720,
hz = 60,
bitrateKbps = 0, // the host's default: this measures the link, not an encoder setting
hdrEnabled = false,
audioChannels = 2,
)
val handle = connectToHost(
context, probeSettings, identity, host, port, pinHex,
launch = null, timeoutMs = SPEED_TEST_CONNECT_TIMEOUT_MS,
)
if (handle == 0L) {
onPhase(
SpeedTestPhase.Failed(
ConnectErrors.connectMessage(NativeBridge.nativeTakeLastError(), requestAccess = false),
),
)
return
}
try {
onPhase(SpeedTestPhase.Measuring)
if (!NativeBridge.nativeSpeedTest(handle, TARGET_KBPS, BURST_MS)) {
onPhase(SpeedTestPhase.Failed("The host wouldn't start a measurement."))
return
}
var waited = 0
while (waited < POLL_BUDGET_MS) {
delay(POLL_INTERVAL_MS.toLong())
waited += POLL_INTERVAL_MS
val r = NativeBridge.nativeProbeResult(handle)
if (r == null || r.size < 3) {
onPhase(SpeedTestPhase.Failed("The session ended before the measurement finished."))
return
}
if (r[0] == 0.0) continue
// Let the last UDP shards land before tearing the session down, or the tail of the
// burst is counted as loss that never happened.
delay(SETTLE_MS)
val settled = NativeBridge.nativeProbeResult(handle) ?: r
val kbps = settled[1].toInt()
onPhase(
SpeedTestPhase.Done(
throughputKbps = kbps,
lossPct = settled[2],
// Integer arithmetic in this order (not `* 0.7`) so the recommendation matches
// the desktop clients' to the kilobit.
recommendedKbps = kbps / 10 * 7,
),
)
return
}
onPhase(SpeedTestPhase.Failed("The measurement timed out."))
} finally {
withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
}
}
/**
* Write a measured bitrate into the layer [target] names. [toProfile] picks the side of a
* [SpeedTestTarget.Ask]; it is ignored for the other targets, which have only one answer. Returns
* a human phrase naming where it went, for the confirmation.
*/
fun applySpeedTestResult(
kbps: Int,
target: SpeedTestTarget,
toProfile: Boolean,
profiles: ProfileStore,
settings: Settings,
onGlobalChange: (Settings) -> Unit,
): String {
val profile = when (target) {
is SpeedTestTarget.Profile -> target.profile
is SpeedTestTarget.Ask -> target.profile.takeIf { toProfile }
SpeedTestTarget.Global -> null
}
return if (profile == null) {
onGlobalChange(settings.copy(bitrateKbps = kbps))
"the default bitrate"
} else {
// Only the bitrate moves — a speed test has nothing to say about the rest of the profile.
// Re-read rather than trusting the copy this dialog was opened with, so a rename or another
// edit in between isn't clobbered.
val live = profiles.byId(profile.id) ?: profile
profiles.save(live.copy(overrides = live.overrides.copy(bitrateKbps = kbps)))
"${live.name}"
}
}
/** Ask for far more than any real link can carry, so the link is what limits the answer. */
private const val TARGET_KBPS = 3_000_000
/** Long enough to fill the pipe and settle, short enough not to interrupt anyone for long. */
private const val BURST_MS = 2_000
private const val POLL_INTERVAL_MS = 250
private const val POLL_BUDGET_MS = 10_000
private const val SETTLE_MS = 400L
private const val SPEED_TEST_CONNECT_TIMEOUT_MS = 15_000
@@ -18,34 +18,19 @@ import kotlin.math.roundToInt
* The live stats overlay the unified HUD (`design/stats-unification.md`): headline is
* `capturedisplayed` tiled by `host+network` + `decode` + `display` when the platform delivered
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
* `capturedecoded` headline without the `display` term when it didn't. Reads the 35-double
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
* `capturedecoded` headline without the `display` term when it didn't. Reads the 26-double
* layout from [NativeBridge.nativeVideoStats]:
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs,
* audioAvOffsetMs]`. Every read
* is length-guarded, so an older native lib simply omits the lines it can't feed.
*
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
* every tier, and the detailed tier names what was excluded on its own line. The principle is the
* Apple client's: metrics report what Punktfunk controls, so the compositor's own latch and scanout
* which no client can pace under is reported rather than charged. It also stops the HUD reading
* worse than it is: the usual Android streaming overlays stop measuring at decode-complete, so a
* headline that carried the compositor's wait was compared against numbers that never contained it.
*
* The RAW figures are not lost the native 1 Hz `pf.present` logcat line keeps `paceMs`, `latchMs`
* and `e2eMs` unshaved, so a HUD-off A/B and any cross-session comparison still work off the
* untouched numbers.
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms]`.
*
* [verbosity] selects how many lines render (each tier a superset of the last see
* [StatsVerbosity]):
* - [StatsVerbosity.COMPACT] one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
* - [StatsVerbosity.NORMAL] the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
* the excluded-floor line when one was measured, and the audio plane's own latency (33/34).
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), and the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
*/
@@ -55,26 +40,12 @@ internal fun StatsOverlay(
verbosity: StatsVerbosity,
decoderLabel: String = "",
codecLabel: String = "",
/**
* The settings profile this session resolved, appended to the first line when there is one
* the in-stream answer to "which profile am I on?", as on the other clients. Absent (the
* common case: no profile) the line is exactly what it always was.
*/
profileName: String? = null,
/**
* The panel's live refresh rate (0 = unknown). Shown as a warning on the first line whenever
* it sits below the stream rate the "an OEM governor ignored the mode pin" tell, which
* otherwise reads as inexplicable judder and an extra refresh of latency.
*/
panelHz: Float = 0f,
modifier: Modifier = Modifier,
) {
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
val w = s[6].toInt()
val h = s[7].toInt()
val hz = s[8].toInt()
val panelBelowStream = panelHz > 0f && hz > 0 && panelHz + 1f < hz.toFloat()
val panelTag = if (panelBelowStream) " ⚠ panel ${panelHz.roundToInt()} Hz" else ""
val latValid = s[4] != 0.0
val skew = s[5] != 0.0
val lost = s[9].toLong()
@@ -85,17 +56,13 @@ internal fun StatsOverlay(
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
val profileTag = profileName?.let { " · $it" }.orEmpty()
// Compact: everything the glance-value needs on one line, nothing else.
if (verbosity == StatsVerbosity.COMPACT) {
statLine(compactLine(s, latValid) + profileTag + panelTag, Color.White)
statLine(compactLine(s, latValid), Color.White)
return@Column
}
statLine(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag$panelTag",
Color.White,
)
statLine("$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s", Color.White)
if (detailed && decoderLabel.isNotEmpty()) {
statLine(decoderLabel, Color(0xFFB0D0FF))
}
@@ -108,15 +75,9 @@ internal fun StatsOverlay(
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
// honestly stays capture→decoded — the equation always tiles the headline interval.
val dispValid = s.size >= 26 && s[22] != 0.0
// The OS present floor this window (see [osFloorMs]) is excluded from every shown
// display / end-to-end number, at every tier — it is pipeline depth no client can pace
// under, so charging it to Punktfunk made our HUD read worse than clients that simply
// never measure it. 0.0 when unmeasured, which leaves the numbers exactly as raw as
// they were.
val floorMs = osFloorMs(s)
val tag = if (skew) "" else " (same-host clock)"
val (p50, p95, endpoint) = if (dispValid) {
Triple(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed")
Triple(s[24], s[25], "capture→displayed")
} else {
Triple(s[2], s[3], "capture→decoded")
}
@@ -133,125 +94,20 @@ internal fun StatsOverlay(
} else {
"host+network ${"%.1f".format(s[14])}"
}
// Timeline-presenter split (s[26]/s[27], when s[29] flags it active): the display
// term decomposes into pace (store + glass budget) + latch (SurfaceFlinger), and
// s[28] is the on-glass confirm count — presents ≪ fps means the presenter is
// dropping/serializing, an fps deficit is upstream.
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
val displayTerm = when {
// Floor excluded: what remains of the `display` term is the half Punktfunk
// owns (the presenter's pace wait), and the excluded line below carries the
// latch — printing the split too would report the same milliseconds twice.
dispValid && floorMs > 0 ->
" + display ${"%.1f".format(shave(s[23], floorMs))}"
dispValid && split ->
" + display ${"%.1f".format(s[23])} " +
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
dispValid -> " + display ${"%.1f".format(s[23])}"
else -> ""
}
val presents = if (s.size >= 30 && s[29] != 0.0) {
" · presents ${s[28].toInt()}"
} else {
""
}
// P3 decode split (s[30]/s[31]): `feed` = received→queued (hand-off + input-slot
// wait) + `codec` = queued→decoded (codec-pure) — rendered when a sample landed.
val decodeTerm = if (s.size >= 33 && (s[30] > 0 || s[31] > 0)) {
"decode ${"%.1f".format(s[15])} " +
"(feed ${"%.1f".format(s[30])} + codec ${"%.1f".format(s[31])})"
} else {
"decode ${"%.1f".format(s[15])}"
}
statLine(
"= $hostTerms + $decodeTerm$displayTerm$presents",
Color.White,
)
// What the numbers above leave out, named — the Apple client's
// `os present +N excluded` line, same wording so the two HUDs read alike.
// (This replaces the old "≈ Apple-HUD equiv" twin: both clients now shave, and
// Android's shave is measured rather than assumed at 2 refresh periods.)
if (floorMs > 0) {
statLine(
"os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)",
Color(0xFF9AA6B8),
)
}
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
}
}
if (detailed) {
audioLine(s)?.let { statLine(it, Color.White) }
}
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
}
}
/**
* The audio plane's own latency from the live gauges at 33/34 `audio buffer 42 ms · a/v +18 ms`,
* the same wording the desktop HUD uses. `buffer` is how much decoded audio is queued ahead of the
* speaker; `a/v` is where that PUTS it relative to the picture (positive = audio behind). `null`
* before any audio has been queued (buffer 0 audio off, or the ring not yet primed) and on an
* older native layout.
*
* Both terms, not just the depth: a deep ring on a jittery link is correct behaviour the
* underrun-driven floor earned that buffer and only the offset distinguishes it from a ring that
* is simply holding audio late. The offset term is dropped at zero, which is both "aligned" and
* "no measurement yet"; the depth alone is still the triage number, and it is the one that did not
* exist at all before (the plane published nothing any surface could render, so a "the audio delay
* is way too high" report had no instrument behind it).
*
* NOT shaved by [osFloorMs], unlike every video figure above. That shave is a reporting policy
* metrics report what Punktfunk controls but sound has to reach the ear when the light reaches
* the eye, so the sync loop aligns against the RAW capturedisplayed time (see the native
* `DisplayTracker`) and this offset is stated in those same terms. Subtracting the floor here would
* report an alignment the listener is not getting.
*/
private fun audioLine(s: DoubleArray): String? {
if (s.size < 35) return null
val bufferMs = s[33].roundToInt()
if (bufferMs <= 0) return null
val avOffset = s[34].roundToInt()
val avTerm = if (avOffset != 0) " · a/v ${if (avOffset > 0) "+" else ""}$avOffset ms" else ""
return "audio buffer $bufferMs ms$avTerm"
}
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
@Composable
private fun statLine(text: String, color: Color) {
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
/**
* The OS present floor to exclude from the shown `display` / `end-to-end` numbers, ms the
* measured `latch` p50 at index 27, i.e. release`OnFrameRendered`: SurfaceFlinger's own latch and
* scanout. That is compositor pipeline depth no client can pace under, so it is reported as
* excluded rather than charged to Punktfunk the Apple client's policy since its presentation
* rebuild, where the same floor is measured from the display link's vend lead.
*
* Measured, not assumed: the previous Android treatment used a fixed `2000/hz` twin, but the latch
* varies with panel rate, tunnelled playback and the vendor's low-latency mode (~21 ms p50 observed
* where the ~2-interval model predicts less), and this term self-adapts to all three. It is also
* available on every render path the presenter's and both legacy release-immediately ones since
* the release stamp it starts from is parked on every render, so it does not depend on
* `presenterActive` (29).
*
* `0.0` means unmeasured no display stage this window (an older native lib, API < 33, or a
* platform that refused the callback), or no latch sample paired and every caller then leaves its
* number raw, which is the honest fallback: we exclude only what we actually measured.
*/
private fun osFloorMs(s: DoubleArray): Double {
val dispValid = s.size >= 26 && s[22] != 0.0
if (!dispValid || s.size < 28) return 0.0
return s[27].coerceAtLeast(0.0)
}
/**
* Subtract the excluded [floorMs] from a shown latency [ms], clamped at zero the percentiles are
* drawn from different sample sets (a p50 latch against a p50/p95 end-to-end), so the difference can
* legitimately go slightly negative on a well-paced window without anything being wrong.
*/
private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAtLeast(0.0)
/**
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
@@ -259,9 +115,8 @@ private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAt
* one reliability signal worth surfacing even at the tersest tier.
*/
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window,
// less the excluded OS present floor — the same number the richer tiers headline.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) shave(s[24], osFloorMs(s)) else s[2]
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
val parts = buildList {
add("${s[0].roundToInt()} fps")
if (latValid) add("${"%.1f".format(e2eP50)} ms")
@@ -286,17 +141,12 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
val fec = s[20].toLong()
val frames = s[21].toLong()
if (lost == 0L && skipped == 0L && fec == 0L) return null
// The overflow subset of `skipped` (s[32]): whole AUs dropped before feeding — the decoder
// fell behind. Absent (0 / old layout) the plain count keeps meaning benign pacing drops.
val overflow = if (s.size >= 33) s[32].toLong() else 0L
return buildList {
if (lost > 0) {
val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1)
add("lost $lost (${"%.1f".format(pct)}%)")
}
if (skipped > 0) {
add(if (overflow > 0) "skipped $skipped (⚠ $overflow overflow)" else "skipped $skipped")
}
if (skipped > 0) add("skipped $skipped")
if (fec > 0) add("FEC $fec")
}.joinToString(" · ")
}
File diff suppressed because it is too large Load Diff
@@ -1,200 +0,0 @@
package io.unom.punktfunk
import android.view.MotionEvent
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.unit.IntSize
import io.unom.punktfunk.kit.NativeBridge
import kotlinx.coroutines.delay
// Wire PEN_* state bits (punktfunk_core::quic::pen; mirrored, asserted by the Rust shim's docs).
private const val PEN_IN_RANGE = 1f
private const val PEN_TOUCHING = 2f
private const val PEN_BARREL1 = 4f
private const val PEN_BARREL2 = 8f
private const val STRIDE = 10
// Ceiling on samples per emit, NOT the wire batch size: the JNI layer splits an over-8 run into
// consecutive wire batches (never truncates — a long historical run means the UI thread hitched,
// which is exactly when dropping its head would notch the stroke). 64 samples ≈ >250 ms of
// 240 Hz history; anything past that clamp is a pathological stall, not stroke geometry.
private const val MAX_SAMPLES = 64
/**
* Android stylus the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
* (`AXIS_TILT`, radians from the surface normal), azimuth (`AXIS_ORIENTATION` Android's 0 =
* "pointed away from the user" IS the wire's north, no offset needed), hover with
* `AXIS_DISTANCE`, the eraser tool, both stylus barrel buttons, and historical (coalesced)
* samples batched oldest-first for full capture-rate fidelity. Android has no barrel-roll
* axis roll stays unknown on this client.
*
* Both touch loops call [intercept] first; stylus/eraser pointers are consumed here (against a
* pen-capable host) and never reach the finger paths, independent of the touch-input mode.
* [heartbeatLoop] implements the 100 ms keepalive wire contract: a stationary held stylus is
* silent in Android's input pipeline, and the host force-releases a stroke after 200 ms
* without samples.
*/
internal class StylusStream(private val handle: Long) {
private var inRange = false
private var touching = false
private var sawHover = false
private val last = FloatArray(STRIDE)
private val batch = FloatArray(MAX_SAMPLES * STRIDE)
init {
idle(last)
}
/**
* Consume the event's stylus pointers into pen samples. Returns true when this event
* carried any (the caller's finger/gesture handling must then skip those changes).
*/
@OptIn(ExperimentalComposeUiApi::class)
fun intercept(ev: PointerEvent, size: IntSize): Boolean {
val stylusChanges = ev.changes.filter {
it.type == PointerType.Stylus || it.type == PointerType.Eraser
}
if (stylusChanges.isEmpty()) return false
stylusChanges.forEach { it.consume() }
val me = ev.motionEvent ?: return true
if (size.width <= 0 || size.height <= 0) return true
// At most one stylus exists — find its pointer index by tool type.
val idx = (0 until me.pointerCount).firstOrNull {
me.getToolType(it) == MotionEvent.TOOL_TYPE_STYLUS ||
me.getToolType(it) == MotionEvent.TOOL_TYPE_ERASER
} ?: return true
when (me.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN,
MotionEvent.ACTION_MOVE,
-> {
touching = true
inRange = true
emitSamples(me, idx, size)
}
MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE -> {
sawHover = true
inRange = true
touching = false
emitSamples(me, idx, size)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> {
touching = false
// Hover-capable hardware keeps proximity (HOVER_EXIT owns the leave);
// anything else leaves range on lift — the host never parks a phantom pen.
inRange = sawHover
emitSamples(me, idx, size)
}
MotionEvent.ACTION_HOVER_EXIT, MotionEvent.ACTION_CANCEL -> release()
else -> {}
}
return true
}
/** Session/composition teardown: leave range so the host lifts anything still inked. */
fun reset() {
if (inRange || touching) release()
sawHover = false
}
/** The 100 ms keepalive (80 ms leaves headroom for one lost datagram). Runs until
* cancelled; resends the last state-full sample while the pen is in range. */
suspend fun heartbeatLoop() {
try {
while (true) {
delay(80)
if (inRange || touching) {
last[9] = 0f // dt
NativeBridge.nativeSendPen(handle, last, 1)
}
}
} finally {
reset()
}
}
private fun release() {
touching = false
inRange = false
last[0] = 0f // state: out of range
last[4] = 0f // pressure
NativeBridge.nativeSendPen(handle, last, 1)
}
/** Historical (coalesced) samples oldest-first, then the current one one emit; the JNI
* layer splits runs longer than the wire's 8-sample batch cap into consecutive sends. */
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
val history = minOf(me.historySize, MAX_SAMPLES - 1)
var count = 0
var prevT = if (history > 0) me.getHistoricalEventTime(0) else me.eventTime
for (h in (me.historySize - history) until me.historySize) {
val t = me.getHistoricalEventTime(h)
fill(
batch, count * STRIDE, size,
x = me.getHistoricalX(idx, h), y = me.getHistoricalY(idx, h),
pressure = me.getHistoricalPressure(idx, h),
tiltRad = me.getHistoricalAxisValue(MotionEvent.AXIS_TILT, idx, h),
orientRad = me.getHistoricalAxisValue(MotionEvent.AXIS_ORIENTATION, idx, h),
distance = me.getHistoricalAxisValue(MotionEvent.AXIS_DISTANCE, idx, h),
buttons = me.buttonState, tool = me.getToolType(idx),
dtUs = ((t - prevT) * 1000).coerceIn(0, 65535).toFloat(),
)
prevT = t
count++
}
fill(
batch, count * STRIDE, size,
x = me.getX(idx), y = me.getY(idx), pressure = me.getPressure(idx),
tiltRad = me.getAxisValue(MotionEvent.AXIS_TILT, idx),
orientRad = me.getAxisValue(MotionEvent.AXIS_ORIENTATION, idx),
distance = me.getAxisValue(MotionEvent.AXIS_DISTANCE, idx),
buttons = me.buttonState, tool = me.getToolType(idx),
dtUs = ((me.eventTime - prevT) * 1000).coerceIn(0, 65535).toFloat(),
)
count++
batch.copyInto(last, 0, (count - 1) * STRIDE, count * STRIDE)
NativeBridge.nativeSendPen(handle, batch, count)
}
private fun fill(
out: FloatArray,
off: Int,
size: IntSize,
x: Float,
y: Float,
pressure: Float,
tiltRad: Float,
orientRad: Float,
distance: Float,
buttons: Int,
tool: Int,
dtUs: Float,
) {
var state = 0f
if (inRange || touching) state += PEN_IN_RANGE
if (touching) state += PEN_TOUCHING
if (buttons and MotionEvent.BUTTON_STYLUS_PRIMARY != 0) state += PEN_BARREL1
if (buttons and MotionEvent.BUTTON_STYLUS_SECONDARY != 0) state += PEN_BARREL2
out[off + 0] = state
out[off + 1] = if (tool == MotionEvent.TOOL_TYPE_ERASER) 1f else 0f
out[off + 2] = (x / (size.width - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
out[off + 3] = (y / (size.height - 1).coerceAtLeast(1)).coerceIn(0f, 1f)
out[off + 4] = if (touching) pressure.coerceIn(0f, 1f) else 0f
// AXIS_DISTANCE units are device-arbitrary; 0..1 covers real hardware, and 0 while
// hovering legitimately means "at the hover floor".
out[off + 5] = if (touching) 0f else distance.coerceIn(0f, 1f)
out[off + 6] = Math.toDegrees(tiltRad.toDouble()).toFloat().coerceIn(0f, 90f)
// AXIS_ORIENTATION: 0 = pointed away from the user (= wire north), clockwise, −π..π.
out[off + 7] = ((Math.toDegrees(orientRad.toDouble()) + 360.0) % 360.0).toFloat()
out[off + 8] = -1f // no barrel-roll axis on Android
out[off + 9] = dtUs
}
private fun idle(out: FloatArray) {
out.fill(0f)
out[5] = -1f // distance unknown
out[6] = -1f // tilt unknown
out[7] = -1f // azimuth unknown
out[8] = -1f // roll unknown
}
}
@@ -1,11 +1,9 @@
package io.unom.punktfunk
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.positionChanged
@@ -58,26 +56,7 @@ private const val ACCEL_MAX = 3.0f
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
* contact is lifted so nothing stays stuck on the host.
*/
/** Whether this change belongs to the stylus lane (only when a pen-capable host is live). */
private fun isStylus(c: PointerInputChange, stylus: StylusStream?): Boolean =
stylus != null && (c.type == PointerType.Stylus || c.type == PointerType.Eraser)
/** [awaitFirstDown] with the stylus lane split out: pen events feed [stylus] and never start a
* mouse/touch gesture. Toward a pen-less host ([stylus] == null) a stylus stays a finger. */
private suspend fun AwaitPointerEventScope.awaitFirstFingerDown(
stylus: StylusStream?,
): PointerInputChange {
while (true) {
val ev = awaitPointerEvent()
stylus?.intercept(ev, size)
val down = ev.changes.firstOrNull {
it.changedToDownIgnoreConsumed() && !isStylus(it, stylus)
}
if (down != null) return down
}
}
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, stylus: StylusStream?) {
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
val ids = mutableMapOf<PointerId, Int>()
fun alloc(p: PointerId): Int {
var id = 0
@@ -89,12 +68,10 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
awaitPointerEventScope {
while (true) {
val ev = awaitPointerEvent()
stylus?.intercept(ev, size)
val sw = size.width
val sh = size.height
if (sw <= 0 || sh <= 0) continue
for (c in ev.changes) {
if (isStylus(c, stylus)) continue // the pen plane owns it
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
when {
@@ -105,20 +82,8 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
}
c.positionChanged() ->
ids[c.id]?.let { id ->
// Batched MotionEvents coalesce intermediate points into the
// historical list — forward them in order so a fast swipe keeps
// its real curvature on the host (usually empty during a stream:
// unbuffered dispatch is requested, so this costs nothing).
for (hs in c.historical) {
NativeBridge.nativeSendTouch(
handle, id, 1,
hs.position.x.roundToInt().coerceIn(0, sw - 1),
hs.position.y.roundToInt().coerceIn(0, sh - 1),
sw, sh,
)
}
NativeBridge.nativeSendTouch(handle, id, 1, x, y, sw, sh)
ids[c.id]?.let {
NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
}
}
c.consume()
@@ -133,13 +98,10 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl
internal suspend fun PointerInputScope.streamTouchInput(
handle: Long,
stylus: StylusStream?,
trackpad: Boolean,
invertScroll: Boolean,
onCycleStats: () -> Unit,
onKeyboard: (show: Boolean) -> Unit,
) {
val scrollDir = if (invertScroll) -1 else 1
var lastTapUp = 0L
var lastTapX = 0f
var lastTapY = 0f
@@ -156,7 +118,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
)
}
awaitEachGesture {
val down = awaitFirstFingerDown(stylus)
val down = awaitFirstDown(requireUnconsumed = false)
val startX = down.position.x
val startY = down.position.y
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
@@ -193,8 +155,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
while (true) {
val ev = awaitPointerEvent()
stylus?.intercept(ev, size)
val pressed = ev.changes.filter { it.pressed && !isStylus(it, stylus) }
val pressed = ev.changes.filter { it.pressed }
if (pressed.isEmpty()) {
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
break
@@ -223,12 +184,12 @@ internal suspend fun PointerInputScope.streamTouchInput(
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
if (sy != 0) {
NativeBridge.nativeSendScroll(handle, 0, sy * 120 * scrollDir)
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
prevCy = cy
moved = true
}
if (sx != 0) {
NativeBridge.nativeSendScroll(handle, 1, sx * 120 * scrollDir)
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
prevCx = cx
moved = true
}
@@ -301,10 +262,7 @@ internal suspend fun PointerInputScope.streamTouchInput(
accY -= outY
}
} else {
// Direct: cursor follows the finger — historical points first (batched
// MotionEvent samples), so the host cursor traces the finger's real path.
for (hs in p.historical) moveAbs(hs.position.x, hs.position.y)
moveAbs(p.position.x, p.position.y)
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
}
}
ev.changes.forEach { it.consume() }
@@ -9,22 +9,16 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -38,9 +32,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -58,25 +49,8 @@ fun SectionLabel(text: String) {
}
/**
* One row of a host card's overflow menu. [startsSection] draws a divider above it, which is how
* the profile actions ("Connect with: …", "Pin as card: …") stay legible next to the host actions
* in one flat menu Compose has no submenus, and the Windows client made the same call.
*/
data class HostMenuItem(
val label: String,
val startsSection: Boolean = false,
val onClick: () -> Unit,
)
/**
* A host as an Apple-style card: a colored avatar carrying the host's OS mark (its initial when we
* don't know the OS), name + address, a trust pill, and (for saved hosts) an overflow menu with
* Wake / Edit / Forget plus whatever [menuItems] adds. Tapping the card connects.
*
* [profileLabel] names the settings profile this card connects with. On a host's own card that is
* its default binding, drawn as a quiet chip the card says what a tap will do. On a **pinned
* card** ([profileProminent]) the host name is still the title, but the profile is the loud part,
* because the pin exists to make that one combination a single tap.
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
* saved hosts) an overflow menu with Rename / Forget. Tapping the card connects.
*/
@Composable
fun HostCard(
@@ -84,25 +58,11 @@ fun HostCard(
address: String,
status: HostStatus,
online: Boolean = false,
/** OS-identity chain (mDNS `os` TXT / stored), drawn as the avatar's mark. "" = the initial. */
os: String = "",
enabled: Boolean,
onConnect: () -> Unit,
onForget: (() -> Unit)?,
onEdit: (() -> Unit)? = null,
onWake: (() -> Unit)? = null,
profileLabel: String? = null,
profileProminent: Boolean = false,
accent: Color? = null,
menuItems: List<HostMenuItem> = emptyList(),
/**
* Keep the profile chip's space even on a card that has no profile. `LazyVerticalGrid` sizes a
* row to its tallest item but does NOT stretch the others, so a card that grew a chip would
* leave its neighbour visibly short a row of cards stepping up and down reads as broken
* layout. The caller passes true when ANY card in that section carries a chip, so a user with
* no profiles never pays for the slot.
*/
reserveProfileSlot: Boolean = false,
) {
// 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.
@@ -129,8 +89,8 @@ fun HostCard(
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HostAvatar(name, online, os)
Spacer(Modifier.height(10.dp))
HostAvatar(name)
Spacer(Modifier.height(12.dp))
Text(
name,
style = MaterialTheme.typography.titleMedium,
@@ -146,27 +106,17 @@ fun HostCard(
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
if (profileLabel != null || reserveProfileSlot) {
Spacer(Modifier.height(10.dp))
Box(
Modifier.heightIn(min = PROFILE_CHIP_SLOT),
contentAlignment = Alignment.Center,
) {
if (profileLabel != null) {
ProfileChip(profileLabel, accent, prominent = profileProminent)
}
}
Spacer(Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PresencePill(online)
StatusPill(status)
}
}
// Trust state lives in the free top-left corner, mirroring the overflow on the right —
// it costs no height, and it is a state you glance at rather than read. The label is
// still there for TalkBack, and the trust DECISION is made in a dialog that spells all
// of this out; on the card it only has to say "this one is settled" vs "this one will
// ask something of you".
TrustBadge(status, Modifier.align(Alignment.TopStart))
if (onForget != null || onEdit != null || onWake != null || menuItems.isNotEmpty()) {
if (onForget != null || onEdit != null || onWake != null) {
var menu by remember { mutableStateOf(false) }
Box(modifier = Modifier.align(Alignment.TopEnd)) {
IconButton(enabled = enabled, onClick = { menu = true }) {
@@ -205,16 +155,6 @@ fun HostCard(
},
)
}
menuItems.forEach { item ->
if (item.startsSection) HorizontalDivider()
DropdownMenuItem(
text = { Text(item.label) },
onClick = {
menu = false
item.onClick()
},
)
}
}
}
}
@@ -222,136 +162,59 @@ fun HostCard(
}
}
/** A circular avatar with the host's first letter (Apple-contact style). */
@Composable
fun HostAvatar(name: String) {
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
letter,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
/**
* The profile a card connects with. Quiet on a bound host's own card (it is a note about what a tap
* does); filled and tinted on a pinned card, where the profile IS the reason the card exists the
* accent field the schema reserves earns its keep here.
* A small dot + label for live presence: green Online when the host advertises on mDNS OR answers
* the reachability probe (so a routed/VPN host that never advertises still reads Online), dimmed
* Offline otherwise.
*/
@Composable
private fun ProfileChip(label: String, accent: Color?, prominent: Boolean) {
val tint = accent ?: MaterialTheme.colorScheme.primary
Row(
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(tint.copy(alpha = if (prominent) 0.24f else 0.12f))
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
fun PresencePill(online: Boolean) {
val color =
if (online) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
Spacer(Modifier.width(6.dp))
Text(
label,
style = if (prominent) {
MaterialTheme.typography.labelLarge
} else {
MaterialTheme.typography.labelMedium
},
color = tint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
if (online) "Online" else "Offline",
style = MaterialTheme.typography.labelMedium,
color = color,
)
}
}
/**
* Reserved height for the profile chip the one part of a card that varies. `LazyVerticalGrid`
* sizes a row to its tallest item and does NOT stretch the others, so a card that grew a chip its
* neighbour lacks would leave the row stepping up and down.
*
* `heightIn(min =)`, not a fixed height: at a large accessibility font scale the chip must be
* allowed to grow rather than clip, and the reservation is sized with room to spare because the
* equal-height guarantee only holds while every card fits INSIDE it.
*/
private val PROFILE_CHIP_SLOT = 26.dp
/** Live presence, on any dynamic scheme: green reads as "up" to everyone, and Material You's
* primary might be any hue at all including a green that would then mean nothing. */
private val PRESENCE_ONLINE = Color(0xFF4ADE80)
/**
* The host's avatar (Apple-contact style) with its presence as a dot on the corner the idiom
* every contact list already uses, and one fewer labelled badge on a small card. It carries the
* host's OS mark when [os] resolves to one we ship, and the host's initial otherwise.
*
* [online] is true when the host advertises on mDNS OR answers the reachability probe, so a
* routed/VPN host that never advertises still reads as up. Online is a FILLED green dot, offline a
* hollow grey ring: the difference is a shape as well as a colour, so it survives both a
* colour-blind reader and a screenshot in greyscale. TalkBack gets the word either way.
*/
/** A small colored dot + label for the host's trust state. */
@Composable
fun HostAvatar(name: String, online: Boolean = false, os: String = "") {
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val cardColor = CardDefaults.elevatedCardColors().containerColor
val osIcon = resolveOsIcon(os)
Box {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
// The OS mark IS the avatar when we know the OS — it identifies the machine better than
// the initial ever did, and it's the same circle, so a card whose host advertises no OS
// (or one we ship no mark for) keeps the letter and the row still reads as one set.
if (osIcon != null) {
Icon(
osIcon,
contentDescription = os,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
} else {
Text(
letter,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(13.dp)
.clip(CircleShape)
// A ring in the card's own colour is what makes the dot read as sitting ON the
// avatar rather than beside it.
.background(cardColor)
.padding(2.dp)
.clip(CircleShape)
.then(
if (online) {
Modifier.background(PRESENCE_ONLINE)
} else {
Modifier
.background(cardColor)
.border(1.5.dp, MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
},
)
.semantics { contentDescription = if (online) "Online" else "Offline" },
)
fun StatusPill(status: HostStatus) {
val color = when (status) {
HostStatus.PAIRED -> MaterialTheme.colorScheme.primary
HostStatus.PAIRING -> MaterialTheme.colorScheme.tertiary
HostStatus.TOFU -> MaterialTheme.colorScheme.onSurfaceVariant
}
}
/**
* The host's trust state as a corner glyph: locked (paired nothing more to do), a key (this host
* will ask for a PIN), or an open lock (trust-on-first-use, the weakest of the three). The full
* label rides along as the content description, and the dialogs that actually make the decision
* spell it out in sentences.
*/
@Composable
private fun TrustBadge(status: HostStatus, modifier: Modifier = Modifier) {
val (icon, tint) = when (status) {
HostStatus.PAIRED -> Icons.Filled.Lock to MaterialTheme.colorScheme.primary
HostStatus.PAIRING -> Icons.Filled.Key to MaterialTheme.colorScheme.tertiary
HostStatus.TOFU -> Icons.Filled.LockOpen to MaterialTheme.colorScheme.onSurfaceVariant
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
Spacer(Modifier.width(6.dp))
Text(status.label, style = MaterialTheme.typography.labelMedium, color = color)
}
Icon(
icon,
contentDescription = status.label,
tint = tint.copy(alpha = 0.85f),
modifier = modifier.padding(14.dp).size(18.dp),
)
}
/** Shown when there are no saved or discovered hosts. */
@@ -1,128 +0,0 @@
package io.unom.punktfunk.components
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.discovery.osIconTokens
import kotlin.math.max
/**
* The host card's OS marks, resolved from the host's OS-identity chain (mDNS `os` TXT,
* e.g. "linux/fedora/bazzite"): [resolveOsIcon] walks the chain most-specific-first
* (kit's [osIconTokens] the shared order + brand aliases) and returns the first mark we
* ship, so an unknown distro degrades to its family's mark and finally to Tux; null means
* "no icon", rendering the card exactly as before the field existed.
*
* Path data is vendored from the assets/os-icons masters (per-mark provenance and licensing
* in that directory's README; `bash scripts/gen-os-icons.sh <token>` prints a master's
* viewport + path ready to paste); Material ships no brand icons. Hand-kept as raw SVG path
* strings (one line each) rather than transcribed ImageVector DSL [PathParser] builds the
* vector once, then it's cached.
*/
private class OsGlyph(val viewportWidth: Float, val viewportHeight: Float, val d: String)
private val GLYPHS: Map<String, OsGlyph> = mapOf(
"windows" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z",
),
"apple" to OsGlyph(
viewportWidth = 384f,
viewportHeight = 512f,
d = "M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z",
),
"linux" to OsGlyph(
viewportWidth = 448f,
viewportHeight = 512f,
d = "M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z",
),
"steam" to OsGlyph(
viewportWidth = 496f,
viewportHeight = 512f,
d = "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
),
"ubuntu" to OsGlyph(
viewportWidth = 496f,
viewportHeight = 512f,
d = "M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z",
),
"fedora" to OsGlyph(
viewportWidth = 448f,
viewportHeight = 512f,
d = "M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z",
),
"arch" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091",
),
"debian" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83",
),
"nixos" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z",
),
"opensuse" to OsGlyph(
viewportWidth = 640f,
viewportHeight = 512f,
d = "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z",
),
// The gaming distros get their own mark rather than their family's: "a Bazzite box" and
// "a Fedora box" are different machines to the person reading the card.
"bazzite" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z",
),
"cachyos" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81",
),
"nobara" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z",
),
)
/** Longest edge of a built mark, in dp — the box callers size the [Icon] to. */
private const val GLYPH_DP = 24f
private val built = mutableMapOf<String, ImageVector>()
/** The mark for a chain, or null (no icon). Vectors build lazily and cache per token. */
fun resolveOsIcon(chain: String): ImageVector? =
osIconTokens(chain).firstNotNullOfOrNull { token ->
GLYPHS[token]?.let { glyph -> built.getOrPut(token) { glyph.build(token) } }
}
private fun OsGlyph.build(token: String): ImageVector {
// The intrinsic size has to carry the VIEWPORT'S ASPECT RATIO, not a fixed square:
// a VectorPainter maps the viewport onto the default size with independent x and y
// scales, so declaring a 448x512 mark as 24x24 dp stretches it horizontally — which is
// exactly how Tux and the Apple mark used to come out on a phone. Scaling the longest
// edge to GLYPH_DP instead keeps the ratio, and Icon() paints with ContentScale.Fit, so
// the mark letterboxes inside whatever box the caller sized us to.
val longest = max(viewportWidth, viewportHeight)
return ImageVector.Builder(
name = "OsIcon.$token",
defaultWidth = (GLYPH_DP * viewportWidth / longest).dp,
defaultHeight = (GLYPH_DP * viewportHeight / longest).dp,
viewportWidth = viewportWidth,
viewportHeight = viewportHeight,
).apply {
// Fill colour is irrelevant — Icon() tints via LocalContentColor, like Material icons.
addPath(
pathData = PathParser().parsePathString(d).toNodes(),
fill = SolidColor(Color.Black),
)
}.build()
}
@@ -25,54 +25,10 @@ data class PendingTrust(
val name: String,
val advertisedFp: String?,
val kind: Kind,
/**
* What the connect on the far side of this decision should carry a `punktfunk://` link's
* one-off profile and library id. A link to an unknown host goes through the confirmation
* first, and the user's stated intent must survive that detour rather than being silently
* dropped on the way to a plain desktop session.
*/
val profile: String? = null,
val launch: String? = null,
) {
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
}
/**
* A stream session that just opened, and the state the stream screen needs about it.
*
* [settings] is the settings the connect ACTUALLY used, resolved once at connect time not
* "whatever the settings store says now". Every post-connect read (the stats tier, the touch and
* mouse models, the low-latency pipeline, rumble, SC2 capture) takes it, so the stream can never
* disagree with the connect that produced it. [clipboardSync] comes from the host record, because
* clipboard sync is a decision about that host rather than about this device.
*/
data class ActiveSession(
val handle: Long,
val settings: io.unom.punktfunk.Settings,
val clipboardSync: Boolean,
/**
* The settings profile this session resolved, if any shown on the stats overlay's first line
* so "which profile am I on?" is answerable from inside the stream, as on the other clients.
*/
val profileName: String? = null,
/**
* The stable id of the host being streamed, when it is a saved one so a `punktfunk://` link
* that arrives mid-stream can tell "this same host" (a no-op; the intent already focused us)
* from "a different host" (a notice; a URL may never preempt a live session).
*/
val hostId: String? = null,
/**
* This session was started by launching a title from [hostId]'s library, rather than by
* connecting to the host's desktop.
*
* Decides where the client goes when the session ENDS: a title launched out of a library
* belongs back in that library when its game exits one press from the next one not on the
* host-selection screen. Only meaningful together with a
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
*/
val launchedFromLibrary: Boolean = false,
)
/** Trust state of a host, shown as a colored pill on its card. */
enum class HostStatus(val label: String) {
PAIRED("Paired"),
@@ -1,162 +0,0 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
// The console UI's background palettes. These assertions are the CONTRACT the Rust
// (`pf-console-ui::library`) and Swift (`GamepadPalette.swift`) ports reproduce — the same ids in
// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look
// on every client.
class GamepadPaletteTest {
private fun luma(c: Triple<Double, Double, Double>) =
0.2126 * c.first + 0.7152 * c.second + 0.0722 * c.third
/** Hue angle in degrees, or null for something too grey to have one. */
private fun hue(c: Triple<Double, Double, Double>): Double? {
val (r, g, b) = c
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val d = max - min
if (d < 0.04) return null
val h = when (max) {
r -> 60.0 * (((g - b) / d) % 6.0)
g -> 60.0 * ((b - r) / d + 2.0)
else -> 60.0 * ((r - g) / d + 4.0)
}
return (h + 360.0) % 360.0
}
/** Ids, order and the light/dark split are the cross-client contract. */
@Test
fun tableMatchesTheOtherClients() {
assertEquals(
listOf(
"violet", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal",
),
GamepadPalette.ALL.map { it.id },
)
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
assertEquals(6, firstLight)
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
assertEquals("violet", GamepadPalette.named("").id)
// The brand default keeps the shipped field rather than a generated ramp.
assertTrue(GamepadPalette.named("violet").stops.isEmpty())
}
/**
* A palette must read as SEVERAL hues, not one hue at several brightnesses that was exactly
* the complaint about the hue-rotation model this replaced.
*/
@Test
fun everyPaletteIsMultiTone() {
for (p in GamepadPalette.ALL) {
val stops = p.stops.ifEmpty { continue }
val hues = stops.mapNotNull { hue(it) }
assertTrue("${p.id}: too few coloured stops", hues.size >= 3)
var spread = 0.0
for (a in hues) {
for (b in hues) {
val d = Math.abs(a - b) % 360.0
spread = maxOf(spread, minOf(d, 360.0 - d))
}
}
// Graphite and Opal are deliberately near-neutral; the rest must travel.
val floor = if (p.id == "graphite" || p.id == "opal") 20.0 else 45.0
assertTrue("${p.id} spans only $spread° of hue", spread >= floor)
}
}
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
@Test
fun palettesAreHonestAboutLightness() {
for (p in GamepadPalette.ALL) {
if (p.light) {
assertTrue("${p.id}'s ground is dark", luma(p.ground) > 0.6)
assertTrue("${p.id}'s accent is too pale", luma(p.accent) < 0.45)
} else {
assertTrue("${p.id}'s ground is light", luma(p.ground) < 0.2)
assertTrue("${p.id}'s accent is too dark", luma(p.accent) > 0.25)
}
}
}
/** The ramp is the shared sampling rule the Rust and Swift ports reproduce. */
@Test
fun rampInterpolatesBetweenStops() {
val stops = listOf(
Triple(0.0, 0.0, 0.0), Triple(1.0, 0.0, 0.0), Triple(1.0, 1.0, 1.0),
)
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.0))
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 1.0))
assertEquals(Triple(1.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.5))
assertEquals(0.5, GamepadPalette.ramp(stops, 0.25).first, 1e-9)
// Out of range clamps rather than throwing.
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, -3.0))
assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 9.0))
assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(emptyList(), 0.5))
}
/** The ink a palette calls for: white on a dark field, near-black on a pale one. */
@Test
fun inkFollowsTheField() {
val dark = GamepadInk.of(GamepadPalette.named("violet"))
assertTrue(!dark.isLight)
assertEquals(1f, dark.fg.red, 1e-6f)
assertEquals(1f, dark.shadeScale, 1e-6f)
val light = GamepadInk.of(GamepadPalette.named("holo"))
assertTrue(light.isLight)
assertTrue("pale fields need dark ink", light.fg.red < 0.3f)
// A pale field's scrims must pull far less, or they bleach the gradient.
assertTrue(light.shadeScale < 0.5f)
}
/**
* Every settings row lands in exactly one tab a row missing from the tab map is a setting
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
*/
@Test
fun everySettingsRowHasATab() {
val rows = buildSettingsRows(
Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true,
) {}
assertTrue(rows.isNotEmpty())
assertEquals(rows.size, rows.map { it.id }.toSet().size)
// Profiles is built separately (from the catalog), so no settings row claims it.
assertTrue(rows.none { it.tab == GpTab.PROFILES })
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
assertTrue("$t is empty", rows.any { it.tab == t })
}
}
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
@Test
fun backgroundRowStepsTheSharedKey() {
var s = Settings()
fun rows() = buildSettingsRows(
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) { s = it }
fun palette() = rows().first { it.id == "palette" }
assertEquals("violet", s.uiPalette)
assertEquals("Violet", palette().value)
assertTrue("already the first = thud", !palette().adjust(-1))
assertTrue(palette().adjust(1))
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
// A from the last entry wraps home.
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
palette().activate()
assertEquals("violet", s.uiPalette)
// A store written by a newer client shows the palette that is actually drawing.
s = s.copy(uiPalette = "chartreuse")
assertEquals("Violet", palette().value)
}
}
@@ -1,98 +0,0 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
* governed row is inert rather than merely dim.
*
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
* these tests pin both halves the flag AND the refusal to write.
*/
class GamepadSettingsRowsTest {
/** Rows for a given forwarding state, capturing whatever a row writes back. */
private fun rows(
forwarding: Boolean,
sink: MutableList<Settings> = mutableListOf(),
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
hasGyroscope = true,
av1Capable = true,
) { sink += it }
private fun row(rows: List<GpRow>, id: String): GpRow =
rows.first { it.id == id }
/** Every row that only means something while a controller is actually being forwarded. */
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
@Test
fun `forwarding off dims every row that depends on it`() {
val off = rows(forwarding = false)
for (id in governed) {
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
}
// The master switch itself stays live — otherwise it could never be turned back on.
assertTrue(row(off, "padForward").enabled)
}
@Test
fun `forwarding on leaves them all live`() {
val on = rows(forwarding = true)
for (id in governed) {
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
}
}
@Test
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
val writes = mutableListOf<Settings>()
val off = rows(forwarding = false, sink = writes)
for (id in governed) {
val i = off.indexOfFirst { it.id == id }
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
// What the screen actually does on left/right/A — the whole point is that it no-ops.
liveRow(off, i)?.adjust(1)
liveRow(off, i)?.adjust(-1)
liveRow(off, i)?.activate()
}
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
}
@Test
fun `the same rows do write once forwarding is on`() {
val writes = mutableListOf<Settings>()
val on = rows(forwarding = true, sink = writes)
val i = on.indexOfFirst { it.id == "sc2" }
assertNotNull(liveRow(on, i))
liveRow(on, i)?.activate()
assertEquals(1, writes.size)
assertFalse("activate flips the toggle", writes[0].sc2Capture)
}
/**
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
* where this screen is the only one reachable a TV box has no touch interface to fall back to.
*/
@Test
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
val on = rows(forwarding = true)
val ids = on.map { it.id }
assertTrue("dsCapture row is missing", "dsCapture" in ids)
assertEquals(
"the two passthrough rows belong side by side",
ids.indexOf("sc2") + 1,
ids.indexOf("dsCapture"),
)
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
}

Some files were not shown because too many files have changed in this diff Show More