Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
491a344f23 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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 "$@"
|
||||
@@ -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 17–21).
|
||||
# The tests are pure JVM (no NDK), but sharing android.yml's image means one image to
|
||||
# keep warm instead of a per-run setup-java + sdkmanager download pair.
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: JDK 21 (AGP 9.2 + Robolectric's SDK-36 android-all jar both want 17–21)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Android SDK
|
||||
# SHA-pinned for parity with android.yml (third-party action). v3 = 9fc6c4e.
|
||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
|
||||
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP
|
||||
# if the platform channel lacks it (same note as android.yml).
|
||||
- name: platform-tools + platform 36 + build-tools
|
||||
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0"
|
||||
|
||||
- name: Cache (gradle)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
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.
|
||||
|
||||
@@ -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 17–21, 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
|
||||
|
||||
@@ -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"
|
||||
@@ -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+
|
||||
|
||||
@@ -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 \
|
||||
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 || 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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*') {
|
||||
@@ -128,10 +109,7 @@ jobs:
|
||||
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
# punktfunk-cli builds the `punktfunk.exe` the manifest aliases and pack-msix.ps1
|
||||
# requires (bf981027 added the requirement without the build — same gap 90c84ef4
|
||||
# closed for deb).
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
|
||||
- name: Pack + sign MSIX
|
||||
shell: pwsh
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 35–39 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 (20–42 ms/unit at `n_fc=2` vs 21–53 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 4–5 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 8–21 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 35–39 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.15.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"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 17–21;
|
||||
# cmake;3.22.1 because kit/build.gradle.kts prepends $ANDROID_SDK/cmake/3.22.1/bin to PATH
|
||||
# for cargo-ndk's audiopus_sys (libopus) CMake build; platforms;android-37 is deliberately
|
||||
# absent (AGP auto-downloads it if a build ever needs it — same note as the old workflow).
|
||||
FROM ubuntu:26.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git unzip zip python3 openjdk-21-jdk-headless \
|
||||
build-essential pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
|
||||
|
||||
# Android SDK: cmdline-tools must land under cmdline-tools/latest for sdkmanager to
|
||||
# find its own root.
|
||||
ENV ANDROID_HOME=/opt/android-sdk \
|
||||
ANDROID_SDK_ROOT=/opt/android-sdk
|
||||
ARG CMDLINE_TOOLS=13114758
|
||||
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
|
||||
&& curl -fsSL -o /tmp/clt.zip "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" \
|
||||
&& unzip -q /tmp/clt.zip -d "$ANDROID_HOME/cmdline-tools" \
|
||||
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
|
||||
&& rm /tmp/clt.zip
|
||||
ENV PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH
|
||||
RUN yes | sdkmanager --licenses >/dev/null \
|
||||
&& sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" \
|
||||
"ndk;30.0.14904198" "cmake;3.22.1" \
|
||||
&& chmod -R a+rX "$ANDROID_HOME"
|
||||
|
||||
# Toolchain shared across CI users (jobs may run as different uids) — same shape as
|
||||
# rust-ci.Dockerfile, plus the Android cross targets and cargo-ndk. The registry/git
|
||||
# download caches are stripped after the cargo-ndk install: jobs restore those from the
|
||||
# shared actions cache, and baking them would only bloat every pull.
|
||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
PATH=/usr/local/cargo/bin:$PATH
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal \
|
||||
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
|
||||
&& cargo install cargo-ndk --locked \
|
||||
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
|
||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||
&& rustc --version && cargo ndk --version
|
||||
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& sccache --version
|
||||
|
||||
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
|
||||
# the job container — no node, no checkout (exit 127; same lesson flatpak.yml documents for
|
||||
# fedora:43). A separate trailing layer on purpose: appending here keeps the fat SDK/NDK
|
||||
# layers above cache-valid instead of invalidating the whole build.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& node --version
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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[@]}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,111 +42,37 @@ 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 (its mode says Always OR a
|
||||
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
|
||||
// connect/disconnect — unless the mode is Always, where it simply stays.
|
||||
// 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.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val controllerConnected by rememberControllerConnected()
|
||||
val gamepadUi = gamepadUiActive(
|
||||
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
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
|
||||
@@ -179,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) },
|
||||
@@ -235,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 }
|
||||
|
||||
@@ -257,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 /
|
||||
@@ -307,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,234 +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 thirteen shipped palettes: the brand default, six 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(
|
||||
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
|
||||
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
|
||||
// of the field is genuinely off rather than "very dark grey", and the ground is
|
||||
// pure black too: the calm mix on the form screens lifts toward nothing. What is
|
||||
// left is a faint indigo→violet ember in the bright corner. The accent stays the
|
||||
// brand violet — focus has to be findable on black.
|
||||
"oled", "OLED",
|
||||
listOf(
|
||||
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
|
||||
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
|
||||
Triple(0.120, 0.024, 0.130),
|
||||
),
|
||||
ground = Triple(0.0, 0.0, 0.0),
|
||||
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,145 +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)) },
|
||||
) + listOfNotNull(
|
||||
// WHEN the switch above takes over. Built only while it is ON: turn the switch off from
|
||||
// this very screen and the row under the cursor would otherwise be one deciding nothing,
|
||||
// on a screen that is itself about to disappear.
|
||||
if (s.gamepadUiEnabled) {
|
||||
choice(
|
||||
"gamepadUIMode", GpTab.INTERFACE, null, "Show it",
|
||||
"With a controller: the touch interface comes back when the last one " +
|
||||
"disconnects. Always keeps this layout either way — for a device that lives " +
|
||||
"docked to a TV. A TV itself is always in this mode regardless.",
|
||||
GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode,
|
||||
) { update(s.copy(gamepadUiMode = it)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,35 +16,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
|
||||
/**
|
||||
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
|
||||
* the switch meant when it was a lone Boolean.
|
||||
*/
|
||||
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
|
||||
|
||||
/**
|
||||
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad — for a phone or
|
||||
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
|
||||
* always awake.
|
||||
*/
|
||||
const val GAMEPAD_UI_ALWAYS = "always"
|
||||
|
||||
/**
|
||||
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
||||
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
||||
* the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached
|
||||
* OR this is a TV OR the dev [forced] flag). A TV counts unconditionally — its remote/gamepad is
|
||||
* the only input, so it's always the console UI (as long as the setting is on), which is why the
|
||||
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
|
||||
* client wrote can never strand this one in a layout it has no way back out of.
|
||||
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
|
||||
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
|
||||
* console UI (as long as the setting is on).
|
||||
*/
|
||||
fun gamepadUiActive(
|
||||
enabled: Boolean,
|
||||
mode: String,
|
||||
controllerConnected: Boolean,
|
||||
tv: Boolean,
|
||||
forced: Boolean,
|
||||
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
|
||||
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
|
||||
enabled && (controllerConnected || tv || forced)
|
||||
|
||||
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
||||
fun isTvDevice(context: Context): Boolean {
|
||||
|
||||
@@ -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 load→save
|
||||
* 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
|
||||
@@ -94,37 +59,17 @@ data class Settings(
|
||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||
/**
|
||||
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||
* gamepad chrome) — mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
|
||||
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
|
||||
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
|
||||
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
|
||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||
*/
|
||||
val gamepadUiEnabled: Boolean = true,
|
||||
/**
|
||||
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
|
||||
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
|
||||
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
|
||||
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
|
||||
* is on, which is why both settings screens hide the row when the switch is off. Anything
|
||||
* unrecognized resolves to `"connected"`. A TV ignores it — it is always in console mode.
|
||||
*/
|
||||
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
|
||||
/**
|
||||
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
||||
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
||||
* 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), then `"oled"`, `"nebula"`,
|
||||
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. 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
|
||||
@@ -138,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
|
||||
@@ -168,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)
|
||||
@@ -187,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:
|
||||
@@ -291,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;
|
||||
@@ -313,27 +167,11 @@ class SettingsStore(context: Context) {
|
||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
|
||||
?: GAMEPAD_UI_WHEN_CONNECTED,
|
||||
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) {
|
||||
@@ -346,31 +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)
|
||||
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -383,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
|
||||
@@ -397,9 +217,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_HUD = "stats_hud_enabled"
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
|
||||
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
|
||||
@@ -412,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
|
||||
@@ -464,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
|
||||
@@ -561,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
|
||||
@@ -588,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)
|
||||
}
|
||||
|
||||
@@ -656,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"),
|
||||
@@ -691,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
|
||||
}
|
||||
|
||||
@@ -769,36 +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)})",
|
||||
)
|
||||
}
|
||||
|
||||
/** (stored value, label) for when the console UI takes over — the Apple client's table verbatim.
|
||||
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
|
||||
val GAMEPAD_UI_MODE_OPTIONS = listOf(
|
||||
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
|
||||
GAMEPAD_UI_ALWAYS to "Always",
|
||||
)
|
||||
|
||||
/** (mode, label) for the touch-input model. */
|
||||
val TOUCH_MODE_OPTIONS = listOf(
|
||||
TouchMode.TRACKPAD to "Trackpad",
|
||||
@@ -806,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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
|
||||
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 35-double
|
||||
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
|
||||
* `capture→decoded` 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 (18–21) when nonzero.
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), 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 (10–13), 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 capture→displayed 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(" · ")
|
||||
}
|
||||
|
||||
@@ -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,181 +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", "oled", "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(7, 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
|
||||
* not merely the darkest of the dark fields. The blob field this client draws samples the
|
||||
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
|
||||
*/
|
||||
@Test
|
||||
fun oledIsActuallyBlack() {
|
||||
val oled = GamepadPalette.named("oled")
|
||||
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
|
||||
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
|
||||
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
|
||||
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
|
||||
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
|
||||
val darkestOther = GamepadPalette.ALL
|
||||
.filter { it.id != "oled" && it.stops.isNotEmpty() }
|
||||
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
|
||||
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||