Compare commits

..
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 6241639042 debug(touch): mirror PUNKTFUNK_TOUCH_DEBUG lines to a data-dir file
The presenter runs in the spawned punktfunk-session binary, whose stderr
Steam's game-mode reaper swallows on the Deck — so also append the finger/
mouse debug lines to $XDG_DATA_HOME/punktfunk-touch-debug.log (host-visible
under ~/.var/app/io.unom.Punktfunk), which survives regardless of how the
client is launched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:59:46 +02:00
enricobuehlerandClaude Fable 5 7c72899a49 debug(touch): env-gated PUNKTFUNK_TOUCH_DEBUG finger/mouse logging
Logs every raw SDL Finger{Down,Motion,Up} (with the is_direct_touch
result) and MouseMotion/MouseButton event when PUNKTFUNK_TOUCH_DEBUG=1,
to diagnose why touchscreen input is dropped on the Steam Deck under
game-mode gamescope (both trackpad and touch-passthrough dead at once =
finger events not reaching the engine). Zero behavior change when unset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:07:40 +02:00
1603 changed files with 56840 additions and 411929 deletions
-18
View File
@@ -1,18 +0,0 @@
# Workspace-wide build flags.
#
# aes_armv8: RustCrypto's `aes` 0.8.x enables ARMv8-Crypto hardware AES on aarch64 only behind
# this cfg (x86_64 AES-NI is runtime-detected with no flag; the 0.9 line will make aarch64
# automatic too). Without it every aarch64 client (all Apple + virtually all Android) ran
# SOFTWARE AES on the per-packet decrypt path — measured 2026-07-14 on an M3 Ultra at
# ~240 MiB/s/core (~7 µs per 1.4 KB datagram), which single-handedly capped receive throughput
# at ~1.57 Gbps wire. The cfg still runtime-detects via `cpufeatures`, so a chip without the
# extensions falls back safely.
#
# NOTE: a RUSTFLAGS environment variable OVERRIDES config rustflags entirely — build scripts /
# CI lanes that set RUSTFLAGS for aarch64 targets (cargo-ndk, xcframework) must carry
# `--cfg aes_armv8` themselves.
# polyval_armv8: same story for GCM's other half — `polyval` 0.6.x gates its PMULL (carry-less
# multiply) GHASH path behind this cfg on aarch64. AES alone took open_in_place from 240 to
# ~790 MiB/s on the M3 Ultra; software GHASH still dominated until this flag joined it.
[target.'cfg(target_arch = "aarch64")']
rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
+3 -6
View File
@@ -1,12 +1,9 @@
# The root build context is used by web/Dockerfile (which needs web/ and
# api/openapi.json) and by ci/rust-ci-arm64cross.Dockerfile (which needs the toolchain
# pin). Allowlist those; keep everything else (target/, .git, crates) out of the
# context upload.
# Root build context is used only by web/Dockerfile, which needs web/ and
# api/openapi.json. Allowlist those; keep everything else (target/, .git, crates)
# out of the context upload.
*
!web
!api/openapi.json
!rust-toolchain.toml
!ci/pf-host-cc
web/node_modules
web/.output
web/dist
+17 -19
View File
@@ -4,14 +4,6 @@
# `screenshots` job, gated to STABLE RELEASE tags only. Standalone + best-effort: a failure here
# reds nothing else. PNGs land as a 30-day artifact; not committed or published.
name: android-screenshots
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
@@ -22,27 +14,33 @@ jobs:
screenshots:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
# JDK 21 + SDK baked (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 1721).
# The tests are pure JVM (no NDK), but sharing android.yml's image means one image to
# keep warm instead of a per-run setup-java + sdkmanager download pair.
container:
image: 192.168.1.58:5010/punktfunk-android-ci:latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: JDK 21 (AGP 9.2 + Robolectric's SDK-36 android-all jar both want 1721)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Android SDK
# SHA-pinned for parity with android.yml (third-party action). v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP
# if the platform channel lacks it (same note as android.yml).
- name: platform-tools + platform 36 + build-tools
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0"
- name: Cache (gradle)
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
# gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the
# Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise
# restore a key that can never hold the new one. Namespace shared with android.yml —
# it is the same content; two keys just stored it twice in the central cache.
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts') }}
restore-keys: android-screenshots-
# Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps
# the cargo-ndk native build out of the graph — the tests never load libpunktfunk_android.so.
+40 -80
View File
@@ -2,112 +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 (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
# 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: JDK 21 (AGP 9.2 runs on JDK 1721, not the host default)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# 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: Rust toolchain + Android targets (self-healing on a fresh runner)
run: |
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
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
- name: Cache (gradle)
- 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
- 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: 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-
- name: cargo-ndk
run: command -v cargo-ndk >/dev/null || cargo install cargo-ndk
- name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android
-70
View File
@@ -1,70 +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 }}
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
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 }}
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
-86
View File
@@ -8,57 +8,13 @@
# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated
# from the build/test job and best-effort, so a capture gap never reds the core signal.
name: apple
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
pull_request:
paths:
- 'crates/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
# side stays on DerivedData (sccache doesn't cache swiftc).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
# runner's persistent target/ disposable instead of precious.
CARGO_INCREMENTAL: "0"
jobs:
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
@@ -85,18 +41,6 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
# system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs).
@@ -155,18 +99,6 @@ jobs:
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
@@ -183,20 +115,6 @@ jobs:
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
- name: Pin + prune DerivedData (same disease release.yml already cures)
# screenshots.sh builds into a throwaway mktemp DerivedData per invocation — two
# fresh ~1 GB trees per run, zero reuse. Pin one stable root (PF_SHOT_DERIVED_DATA,
# honored by the script) so repeat runs are incremental, and GC anything a week old
# in the default DerivedData root that no pin owns.
run: |
DD="$HOME/ci/derived-data/screenshots"
mkdir -p "$DD"
echo "PF_SHOT_DERIVED_DATA=$DD" >> "$GITHUB_ENV"
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
@@ -210,10 +128,6 @@ jobs:
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
echo "Produced:"; ls -la screenshots || true
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
if: always()
run: xcrun simctl shutdown all || true
- name: Upload screenshots (zip artifact)
if: always()
# v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+
+10 -111
View File
@@ -1,4 +1,4 @@
# Build the punktfunk-host / punktfunk-client / punktfunk-web / punktfunk-scripting pacman packages from
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
@@ -14,36 +14,10 @@
# 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.
@@ -53,41 +27,26 @@ on:
env:
REGISTRY: git.unom.io
OWNER: unom
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io). NOTE: makepkg runs
# behind `sudo -u builder env ...`, which strips ambient env — the makepkg step
# re-exports these explicitly.
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
CARGO_INCREMENTAL: "0"
jobs:
build-publish:
runs-on: ubuntu-24.04
container:
# Everything the two pacman steps below used to download (~1 GB/run) is baked in,
# plus bun, sccache and node (ci/arch-ci.Dockerfile). The steps stay as --needed
# no-op guards for the one push where :latest lags an image-content change.
image: 192.168.1.58:5010/punktfunk-arch-ci:latest
image: docker.io/library/archlinux:base-devel
timeout-minutes: 90
env:
CARGO_HOME: /usr/local/cargo
steps:
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
- name: Build + runtime-dev deps (no-op guard — baked into arch-ci)
# No -Syu: the image's snapshot IS the build environment (see the Dockerfile's
# rolling-release note); with everything installed this resolves locally and
# does nothing. It only matters on the push that adds a dep before the image
# rebuild lands — same bootstrap note as ci.yml's GTK4 step.
- name: Install build + runtime-dev deps
run: |
pacman -S --noconfirm --needed \
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages"
mesa libglvnd unzip libarchive
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
# it's AUR-only on Arch, so bootstrap the official binary.
command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
@@ -112,26 +71,11 @@ 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.)
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
esac
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
@@ -161,61 +105,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 \
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
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 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
- 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')
+13 -115
View File
@@ -1,64 +1,32 @@
# Supply-chain advisory scan for EVERY dependency tree the project ships or publishes, plus the
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
# it lands).
# Supply-chain advisory scan for BOTH dependency trees the project ships to users:
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
# * bun audit → each Bun-managed tree that ships or publishes: web (the mgmt console BFF —
# login gate, session sealing, mgmt bearer token), sdk (@punktfunk/host),
# plugin-kit (@punktfunk/plugin-kit).
# * pnpm audit → clients/decky (the Steam Deck plugin).
# * docs-site → scanned NON-blocking (continue-on-error): known transitive advisories ride in
# via the CMS/UI chain (@unom/ui → payload → dompurify/monaco) and the nitropack
# build chain (node-tar, brace-expansion); clearing them needs coordinated bumps
# verified against the LIVE site (the docs don't build standalone) — tracked in
# punktfunk-planning design/cra-readiness.md. Flip to blocking once clean.
# * cargo-about → license-allowlist gate over BOTH Rust workspaces (about.toml `accepted`);
# fails if any crate carries a license outside the allowlist — the regression
# guard about.toml always promised. (The Android Gradle tree has no lockfile, so
# nothing scans it — see the CRA roadmap.)
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
# change, and on demand.
# * bun audit → the web management console (Nitro/Bun BFF) — the component that holds the login
# gate, session sealing, and the mgmt bearer token, so its deps matter too.
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile change (catch a bad
# dep the moment it lands), and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
name: audit
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
push:
branches: [main]
paths:
- 'Cargo.lock'
- 'packaging/windows/drivers/Cargo.lock'
- 'web/bun.lock'
- 'docs-site/bun.lock'
- 'sdk/bun.lock'
- 'plugin-kit/bun.lock'
- 'clients/decky/pnpm-lock.yaml'
- 'about.toml'
- '.gitea/workflows/audit.yml'
paths: ['Cargo.lock', 'web/bun.lock', '.gitea/workflows/audit.yml']
workflow_dispatch:
jobs:
cargo-audit:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Cache /usr/local/cargo so the cargo-audit binary (and the advisory DB clone) persist.
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/bin
/usr/local/cargo/registry
path: /usr/local/cargo
key: cargo-audit-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-audit-
- name: cargo audit
@@ -68,17 +36,13 @@ jobs:
cargo audit
bun-audit:
strategy:
fail-fast: false
matrix:
tree: [web, sdk, plugin-kit]
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: ${{ matrix.tree }}
working-directory: web
steps:
# oven/bun's slim base lacks a CA bundle + git — actions/checkout's HTTPS fetch needs them
# (same preamble as web-screenshots.yml / ci.yml's web job).
@@ -86,75 +50,9 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
# `bun audit` queries the registry advisory DB for the versions pinned in the tree's
# bun.lock. No install/build needed — it reads the manifest + lockfile. Fails the job on any
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
# bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
# noting why here).
# `bun audit` queries the registry advisory DB for the versions pinned in web/bun.lock. No
# install/build needed — it reads the manifest + lockfile. Fails the job on any advisory, the
# same fail-on-vulnerability stance as cargo-audit above; triage a finding by bumping the dep
# (or, if genuinely unfixable + inapplicable, pinning a resolution and noting why here).
- name: bun audit
run: bun audit
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
# act_runner does not reliably honor — a red job here would take the whole run red). The full
# advisory list still lands in the log; the warning marks it wasn't clean.
docs-site-audit:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: docs-site
steps:
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
- name: bun audit (non-blocking)
run: bun audit || echo "::warning::docs-site has known advisories (CMS/UI + nitropack chains) — tracked in punktfunk-planning design/cra-readiness.md"
pnpm-audit:
runs-on: ubuntu-24.04
container:
image: node:22-bookworm
timeout-minutes: 15
defaults:
run:
working-directory: clients/decky
steps:
- uses: actions/checkout@v4
# decky is pnpm-managed (pnpm-lock.yaml lockfileVersion 9.0 → pnpm 10 reads it). Like
# bun audit, `pnpm audit` needs no install/build — lockfile + registry advisory DB only.
# --prod: rollup bundles only the prod deps into the shipped plugin; devDependencies are
# build tooling that never leaves CI (auditing them fails on toolchain advisories that
# can't reach a user — the docs-site problem in miniature).
- name: pnpm audit
run: |
npm install -g pnpm@10
pnpm audit --prod
# The regression guard about.toml documents: fail if any crate in either Rust workspace carries
# a license outside the `accepted` allowlist (e.g. a copyleft dep silently entering the linked
# set). cargo-about is version-pinned: the config uses the per-crate `accepted` syntax
# validated against exactly this version.
license-gate:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/bin
/usr/local/cargo/registry
key: cargo-about-0.9.1
restore-keys: cargo-about-
- name: cargo about license gate (host + driver workspaces)
run: |
git config --global --add safe.directory "$PWD"
command -v cargo-about >/dev/null 2>&1 || cargo install --locked cargo-about --version 0.9.1 --features cli
cargo about generate about.hbs --fail -o /dev/null
cargo about generate -m packaging/windows/drivers/Cargo.toml -c about.toml about.hbs --fail -o /dev/null
-59
View File
@@ -1,59 +0,0 @@
# Report-only CPU benchmarks, moved out of ci.yml: they never fail the build (shared CI
# hardware is too noisy to gate on), so running them per-push only occupied a fleet slot
# during fan-out storms. Nightly + on demand is exactly as much signal at none of the
# queue cost. The tight regression gate + the real encode/stream path live on the
# self-hosted GPU runner (Tier 3, bench-gpu.yml).
name: bench
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
schedule:
- cron: '30 4 * * *'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
bench:
# Tier-1 (criterion microbenchmarks) + Tier-2 (FEC loss recovery) — GPU-free, so they run here.
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
- name: Prep
run: |
git config --global --add safe.directory "$PWD"
command -v python3 >/dev/null || { apt-get update && apt-get install -y --no-install-recommends python3; }
- name: Tier-1 microbenchmarks (criterion)
run: cargo bench -p punktfunk-core --bench pipeline -- --warm-up-time 1 --measurement-time 3
- name: Tier-2 FEC loss recovery (loss-harness)
run: cargo run -q -p loss-harness
- name: Compare vs baseline (report-only)
run: python3 scripts/bench/compare.py --threshold 0.5
+26 -160
View File
@@ -1,58 +1,23 @@
# CI for punktfunk (Gitea Actions). Linux jobs run on the `ubuntu-24.04` fleet label; the
# Rust job runs inside the prebuilt builder image (ci/rust-ci.Dockerfile — system FFmpeg 8,
# CI for punktfunk (Gitea Actions). Linux jobs run on the `ubuntu-latest` runner; the Rust
# job runs inside the prebuilt builder image (ci/rust-ci.Dockerfile — system FFmpeg 8,
# PipeWire, GL/GBM, libcuda link stub, pinned-channel rustup) so the workspace links the
# same libs as the dev boxes. Builder images come from the LAN registry on home-ci-core
# (content-keyed, docker.yml) — never the WAN. Apple client CI lives in apple.yml (macOS
# runner). The report-only benchmarks moved to bench.yml (nightly + dispatch) so they stop
# occupying a fleet slot on every push.
# same libs as the dev boxes. Apple client CI lives in apple.yml (macOS runner).
name: ci
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
pull_request:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
rust:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# punktfunk-client-linux link deps. Also baked into rust-ci.Dockerfile — but ci.yml
# runs against the image from the PREVIOUS push (docker.yml bootstrap note), so this
# keeps the job green across image-content changes; a no-op once the image has them.
@@ -61,30 +26,6 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev
# The committed pf-zerocopy SPIR-V blobs are pulled in with include_bytes! and rebuilt only
# by hand — edit a .comp, forget the rebuild, and the OLD kernel ships with no compile error
# or failing test. Recompile each shader and diff the disassembly. Filtering OpSourceExtension
# (+ --no-header) is exactly what absorbs the shaderc-vs-glslang generator difference; every
# instruction, ID and constant must match.
#
# Disassemble to FILES rather than `diff <(…) <(…)`: Gitea's runner executes a step's `run:`
# under `sh -e`, not bash, and dash has no process substitution — the shell rejected the
# script at PARSE time, so the gate never compared anything. Worse, it took the whole `rust`
# job with it: Format, Clippy, Build, Test and every gate below were skipped on each of the
# 35 commits between the gate landing (143a707f) and this fix. A gate that cannot run is
# indistinguishable from one that passes, which is exactly the failure it exists to prevent.
- name: Shader SPIR-V drift gate (pf-zerocopy)
run: |
apt-get install -y --no-install-recommends glslang-tools spirv-tools
for s in rgb2nv12_buf cursor_blend; do
d=crates/pf-zerocopy/src/imp
glslangValidator -V "$d/$s.comp" -o "/tmp/$s.spv" >/dev/null
spirv-dis --no-header "$d/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.committed"
spirv-dis --no-header "/tmp/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.rebuilt"
diff "/tmp/$s.committed" "/tmp/$s.rebuilt" \
|| { echo "::error::$d/$s.spv is stale — rebuild it from $s.comp"; exit 1; }
done
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
# registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
@@ -120,49 +61,9 @@ jobs:
- name: Test (unit + loopback + proptest + C ABI harness)
run: cargo test --workspace --locked
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
# lines carrying ~70 `unsafe` blocks. Their ONLY prior CI coverage was deb.yml's
# `cargo build`, where warnings are not errors, so pf-encode's own
# `#![deny(clippy::undocumented_unsafe_blocks)]` — the crate's stated unsafe-proof gate —
# was never actually enforced on them. (`pyrowave` needs no extra step: punktfunk-host has
# `default = ["pyrowave"]`, so the steps above already cover it.)
#
# `--all-targets` is load-bearing, not decoration: without it the feature-gated
# `#[cfg(test)]` modules are never compiled, which is exactly how all ten
# `NvencCudaEncoder::open` call sites in nvenc_cuda.rs's tests drifted to the wrong arity
# (E0061 x10) without any job noticing.
#
# GPU-free: every test needing real hardware is `#[ignore]`d, and NVENC/CUDA resolve their
# entry points at RUNTIME (dlopen), so the test binary links without a driver present.
# (On MSVC the same crate link-imports those symbols instead, which is why windows-host.yml
# can only type-check these tests via clippy — see the note there.)
#
# Scoped to `-p pf-encode` with ITS OWN feature names: punktfunk-host has no code gated on
# `nvenc`/`vulkan-encode` (its only `cfg(feature)` sites are the two `pyrowave` ones in
# capture.rs, and pyrowave is default-on, so the steps above already cover them). Going
# through `--features punktfunk-host/...` would force punktfunk-host into the selection and
# re-run its entire test suite a second time for no extra coverage.
#
# `pyrowave` is listed explicitly even though it is punktfunk-host's default: selecting only
# `-p pf-encode` takes the host out of the resolution, and pf-encode's own default is empty.
# Naming it keeps this the SHIPPED Linux feature set — deb.yml builds
# `--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode` WITHOUT
# `--no-default-features`, so the .deb carries nvenc + vulkan-encode + pyrowave together, and
# that combination is what deserves the lint.
- name: Clippy + test the feature-gated Linux encode backends
run: |
cargo clippy -p pf-encode --all-targets --locked \
--features nvenc,vulkan-encode,pyrowave -- -D warnings
cargo test -p pf-encode --locked --features nvenc,vulkan-encode,pyrowave
- name: C ABI harness (standalone link proof)
run: bash crates/punktfunk-core/tests/c/run.sh
- name: sccache stats (visibility only)
run: sccache --show-stats
- name: Verify generated header is committed & up to date
run: |
cargo build -p punktfunk-core --locked
@@ -170,63 +71,6 @@ jobs:
git diff --exit-code include/punktfunk_core.h \
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
# The client stack cross-checked for aarch64. NOT an artifact job — deb.yml ships those —
# this exists so a portability defect fails here instead of surfacing in a release build or
# on a user's board. It earns its runtime: the bug that motivated it (a Vulkan extension
# array typed `*const i8`, where `c_char` is signed on x86_64 and UNSIGNED on aarch64)
# compiled cleanly on every target CI built at the time.
#
# Client crates only, listed explicitly: the host's encode stack is x86 (NVENC/QSV/AMF) and
# `--workspace` would drag it in. Runs in the cross image (amd64 toolchain + arm64 sysroot,
# ci/rust-ci-arm64cross.Dockerfile) on the ordinary runner — no arm64 runner involved.
rust-arm64:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci-arm64cross:latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
with:
path: target
# Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache.
key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-arm64-v1-${{ env.rustc }}-
- name: Clippy for aarch64 (deny warnings)
run: |
cargo clippy --target aarch64-unknown-linux-gnu --all-targets --locked \
-p punktfunk-core -p pf-client-core -p pf-presenter -p pf-console-ui \
-p punktfunk-client-session -p punktfunk-client-linux \
-- -D warnings
# The minimal embedded build — no Skia, no PyroWave — is what a small image installs, so
# it has to keep compiling on its own, not just as a subset of the default features.
- name: Build the session binary, minimal features
run: |
cargo build --release --target aarch64-unknown-linux-gnu --locked \
-p punktfunk-client-session --no-default-features
web:
runs-on: ubuntu-24.04
container:
@@ -275,3 +119,25 @@ jobs:
run: bun run build
- name: Typecheck
run: bun run lint
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: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- 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
+20 -295
View File
@@ -1,58 +1,18 @@
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
# apt distribution/component:
#
# build-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
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
#
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
# host and client packages always share a version line. The release-attach helpers are race-safe
# (ensure_release create-or-fetch; upsert_asset only conflicts on same-name), so the jobs run parallel.
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the
# target boxes run.
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages
# Box setup (once): see packaging/debian/README.md
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
name: deb
# 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 +24,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
@@ -142,20 +83,22 @@ jobs:
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-v3-${{ env.rustc }}-
- name: Build release clients
- name: Build release host + client
env:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
run: |
git config --global --add safe.directory "$PWD"
# THREE binaries ship in the client .deb, so all three are built here: the GTK shell,
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), and
# punktfunk-cli (the headless `punktfunk` front-end). build-client-deb.sh installs all
# three; 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
# 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).
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -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
@@ -185,12 +128,10 @@ jobs:
- name: Build .debs
run: |
export PATH="$HOME/.bun/bin:$PATH"
# host .deb is built in build-publish-host (Ubuntu 24.04 image); this job ships the rest.
VERSION="$VERSION" bash packaging/debian/build-deb.sh
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
# The plugin/script runner (bun-bundled Effect SDK) — same vendored-bun mechanics.
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-scripting-deb.sh
- name: Publish to the Gitea apt registry
env:
@@ -225,219 +166,3 @@ jobs:
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
# ---------------------------------------------------------------------------------------------
# The HOST .deb — built on Ubuntu 24.04 (noble) with a from-source FFmpeg 8 BUNDLED, so it installs
# on Ubuntu 24.04 LTS through 26.04 (see the file header + packaging/debian/README.md). Runs in
# parallel with build-publish and publishes to the SAME distribution/component; the version step is
# byte-identical so host and clients share a version line.
build-publish-host:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/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"
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
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 "host package version $V -> apt distribution '$DIST'"
# dpkg-shlibdeps/dpkg-deb + patchelf are baked into rust-ci-noble; python3 is for the
# release-attach helper. Re-install defensively so the job stays green against the PREVIOUS
# image on the same push (docker.yml bootstrap lag) — a no-op once the image ships them.
- name: dpkg-dev + patchelf + python3
run: |
apt-get update
apt-get install -y --no-install-recommends dpkg-dev patchelf 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
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
# ci.yml's 26.04 target cache (mixing would poison both).
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
- name: Build release host
env:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
run: |
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.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host
- name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
# binary's rpath, so there is no `Depends: libavcodec62` to block install on 24.04. FFMPEG_PREFIX
# defaults to /opt/ffmpeg (the image's ENV also sets it).
run: |
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
- 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 host to $OWNER/debian $DISTRIBUTION/$COMPONENT"
- name: Attach the host .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
# ---------------------------------------------------------------------------------------------
# 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
+30 -39
View File
@@ -6,12 +6,17 @@
#
# The plugin backend is PURE PYTHON (clients/decky/main.py — no compiled binary), so we do NOT
# need the Decky CLI (which requires Docker + rust-nightly only to compile native backends).
# We build the frontend with pnpm and stage the store-layout tree with the SAME script local
# builds use (clients/decky/scripts/package.sh) — the plugin's file list lives in exactly ONE
# place, so a file added there (bin/, assets/, controller_config/, …) can never be silently
# missing from the published build. (Hand-assembling the zip here is how the shipped plugin
# lost the shortcut artwork + Steam Input layout for a while.) CI only adds `update.json` on
# top: the {channel, manifest} pointer the plugin's self-update check polls.
# We build the frontend with pnpm and assemble the store-layout zip by hand:
#
# punktfunk.zip
# punktfunk/ <- single top-level dir == plugin.json "name"
# plugin.json [required]
# package.json [required; CI stamps "version" — Decky reads the installed version here]
# main.py [required: python backend]
# dist/index.js [required: rollup output]
# update.json [CI-baked {channel, manifest}: where the plugin's self-update check polls]
# README.md (recommended)
# LICENSE [required by the plugin store]
#
# SELF-UPDATE (no Decky store): alongside the zip we also publish a tiny per-channel
# `manifest.json` ({version, artifact=<immutable per-version zip URL>, sha256}). The installed
@@ -20,25 +25,10 @@
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
name: decky
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'clients/decky/**'
- 'scripts/ci/**'
- '.gitea/workflows/decky.yml'
tags: ['v*']
workflow_dispatch:
@@ -100,27 +90,28 @@ jobs:
- name: Assemble store-layout zip
working-directory: ${{ gitea.workspace }}
run: |
# node:22-bookworm ships python3 (a package.sh dep) but not zip; install both anyway
# so an image change can't silently break the build.
apt-get update && apt-get install -y --no-install-recommends zip python3 >/dev/null
# Stage the canonical plugin tree (dist/, main.py, bin/, assets/, controller_config/,
# LICENSE, …) with the same script local/sideload builds use — see the header comment.
# Runs AFTER the version stamp, so the staged package.json carries $VERSION.
bash clients/decky/scripts/package.sh
DEST="clients/decky/out/$PLUGIN"
# CI-only addition: the self-update channel pointer the backend reads (main.py
# check_update). It points at THIS channel's manifest.json (published below); that
# manifest in turn points at the immutable per-version zip, so its sha256 stays valid
# across future alias re-uploads.
apt-get update && apt-get install -y --no-install-recommends zip >/dev/null
STAGE="$RUNNER_TEMP/decky"
DEST="$STAGE/$PLUGIN"
rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin"
cp clients/decky/plugin.json "$DEST/"
cp clients/decky/package.json "$DEST/"
cp clients/decky/main.py "$DEST/"
cp clients/decky/dist/index.js "$DEST/dist/"
cp clients/decky/README.md "$DEST/"
# The stream-launch wrapper (target of the Steam shortcut); keep it executable
# (runner_info() also re-chmods at runtime in case the zip/extract drops the bit).
cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/"
chmod 0755 "$DEST/bin/punktfunkrun.sh"
# Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0.
cp LICENSE-MIT "$DEST/LICENSE"
# Self-update channel pointer the backend reads (main.py check_update). It points at
# THIS channel's manifest.json (published below); that manifest in turn points at the
# immutable per-version zip, so its sha256 stays valid across future alias re-uploads.
printf '{"channel":"%s","manifest":"%s/%s/manifest.json"}\n' "$ALIAS" "$BASE" "$ALIAS" > "$DEST/update.json"
( cd clients/decky/out && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" )
ls -lh "$RUNNER_TEMP/punktfunk.zip"
unzip -l "$RUNNER_TEMP/punktfunk.zip"
# Backstop against packaging drift: the runtime-loaded pieces MUST be in the zip.
for f in main.py dist/index.js bin/punktfunkrun.sh assets/grid.png \
controller_config/punktfunk.vdf update.json; do
unzip -l "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN/$f" >/dev/null || { echo "MISSING $f" >&2; exit 1; }
done
# The update manifest the plugin polls: the immutable per-version artifact + its
# sha256 (Decky's installer verifies the download against this hash, aborting on
# mismatch — so it MUST be the per-version URL, never the mutable alias).
-38
View File
@@ -99,41 +99,3 @@ jobs:
mkdir -p ~/unom-flatpak/site/repo
cd ~/unom-flatpak
docker compose -f compose.production.yml up -d
winget:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync winget source compose + server
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
# Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix).
source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs"
target: "~/unom-winget"
strip_components: 3
overwrite: true
- name: Start winget REST source
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
# ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each
# stable tag (same content/config split as the flatpak repo). Ensure the bind-mount
# source exists so the container starts; it serves 503 until the first catalogue lands.
mkdir -p ~/unom-winget/data
cd ~/unom-winget
docker compose -f compose.production.yml up -d
# Surface a missing catalogue here rather than letting winget report "no package found".
sleep 3
curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it"
+24 -175
View File
@@ -1,37 +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, 192.168.1.58:5010 — 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.
#
# 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 only —
# the LAN registry is unauthenticated inside the LAN).
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope.
#
# 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:
@@ -42,151 +22,9 @@ on:
env:
REGISTRY: git.unom.io
OWNER: unom
CI_REGISTRY: 192.168.1.58:5010
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
- 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/${{ matrix.image }}:$KEY" \
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
ci
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
# 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 -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
# 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/$IMAGE:$KEY" \
-t "$CI_REGISTRY/$IMAGE:latest" \
.
- name: Push
if: steps.exists.outputs.hit == 'false'
run: |
docker push "$CI_REGISTRY/$IMAGE:$KEY"
docker push "$CI_REGISTRY/$IMAGE:latest"
- 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 -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
apps:
build-push:
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
@@ -198,6 +36,17 @@ jobs:
- image: punktfunk-docs
dockerfile: docs-site/Dockerfile
context: docs-site
- image: punktfunk-rust-ci
dockerfile: ci/rust-ci.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
@@ -212,7 +61,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}" \
@@ -231,7 +80,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
+3 -54
View File
@@ -19,14 +19,6 @@
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with deb/rpm/docker).
name: flatpak
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
@@ -64,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`
@@ -92,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.
@@ -146,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/ffmpeg 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
+1 -33
View File
@@ -5,55 +5,23 @@
# Standalone + best-effort: a failure here reds nothing else. PNGs land as a 30-day
# artifact; they are not committed or published.
name: linux-client-screenshots
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
tags: ["v*"]
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
screenshots:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
# Same image as ci.yml/deb.yml — already carries the Rust toolchain + GTK/SDL build deps.
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
image: git.unom.io/unom/punktfunk-rust-ci:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# Client link deps (baked into the image; kept here so the job is green across image
# rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server,
# software GL+Vulkan (llvmpipe/lavapipe), the icon theme + fonts the UI draws with, and a
-96
View File
@@ -1,96 +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
- 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
+12 -170
View File
@@ -56,14 +56,6 @@
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
# loud warning.
name: release
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
@@ -88,21 +80,6 @@ on:
required: false
default: "true"
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
# side stays on DerivedData (sccache doesn't cache swiftc).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
# runner's persistent target/ disposable instead of precious.
CARGO_INCREMENTAL: "0"
jobs:
apple:
runs-on: macos-arm64
@@ -172,38 +149,6 @@ jobs:
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
- name: Pin + prune Xcode DerivedData
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
# (~/.cache/act/<hash>/hostexecutor), so each rotation minted a brand new ~760 MB tree
# under ~/Library that nothing ever collected. 31 of them piled up in three days
# (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed
# v0.16.0's xcframework build with "No space left on device". Pinning one path makes the
# tree REUSED instead of multiplied — it also keeps the module cache warm between runs.
run: |
DD="$HOME/ci/derived-data/release"
mkdir -p "$DD"
echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV"
# Safety net for trees the pin does not own: the legacy per-path ones from before this
# change, and anything another job leaves in the default root. Untouched for a week ⇒ gone.
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
echo "disk after prune:"; df -h /System/Volumes/Data | tail -1
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS)
# tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on
# the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the
@@ -231,7 +176,6 @@ jobs:
-project "$PROJECT" -scheme Punktfunk \
-destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGNING_ALLOWED=NO
@@ -314,12 +258,9 @@ jobs:
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
# regenerate the managed *development* profile — needed because the App Groups capability
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
# step below
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
# distribution profile that export needs.
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
@@ -329,12 +270,7 @@ 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" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
@@ -372,33 +308,19 @@ jobs:
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
# this step used to set matched it and failed the archive ("does not support provisioning
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
# App Store distribution profile survives for export.
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
# manually-installed App Store distribution profile survives for export.
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk iOS App Store Distribution"
# The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second
# distribution artifact in the .ipa, so manual signing must map its App ID to its own
# App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets").
WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk-iOS \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-derivedDataPath "$DERIVED_DATA" \
-skipMacroValidation -skipPackagePluginValidation \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
@@ -413,10 +335,7 @@ jobs:
<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><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
EOF
@@ -428,76 +347,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.
@@ -510,11 +359,9 @@ jobs:
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
# DISTRIBUTION signing is the export step below (manual, plist).
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk tvOS App Store Distribution"
@@ -522,12 +369,7 @@ 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" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$TEAM_ID"
+10 -121
View File
@@ -9,33 +9,10 @@
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
name: rpm
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'web/**'
- 'sdk/**'
- 'packaging/rpm/**'
- 'packaging/gamescope/**'
- 'packaging/bazzite/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/rpm.yml'
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
# version-shadow (a release outranking rolling builds in one group) is structurally gone.
@@ -45,15 +22,6 @@ on:
env:
REGISTRY: git.unom.io
OWNER: unom
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
build-publish:
@@ -72,23 +40,13 @@ jobs:
group: fedora-44
fedver: 44
container:
image: 192.168.1.58:5010/${{ matrix.image }}:latest
image: git.unom.io/unom/${{ matrix.image }}:latest
timeout-minutes: 90
env:
CARGO_HOME: /usr/local/cargo
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
# rpmbuild + git archive need the checkout trusted; cache the crates download.
# The client link deps are also baked into the fedora-rpm image, but this job runs
# against the image from the PREVIOUS push (docker.yml bootstrap note) — keep it
@@ -101,11 +59,6 @@ jobs:
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 || {
@@ -117,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>
@@ -139,14 +92,11 @@ jobs:
echo "rpm $V-$R -> group '$GROUP'"
- name: Build RPM
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
# Recommends both). Both need bun (ensured in Prep).
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
# globs it in; the host RPM Recommends it). Needs bun (ensured in Prep).
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=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 }}
@@ -173,87 +123,26 @@ 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
dist/punktfunk-web-"${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
-68
View File
@@ -1,68 +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, VB-CABLE, 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).
- name: Install syft
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.49.0
- 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
-68
View File
@@ -1,68 +0,0 @@
# Publish the TypeScript SDK (@punktfunk/host) to the Gitea npm registry
# (https://git.unom.io/api/packages/unom/npm/).
#
# Trigger: push a tag `sdk-vX.Y.Z` (must equal sdk/package.json "version"), or run manually.
# The SDK versions independently of the app's `v*` tags, so bumping the host doesn't republish it.
#
# 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:
tags: ['sdk-v*']
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: sdk
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
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Typecheck
run: bun run typecheck
- name: Test
run: bun test
- name: Build (dist/ JS + .d.ts)
run: bun run build
- name: Tag matches package version
if: startsWith(github.ref, 'refs/tags/')
run: |
TAG="${GITHUB_REF_NAME#sdk-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
env:
NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
# .npmrc already maps the @punktfunk scope to the registry; append the auth line.
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc
bun publish
-8
View File
@@ -6,14 +6,6 @@
# host packaging). Best-effort: a standalone workflow, so a failure here reds
# nothing else. PNGs land as a 30-day artifact; they are not committed or published.
name: web-screenshots
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
+3 -11
View File
@@ -11,14 +11,6 @@
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
# first GITHUB_ENV line — see windows.yml).
name: windows-drivers
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
@@ -161,9 +153,9 @@ jobs:
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
- 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 clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
run: |
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
+13 -358
View File
@@ -1,8 +1,7 @@
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
# run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
# windows-amd64 runner
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
#
@@ -22,32 +21,17 @@
#
# 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.
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
# AMD/Intel-only boxes before main).
# - QSV native (Intel, VPL — design/native-qsv-encoder.md): the MIT dispatcher is built from the
# vendored tree (libvpl-sys, cmake+bindgen — LIBCLANG_PATH already in the runner env for
# pyrowave-sys) and statically linked; the GPU runtime comes from the Intel driver store at run
# time, so no new DLL ships and non-Intel boxes are unaffected. This is the Intel dispatch;
# the ffmpeg *_qsv path below stays as its open-failure fallback until Phase 4 deletes it.
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
# 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:
@@ -56,31 +40,9 @@ on:
- 'crates/punktfunk-host/**'
- 'crates/punktfunk-core/**'
- 'crates/punktfunk-tray/**'
# The encode subsystem (split out in W6) + the vendored VPL dispatcher the `qsv` feature
# 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/**'
- 'sdk/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-host.yml'
@@ -91,15 +53,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,15 +85,10 @@ 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
# 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
"CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# 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
@@ -166,11 +114,10 @@ jobs:
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "host version $v"
- name: Build (release, nvenc + amf-qsv + qsv)
- name: Build (release, nvenc + amf-qsv)
shell: pwsh
# All-vendor host: NVENC (NVIDIA, direct SDK) + native QSV (Intel, static VPL dispatcher)
# + AMF/QSV (AMD + the Intel ffmpeg fallback, libavcodec via FFMPEG_DIR).
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv,qsv
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
- name: Build (release, status tray)
shell: pwsh
@@ -180,89 +127,9 @@ jobs:
- name: Clippy (host + tray, Windows)
shell: pwsh
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
# --release is REQUIRED, not just faster: a default (debug) clippy compiles the whole dep tree
# into a SECOND target dir (C:\t\debug), which means a second full build of openh264-sys2's
# vendored C++ (the software-H.264 fallback in pf-encode) on top of the release copy the Build
# steps above already produced. That second cc-rs `cl.exe` fan-out tips this runner over into
# `cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)` — an environmental
# disk/temp exhaustion, NOT a source error (the identical file compiles fine in the release
# 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" }
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh
@@ -276,18 +143,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: |
@@ -307,7 +162,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.
@@ -325,15 +179,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"
@@ -348,87 +194,13 @@ 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)
shell: pwsh
# `bun build --target=bun` bundles the SDK's runner CLI to ONE self-contained JS (effect + the
# SDK inlined; the dynamic plugin import stays a runtime import). pack-host-installer.ps1 ships
# it (+ the shared bun) and registers its scheduled task DISABLED (opt-in). The SDK's deps are
# public npm (effect), so no @unom token is needed here.
run: |
$bun = $env:BUN_EXE
Push-Location sdk
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "sdk bun install failed ($LASTEXITCODE)" }
New-Item -ItemType Directory -Force -Path C:\t\scripting | Out-Null
& $bun build src/runner-cli.ts --target=bun --outfile=C:\t\scripting\runner-cli.js; if ($LASTEXITCODE) { throw "runner bundle build failed ($LASTEXITCODE)" }
Pop-Location
if (-not (Select-String -Path C:\t\scripting\runner-cli.js -Pattern 'attempt=' -Quiet)) {
throw "runner bundle missing the dynamic plugin import - wrong build"
}
"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' }
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
)
$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
@@ -477,120 +249,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 }}
+13 -35
View File
@@ -21,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:
@@ -60,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:
@@ -101,9 +81,11 @@ jobs:
- 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). FFMPEG_DIR selects the arch's import libs + is read by
# pack-msix.ps1 for the runtime DLLs. All via GITHUB_ENV.
# 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).
@@ -122,15 +104,11 @@ jobs:
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}"
# All three client binaries — the shell spawns punktfunk-session.exe (a package
# sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
# Both client binaries — the shell spawns punktfunk-session.exe (a package sibling)
# for every stream. --no-default-features on ARM64 is a no-op for the shell.
- name: Build (release)
shell: pwsh
# punktfunk-cli builds the `punktfunk.exe` the manifest aliases and pack-msix.ps1
# requires (bf981027 added the requirement without the build — same gap 90c84ef4
# closed for deb).
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
- name: Pack + sign MSIX
shell: pwsh
+9 -32
View File
@@ -22,6 +22,8 @@
# 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,18 +34,10 @@
# - 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:
@@ -73,20 +67,6 @@ on:
- '.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 /
@@ -117,6 +97,7 @@ jobs:
- 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
@@ -139,20 +120,16 @@ jobs:
# 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: |
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
$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
@@ -160,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-ffvk -- --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-ffvk --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 }}
-9
View File
@@ -38,12 +38,3 @@ CLAUDE.md
# Local flatpak-builder output (build-flatpak.sh) — ostree repo + build dir at the repo root.
.flatpak-repo/
.flatpak-build/
# Nix build outputs (flake.nix) — `nix build` result symlinks + direnv cache. flake.lock IS tracked.
/result
/result-*
.direnv/
# Gradle build output inside the vendored pyrowave Granite Android platform (regenerated, never ours to commit)
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/.gradle/
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/build/
-10
View File
@@ -30,16 +30,6 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
## Before you push
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
workspace + the UMDF driver workspace) on every commit and push, so a push can never fail CI
on formatting alone:
```sh
git config core.hooksPath scripts/git-hooks
```
Then the usual full pass:
```sh
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
Generated
+98 -534
View File
File diff suppressed because it is too large Load Diff
+1 -47
View File
@@ -6,32 +6,15 @@ members = [
"crates/punktfunk-host/vendor/usbip-sim",
"crates/punktfunk-tray",
"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-host-config",
"crates/pf-gpu",
"crates/pf-zerocopy",
"crates/pf-frame",
"crates/pf-win-display",
"crates/pf-encode",
"crates/pf-capture",
"crates/pf-inject",
"crates/pf-vdisplay",
"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",
]
@@ -52,42 +35,13 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.22.3"
version = "0.10.1"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
authors = ["unom"]
repository = "https://git.unom.io/unom/punktfunk"
# The `unsafe` discipline the `packaging/windows/drivers/*` crates already run, extended to the
# workspace. `unsafe fn` marks a CONTRACT the caller must uphold; it is not a licence for the whole
# body to skip checking. Without this lint an `unsafe fn` body is unchecked end to end, so a 600-line
# function hides which handful of lines are actually the unsafe ones — exactly the reviewer-hostile
# shape we are working down. (This is the Rust 2024 default; adopting it early also pays off the
# edition migration.)
#
# `deny`, not `warn`. `warn` was never actually a softer setting: CI runs `cargo clippy … -D
# warnings`, which promotes it to a hard error anyway — that is how adopting this lint turned main
# red on every platform for a day without the level in this file ever saying `deny`. A level that
# lies about its own severity is worse than a strict one, so this now states what CI already does,
# and the exemptions are written down per file instead of hiding in a 689-warning wall nobody reads.
#
# THE EXEMPTIONS. Fourteen GPU/FFI backend files carry `#![allow(unsafe_op_in_unsafe_fn)]` with a
# one-line reason each. They are not "not done yet" — they are where this lint stops paying:
# their bodies are ash/CUDA/AMF/libav calls almost line for line (measured: 64% of the sites are a
# single third-party FFI call, and of the 44 `unsafe fn`s in them only 4 have a body containing no
# unsafe operation at all). Narrowing them means one `unsafe {}` per line plus, since pf-encode also
# denies `clippy::undocumented_unsafe_blocks`, one hand-written SAFETY comment per line that could
# only ever restate "an ash call on a live device" — the precise noise that made `unsafe` stop
# meaning anything here before (see the header of `pf-win-display/src/win_display.rs`).
#
# Everything else in the workspace is at zero and enforced. Removing one of those allows, file by
# file, is real work with a real payoff; blanket-narrowing all fourteen is not. Prefer DELETING an
# `unsafe fn` marker over wrapping its body: keep the marker only where a caller can actually break
# something (a raw pointer, a borrowed HANDLE, a GPU object that must not be in flight).
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[profile.release]
opt-level = 3
lto = "thin"
+1 -1
View File
@@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| **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) | `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) |
| **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).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
-32
View File
@@ -56,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
+1070 -1345
View File
File diff suppressed because it is too large Load Diff
+10 -12
View File
@@ -37,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"
+77 -3860
View File
File diff suppressed because it is too large Load Diff
@@ -1,17 +0,0 @@
Font Awesome Free — brand icons (windows, 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.
-10
View File
@@ -1,10 +0,0 @@
Simple Icons — brand icons (arch, nixos, debian 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.
-30
View File
@@ -1,30 +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 | Font Awesome Free brands (CC BY 4.0) |
| `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) |
Distros with no file here (Bazzite, CachyOS, Nobara, Pop!_OS, Mint, …) are intentional:
the host advertises the full chain (`linux/fedora/bazzite`), and clients walk it
most-specific-first, so they degrade to the family mark and finally to Tux.
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved.
Licensing: attribution notices live in `LICENSES/` and are folded into
`THIRD-PARTY-NOTICES.txt` by `scripts/gen-third-party-notices.py`. The marks are
trademarks of their respective owners; they are used here nominatively — to *identify*
the operating system a host runs, the standard practice in this ecosystem — and imply no
affiliation or endorsement.
-2
View File
@@ -1,2 +0,0 @@
<!-- apple — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaApple. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>

Before

Width:  |  Height:  |  Size: 640 B

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

Before

Width:  |  Height:  |  Size: 841 B

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

Before

Width:  |  Height:  |  Size: 2.8 KiB

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

Before

Width:  |  Height:  |  Size: 2.3 KiB

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

Before

Width:  |  Height:  |  Size: 3.6 KiB

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

Before

Width:  |  Height:  |  Size: 880 B

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

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

Before

Width:  |  Height:  |  Size: 937 B

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

-2
View File
@@ -1,2 +0,0 @@
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>

Before

Width:  |  Height:  |  Size: 344 B

-67
View File
@@ -1,67 +0,0 @@
# Android CI builder: JDK 21 + Android SDK/NDK/CMake + pinned Rust with the three shipping
# Android targets + cargo-ndk + sccache. Everything android.yml used to download per run
# (~3 GB of NDK + SDK packages from Google, plus a from-source cargo-ndk build) is baked
# here instead; the image is content-keyed and rebuilt only when the ci/ tree changes
# (docker.yml `builders`).
#
# docker build -f ci/android-ci.Dockerfile -t punktfunk-android-ci ci
#
# Version pins mirror what android.yml installed via sdkmanager: AGP 9.3 wants JDK 1721;
# cmake;3.22.1 because kit/build.gradle.kts prepends $ANDROID_SDK/cmake/3.22.1/bin to PATH
# for cargo-ndk's audiopus_sys (libopus) CMake build; platforms;android-37 is deliberately
# absent (AGP auto-downloads it if a build ever needs it — same note as the old workflow).
FROM ubuntu:26.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git unzip zip python3 openjdk-21-jdk-headless \
build-essential pkg-config \
&& rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
# Android SDK: cmdline-tools must land under cmdline-tools/latest for sdkmanager to
# find its own root.
ENV ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk
ARG CMDLINE_TOOLS=13114758
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
&& curl -fsSL -o /tmp/clt.zip "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" \
&& unzip -q /tmp/clt.zip -d "$ANDROID_HOME/cmdline-tools" \
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
&& rm /tmp/clt.zip
ENV PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH
RUN yes | sdkmanager --licenses >/dev/null \
&& sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" \
"ndk;30.0.14904198" "cmake;3.22.1" \
&& chmod -R a+rX "$ANDROID_HOME"
# Toolchain shared across CI users (jobs may run as different uids) — same shape as
# rust-ci.Dockerfile, plus the Android cross targets and cargo-ndk. The registry/git
# download caches are stripped after the cargo-ndk install: jobs restore those from the
# shared actions cache, and baking them would only bloat every pull.
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
&& cargo install cargo-ndk --locked \
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo ndk --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
# the job container — no node, no checkout (exit 127; same lesson flatpak.yml documents for
# fedora:43). A separate trailing layer on purpose: appending here keeps the fat SDK/NDK
# layers above cache-valid instead of invalidating the whole build.
RUN apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& node --version
-42
View File
@@ -1,42 +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-07-29) — re-keys and re-snapshots it.
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.
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
-8
View File
@@ -66,11 +66,3 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& sccache --version
-45
View File
@@ -1,45 +0,0 @@
#!/bin/bash
# Host-side C compiler wrapper for the aarch64 cross image (ci/rust-ci-arm64cross.Dockerfile).
#
# Why this exists: ffmpeg-sys-next's build script compiles a probe it intends to RUN — it
# executes the binary to read the libav* version macros — so it forces `.target(HOST)` with
# the comment "don't cross-compile this", but still hands that host compile the TARGET's
# pkg-config include paths. `-I/usr/include/aarch64-linux-gnu` then shadows the host's own
# multiarch libc headers and the x86 compiler dies inside bits/math-vector.h on NEON/SVE
# types it has never heard of.
#
# Prepending the host's multiarch dir does NOT fix it: GCC drops a `-I` that duplicates a
# directory already on its system include path (keeping it in the original, later position),
# so the arm64 dir stays in front. The reliable fix is to remove the target include dirs from
# the host compile entirely — the probe only wants FFmpeg's version macros, and the amd64
# libav*-dev headers are installed and on the default search path, at the same version (both
# come from this Ubuntu release).
#
# Scope: only ever invoked as CC for the HOST triple (CC_x86_64_unknown_linux_gnu). Target
# compiles go to aarch64-linux-gnu-gcc and never pass through here.
set -euo pipefail
declare -a out=()
while (($#)); do
case "$1" in
# `-I dir` as two arguments — the form cc's Command building and ffmpeg-sys both emit.
-I)
if [[ ${2-} == *aarch64-linux-gnu* ]]; then
shift 2
continue
fi
out+=("$1" "${2-}")
shift 2
;;
# `-Idir` glued into one argument.
-I*aarch64-linux-gnu*)
shift
;;
*)
out+=("$1")
shift
;;
esac
done
exec /usr/bin/cc "${out[@]}"
-81
View File
@@ -1,81 +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,
# Vulkan headers for pf-ffvk's bindgen over hwcontext_vulkan.h).
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 libvulkan-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
-93
View File
@@ -1,93 +0,0 @@
# LTS builder for the punktfunk HOST .deb — Ubuntu 24.04 (noble), the current Ubuntu LTS.
#
# WHY THIS EXISTS (see packaging/debian/README.md → "Ubuntu 24.04 LTS"):
# The default builder (ci/rust-ci.Dockerfile) is Ubuntu 26.04, so the host .deb it produces bakes
# in a glibc 2.41 floor and a hard `Depends: libavcodec62, …` (FFmpeg 8). Ubuntu 24.04 LTS ships
# glibc 2.39 and FFmpeg 6.1 (libavcodec60), so that .deb is uninstallable there — apt reports the
# deps as "too recent". Building the host on 24.04 instead lowers the glibc floor to 2.39 (the
# binary then runs on 24.04 → 26.04), and the ONE library 24.04 is too old for — FFmpeg — is built
# from source here and BUNDLED into the .deb (packaging/debian/build-deb.sh, BUNDLE_FFMPEG=1), so
# the package no longer depends on the distro's libav* at all. Everything else the host links
# (PipeWire, Wayland, xkbcommon, GL/EGL/GBM, Vulkan; opus is vendored via cmake) is soname-compatible
# on 24.04, so this ONE universal host .deb replaces the 26.04-built one for every Ubuntu user.
#
# libcuda is deliberately NOT provided: the host dlopen's libcuda.so.1 at runtime (pf-zerocopy /
# pf-encode) and never link-imports it, so — unlike the full-workspace rust-ci image, which builds
# tests that DO link a cuda stub — this host-only build needs no NVIDIA driver package. NVENC/EGL
# come from whatever driver the target runs, out of band.
#
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-rust-ci-noble); consumed by the
# `build-publish-host` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci, the first deb.yml
# run after this image is added uses the image from a PRIOR docker.yml push — seed it once manually
# (docker build -f ci/rust-ci-noble.Dockerfile -t … ci && docker push) before the host job can run.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
dpkg-dev patchelf \
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
# encode backend the host auto-selects; zlib (libavformat). NVENC needs only headers (below), dlopen'd.
nasm libva-dev libdrm-dev zlib1g-dev \
# host link deps present on 24.04 with sonames compatible up to 26.04
libpipewire-0.3-dev libwayland-dev libxkbcommon-dev \
libgl-dev libegl-dev libgbm-dev libvulkan-dev \
&& rm -rf /var/lib/apt/lists/*
# --- FFmpeg 8 from source -> /opt/ffmpeg (shared libs + .pc files) ----------------------------------
# libavcodec.so.62, matching the 26.04 line's soname so the host behaves identically. This is an
# LGPL build (no --enable-gpl / --enable-nonfree) so bundling the .so's into an MIT/Apache .deb stays
# license-clean — LGPL's relink clause is satisfied by dynamic linking, and the only encoders the host
# calls (h264/hevc/av1 _nvenc + _vaapi, plus scale_vaapi/hwmap filters; software H.264 fallback is the
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
# 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.
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
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
ARG NVHDR_TAG=n12.2.72.0
RUN set -eux; \
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
make -C /tmp/nvhdr install PREFIX=/usr/local; \
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
cd /tmp/ffmpeg; \
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
--prefix=/opt/ffmpeg \
--enable-shared --disable-static \
--disable-doc --disable-programs --disable-debug \
--enable-nvenc --enable-vaapi \
--extra-cflags=-I/usr/local/include --extra-ldflags=-L/usr/local/lib; \
make -j"$(nproc)"; make install; \
cd /; rm -rf /tmp/ffmpeg /tmp/nvhdr; \
# sanity: the soname we expect to bundle (libavcodec.so.62 on FFmpeg 8)
test -e /opt/ffmpeg/lib/libavcodec.so.62
# ffmpeg-sys-next discovers FFmpeg via pkg-config; point it at the bundled build. PKG_CONFIG_PATH is
# PREPENDED to pkg-config's default dirs (not a replacement — that's PKG_CONFIG_LIBDIR), so PipeWire /
# Wayland / libva / … still resolve from the system. FFMPEG_PREFIX is read by build-deb.sh's bundler.
ENV PKG_CONFIG_PATH=/opt/ffmpeg/lib/pkgconfig \
FFMPEG_PREFIX=/opt/ffmpeg
# Toolchain shared across CI users (jobs may run as different uids).
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 \
--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
-8
View File
@@ -50,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
@@ -27,10 +27,6 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Gamepad rumble feedback. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Steam Controller 2 over direct BLE (Sc2BleLink talks Valve's vendor GATT service to the
bonded pad). A RUNTIME permission (NEARBY_DEVICES group); the capture engages only when
already granted — USB capture (wired / Puck dongle) needs no Bluetooth at all. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
devices without a gamepad. -->
@@ -44,10 +40,6 @@
ethernet-only boxes declare no wifi (discovery/WifiLock are best-effort hedges there). -->
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<!-- Steam Controller 2 capture: USB host for the wired pad / Puck dongle, Bluetooth for the
direct-BLE pad — both optional (the feature quietly disengages without them). -->
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<!-- appCategory="game": a game-streaming client IS a game as far as the SoC is concerned.
On Snapdragon devices (and other OEMs with a Game Mode / Game Dashboard) this makes the app
@@ -73,16 +65,10 @@
android:name="android.game_mode_config"
android:resource="@xml/game_mode_config" />
<!-- configChanges includes `keyboard` (not just keyboardHidden): claiming a Steam
Controller 2's USB HID interface removes its lizard-mode keyboard/mouse input
devices, which flips CONFIG_KEYBOARD (QWERTY→NOKEYS) — without `keyboard` declared,
Android RECREATES the activity, disposing StreamScreen and killing the stream the
moment the capture engages (tester-diagnosed on-glass, 2026-07-15). Releasing the
interfaces at session end brings the devices back — same flip, same need. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden|screenLayout|density|navigation"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|density|navigation"
android:theme="@style/Theme.PunktfunkAndroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -90,21 +76,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,9 +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.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
@@ -43,23 +42,14 @@ 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.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) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
@@ -68,53 +58,21 @@ fun App(forceGamepadUi: Boolean = false) {
val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
// destroyed mid-stream doesn't leave a ghost that blocks every future link.
DisposableEffect(session) {
MainActivity.liveStream = session?.let { MainActivity.LiveStream(it.hostId) }
onDispose { MainActivity.liveStream = null }
}
// The same rule for the rare in-instance case (a caller that set FLAG_ACTIVITY_SINGLE_TOP, so
// the link reached `onNewIntent` on the streaming activity itself). Pointing at the host
// already being streamed is the one exception, and its right answer is to do nothing — the
// intent has already brought the app forward, which is exactly what "focus it" means here.
val pendingLink = activity?.pendingDeepLink
LaunchedEffect(pendingLink, session) {
val url = pendingLink ?: return@LaunchedEffect
val live = session ?: return@LaunchedEffect // not streaming: ConnectScreen routes it
activity.pendingDeepLink = null
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all())
val sameHost = target is HostResolution.Known && target.host.id == live.hostId
if (!sameHost) {
Toast.makeText(
context,
"Already streaming — end this session first.",
Toast.LENGTH_LONG,
).show()
}
}
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, onDisconnect = { 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 },
onConnected = { streamHandle = it },
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -145,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) },
@@ -215,9 +167,7 @@ private enum class GamepadScreen { Home, Settings, Library }
fun GamepadShell(
settings: Settings,
onSettingsChange: (Settings) -> Unit,
onConnected: (ActiveSession) -> Unit,
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
onConnected: (Long) -> Unit,
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
@@ -244,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
@@ -304,8 +303,7 @@ internal fun PairPinDialog(
if (fp.isNotEmpty()) {
onPaired(fp) // verified host fp — caller saves + connects
} else {
// Cause-specific: wrong PIN vs not-armed vs unreachable.
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
err = "Pairing failed — wrong PIN, or the host isn't armed."
}
}
}
@@ -354,18 +352,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 +371,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 +406,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 +418,6 @@ internal fun EditHostDialog(
address = address.trim(),
port = port.toIntOrNull() ?: target.port,
mac = KnownHostStore.parseMacs(mac),
clipboardSync = clipboard,
profileId = boundId,
pinnedProfileIds = pins,
),
)
},
@@ -467,103 +428,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."
}
@@ -1,69 +0,0 @@
package io.unom.punktfunk
import io.unom.punktfunk.kit.NativeBridge
/**
* Cause-specific user-facing messages for failed pair/connect attempts, keyed on the stable
* machine token from [NativeBridge.nativeTakeLastError]. One vocabulary for both the PIN
* ceremony and the request-access (delegated approval) path, so a dead network path is never
* reported as "wrong PIN" and an operator denial is never reported as a timeout — the exact
* collapse behind more than one support thread.
*/
object ConnectErrors {
/** Message for a failed SPAKE2 PIN ceremony ([NativeBridge.nativePair] returned `""`). */
fun pairMessage(token: String): String = when (token) {
"crypto" -> "Wrong PIN — check the PIN on the host's Pairing page and try again."
else -> shared(token) ?: transport(token)
}
/**
* Message for a failed connect / request-access ([NativeBridge.nativeConnect] returned `0`).
* [requestAccess] tunes the fallback wording for the delegated-approval path.
*/
fun connectMessage(token: String, requestAccess: Boolean): String =
shared(token) ?: when (token) {
"crypto" ->
"The host's identity doesn't match the saved fingerprint — re-pair with this host."
"timeout", "io", "" ->
if (requestAccess) {
"The request never reached the host, or nobody approved it in time — " +
"check the network path (no VPN, no guest-Wi-Fi isolation) and the " +
"host's console."
} else {
transport(token)
}
else -> "Connection failed — check host/port and logcat."
}
/** The host's typed rejection reasons — identical wording across every punktfunk client. */
private fun shared(token: String): String? = when (token) {
"not-armed" ->
"Pairing isn't armed on the host — arm it on the host's Pairing page, then try again."
"bound-other" ->
"The host's pairing window is armed for a different device — arm it for this one."
"rate-limited" -> "Too many pairing attempts — wait a couple of seconds and try again."
"identity-required" ->
"The host requires pairing — pair this device (PIN or request access) first."
"denied" -> "The host declined this device's request."
"approval-timeout" ->
"Nobody approved the request on the host in time — approve this device in the " +
"host's console or web UI, then request access again."
"superseded" ->
"A newer request from this device replaced this one — approve the latest request " +
"on the host."
"wire-version" -> "Client and host versions don't match — update both to the same release."
"busy" -> "The host is busy with another session."
else -> null
}
/** Transport-level causes (nothing typed arrived from the host). */
private fun transport(token: String): String = when (token) {
"timeout" ->
"The host didn't answer — check that this device and the host are on the same " +
"network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
"io" ->
"Couldn't reach the host — check that this device and the host are on the same " +
"network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
else -> "Pairing failed — the host didn't answer or closed the connection (see logcat)."
}
}
@@ -53,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
@@ -108,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).
@@ -119,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
@@ -132,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) }
@@ -214,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.
@@ -237,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
}
@@ -289,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
@@ -298,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
@@ -344,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()) {
@@ -358,27 +296,22 @@ 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()
val unreachable = token == "timeout" || token == "io" || token.isEmpty()
if (onFailure != null && unreachable) {
// Unreachable — hand off to the wake-and-wait flow — clearing `attempt` above
// and setting `waker.waking` here land in one recompose, so the overlay slides
if (onFailure != null) {
// Hand off to the wake-and-wait flow — clearing `attempt` above and setting
// `waker.waking` here land in one recompose, so the overlay slides
// Connecting → Waking without a blank frame.
onFailure()
} else {
// A typed host rejection (busy / versions differ / pairing required) means the
// host is awake — waking it would be nonsense; show the stated reason instead.
status = ConnectErrors.connectMessage(token, requestAccess = false)
status = "Connection failed — check host/port, PIN, and logcat"
}
}
}
@@ -402,22 +335,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
@@ -428,7 +351,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,
@@ -441,18 +364,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)
}
}
@@ -477,12 +397,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()) {
@@ -495,19 +410,13 @@ 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.
status = ConnectErrors.connectMessage(
NativeBridge.nativeTakeLastError(),
requestAccess = true,
)
status = "Request timed out — approve this device in the host's console, then retry."
discovery.start()
}
}
@@ -523,12 +432,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.
@@ -544,195 +447,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()
}
// 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)) })
}
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)
}
}
@@ -743,15 +469,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,
@@ -759,23 +481,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(
@@ -812,11 +517,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()) {
@@ -838,23 +539,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
@@ -919,45 +603,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) {
@@ -976,11 +639,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,
)
}
}
@@ -996,7 +654,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,
@@ -1075,15 +732,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 })
@@ -1108,9 +765,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,
@@ -1130,56 +785,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
},
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
}
@@ -1197,7 +823,6 @@ fun ConnectScreen(
EditHostDialog(
target = kh,
suggestedMacs = suggested,
profiles = profiles,
onSave = onSaveHost,
onDismiss = { editTarget = null },
)
@@ -1238,15 +863,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
@@ -1,6 +1,5 @@
package io.unom.punktfunk
import android.content.Context
import android.hardware.input.InputManager
import android.os.Build
import android.os.CombinedVibration
@@ -44,9 +43,7 @@ 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,46 +147,8 @@ 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) }
DisposableEffect(Unit) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
}
val filter = android.content.IntentFilter().apply {
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED)
}
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
val sc2Probe = remember { Sc2Capture(context) }
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(usbGeneration) {
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) {
if (pads.isEmpty()) {
Text(
"No controller detected. punktfunk can only forward devices Android " +
"classifies as a gamepad or joystick — a pad connected through an adapter " +
@@ -255,177 +214,6 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
}
}
/**
* The Steam Controller 2 card capture-side state, since a (claimed or lizard-mode) SC2 never
* appears as a gamepad InputDevice. Shows the transport, whether the capture is live (driving
* these menus now; streamed as-is in a session), and a grant button when USB access is missing.
*/
@Composable
private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivity?) {
val context = LocalContext.current
val settingOn = remember { SettingsStore(context).load().sc2Capture }
val active = activity?.sc2MenuActive == true
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
val permitted = usbDev != null && usbManager.hasPermission(usbDev)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
"Steam Controller 2",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
if (active) {
Text(
"navigating this UI",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
Text(
when {
usbDev == null -> "Paired via Bluetooth"
usbDev.productId == io.unom.punktfunk.kit.Sc2Device.PID_WIRED -> "Wired (USB)"
else -> "Puck dongle (USB)"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when {
!settingOn -> Text(
"Passthrough is disabled in Settings — enable \"Steam Controller 2 " +
"passthrough\" to capture it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
active -> Text(
"Captured — streams as-is: the host presents a real Steam Controller 2 " +
"that its Steam drives directly (trackpads, gyro, haptics).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
usbDev != null && !permitted -> {
Text(
"Needs USB access to be captured.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = { activity?.startSc2MenuNav(forceAsk = true) }) {
Text("Grant USB access")
}
}
else -> Text(
"Detected — capture engages automatically.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* 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,
)
}
}
}
}
/** One detected gamepad: identity, what it streams as, and a rumble test. */
@Composable
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
@@ -601,8 +389,6 @@ private fun prefLabel(pref: Int): String = when (pref) {
Gamepad.PREF_STEAMDECK -> "Steam Deck"
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
Gamepad.PREF_STEAMCONTROLLER2 -> "Steam Controller 2"
Gamepad.PREF_STEAMCONTROLLER2_PUCK -> "Steam Controller 2 Puck"
else -> "Automatic"
}
@@ -213,92 +213,19 @@ fun GamepadHostOptionsDialog(
onEdit: () -> Unit,
onForget: () -> 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("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("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."
},
)
}
}
/**
* 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.")
}
}
@@ -424,12 +351,7 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
}
pairing = false
if (fp.isNotEmpty()) {
onPaired(fp)
} else {
// Cause-specific: wrong PIN vs not-armed vs unreachable.
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
}
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
}
}
@@ -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
}
/**
@@ -49,14 +49,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.deviceBodyVibrator
// 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
@@ -84,12 +82,7 @@ fun GamepadSettingsScreen(
var s by remember { mutableStateOf(initial) }
fun update(next: Settings) { s = next; onChange(next) }
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 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 }
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
val rows = buildSettingsRows(s, ::update)
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
@@ -141,10 +134,7 @@ fun GamepadSettingsScreen(
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, adjustDir = adjustDir, onClick = {
@@ -267,15 +257,8 @@ 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 (absent on TVs); [av1Capable] gates the
* AV1 codec entry (see `codecOptionsFor`). */
private fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
av1Capable: Boolean,
update: (Settings) -> Unit,
): List<GpRow> {
/** Build the console settings rows from the current [Settings], writing through [update]. */
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
fun <T> choice(
id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
@@ -311,36 +294,9 @@ private fun buildSettingsRows(
toggled = value,
)
// Grouped and ordered by the cross-client category map (General / Display / Audio /
// Controllers), with the same sub-section names the touch settings and the desktop clients use,
// so a setting sits in the same place whichever surface 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(
"hud", "General · Statistics", "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", "General · Session", "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", "General · Library", "Game library",
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"gamepadUI", "General · Interface", "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
choice(
"resolution", "Display · Resolution", "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
@@ -357,36 +313,33 @@ private fun buildSettingsRows(
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
REFRESH_OPTIONS, s.hz,
) { update(s.copy(hz = it)) },
choice(
"bitrate", "Display · Quality", "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(
"codec", null, "Video codec",
"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", "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", 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", "Display · 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(
"compositor", "Display · Host output", "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(
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
@@ -397,29 +350,31 @@ private fun buildSettingsRows(
) { update(s.copy(micEnabled = it)) },
choice(
"padType", "Controllers", "Controller type",
"padType", "Controller", "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
) { update(s.copy(gamepad = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
"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,
) { update(s.copy(rumbleOnPhone = 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.
choice(
"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(
"sc2", null, "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,
) { update(s.copy(sc2Capture = it)) },
"library", null, "Game library",
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"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)) },
)
}
@@ -10,7 +10,6 @@ import android.os.Looper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@@ -47,10 +46,6 @@ fun isTvDevice(context: Context): Boolean {
@Composable
fun rememberControllerConnected(): State<Boolean> {
val context = LocalContext.current
// A menu-captured Steam Controller 2 counts as connected: it drives the console UI through
// the capture link, but never surfaces as an Android InputDevice (lizard mode is kb/mouse,
// and the claim removes even those) — the InputManager path below can't see it.
val activity = context as? MainActivity
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
DisposableEffect(Unit) {
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
@@ -64,7 +59,5 @@ fun rememberControllerConnected(): State<Boolean> {
connected.value = Gamepad.firstPad() != null
onDispose { im.unregisterInputDeviceListener(listener) }
}
return remember {
derivedStateOf { connected.value || activity?.sc2MenuActive == true }
}
return connected
}
@@ -1,7 +1,6 @@
package io.unom.punktfunk
import android.content.Context
import android.os.Build
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders
@@ -31,13 +30,7 @@ suspend fun connectToHost(
): Long {
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
val (baseW, baseH, hz) = settings.effectiveMode(context)
// Render scale: ask the host for `chosen mode × scale` (even + codec-clamped) — > 1 supersamples
// (the compositor downscales the larger decoded frame to the SurfaceView), < 1 renders under
// native. 1.0 leaves the resolved mode untouched.
val (w, h) = RenderScale.apply(
baseW, baseH, settings.renderScale, RenderScale.maxDimension(settings.codec)
)
val (w, h, hz) = settings.effectiveMode(context)
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
@@ -54,9 +47,6 @@ suspend fun connectToHost(
// 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",
)
}
}
@@ -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,7 +85,7 @@ private sealed class LibState {
fun LibraryScreen(
host: KnownHost,
settings: Settings,
onLaunched: (ActiveSession) -> Unit,
onLaunched: (Long) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true,
) {
@@ -143,11 +142,7 @@ fun LibraryScreen(
host.address, host.port, host.fpHex, launch = game.id,
)
launching = false
if (handle != 0L) {
onLaunched(
ActiveSession(handle, settings, host.clipboardSync),
)
}
if (handle != 0L) onLaunched(handle)
else Toast.makeText(
context,
"Launch failed — check the host and try again.",
@@ -1,81 +1,24 @@
package io.unom.punktfunk
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.os.Build
import android.os.Bundle
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() {
/**
@@ -100,21 +43,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,72 +70,11 @@ 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
/**
* Menu-time Steam Controller 2 capture (UI mode no router): a captured SC2 never produces
* ordinary gamepad events (lizard mode is kb/mouse; the claim removes even those), so this
* drives the console UI directly from the parsed reports via [sc2NavKey]. Runs while the app
* is foreground and NOT streaming; StreamScreen pauses it around its own stream-mode capture.
* [sc2MenuActive] is observed by the console-UI gate ([rememberControllerConnected]) and the
* Controllers screen.
*/
private var sc2Menu: io.unom.punktfunk.kit.Sc2Capture? = null
var sc2MenuActive by mutableStateOf(false)
private set
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
* ViewRootImpl's focus-navigation stage the one that grants initial focus for a real
* pad's first D-pad press so on a phone in touch mode it lands on a focus-less window
* and does nothing (first on-glass run: only B worked, since it bypasses key events
* entirely). `FocusManager.moveFocus` is the public API for exactly this.
*/
private var sc2MoveFocus: ((androidx.compose.ui.focus.FocusDirection) -> Boolean)? = null
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()
@@ -223,215 +90,13 @@ class MainActivity : ComponentActivity() {
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
// SC2 hot-plug + the menu-time USB-permission grant both (re)start the menu capture.
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
when (intent?.action) {
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)) {
startSc2MenuNav()
}
}
}
}
}
sc2Receiver = receiver
val filter = IntentFilter().apply {
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
addAction(SC2_MENU_PERMISSION)
}
if (Build.VERSION.SDK_INT >= 33) {
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(receiver, filter)
}
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.
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
androidx.compose.runtime.DisposableEffect(Unit) {
sc2MoveFocus = { dir ->
focusManager.moveFocus(dir) ||
focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
}
onDispose { sc2MoveFocus = null }
}
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
}
}
}
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() {
// Release the claim while backgrounded so the OS (and other apps) get the pad back.
stopSc2MenuNav()
super.onPause()
}
override fun onDestroy() {
sc2Receiver?.let { runCatching { unregisterReceiver(it) } }
sc2Receiver = null
stopSc2MenuNav()
super.onDestroy()
}
/**
* Engage the menu-time SC2 capture if possible: setting on, not streaming, and a wired/Puck
* pad attached (asking for USB permission at most once per attach [forceAsk] re-arms the
* dialog, for the Controllers screen's explicit grant button) else an already-paired BLE
* controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly.
*/
fun startSc2MenuNav(forceAsk: Boolean = false) {
if (forceAsk) sc2PermissionAsked = false
if (streamHandle != 0L) return // StreamScreen owns the pad while streaming
if (sc2Menu?.isActive == true) return
if (!SettingsStore(this).load().sc2Capture) return
val cap = sc2Menu ?: io.unom.punktfunk.kit.Sc2Capture(this).also { c ->
c.onUiKey = { key, down -> runOnUiThread { sc2NavKey(key, down) } }
c.onActiveChanged = { on -> runOnUiThread { sc2MenuActive = on } }
sc2Menu = c
}
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val dev = cap.findUsbDevice()
when {
dev != null && usbManager.hasPermission(dev) -> cap.startUsb(dev)
dev != null && !sc2PermissionAsked -> {
sc2PermissionAsked = true
usbManager.requestPermission(
dev,
PendingIntent.getBroadcast(
this, 1,
Intent(SC2_MENU_PERMISSION).setPackage(packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED -> {
cap.pairedBleAddress()?.let { cap.startBle(it) }
}
}
}
/** Release the menu-time SC2 capture (backgrounded / stream taking over). Idempotent. */
fun stopSc2MenuNav() {
sc2Menu?.stop()
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,
* A activates the focused element, everything else (D-pad, shoulders, Start/Select) goes to
* the framework's focus navigation. Also claims the console-UI glyphs for the pad.
*/
private fun sc2NavKey(keyCode: Int, down: Boolean) {
if (streamHandle != 0L) return // raced a stream start — the wire path owns input now
lastPadIsGamepad = true
lastPadStyle = Gamepad.PadStyle.XBOX // Valve pads carry A/B/X/Y in Xbox positions
val action = if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP
// The console UI navigates through padKeyProbe (GamepadNavEffect's held-state + repeat
// machinery — A/X/Y/D-pad/Select), NOT the focus system: synthesized events must be
// offered there first, exactly like real ones in dispatchKeyEvent (tester-diagnosed:
// routing everything via super.dispatchKeyEvent bypassed the probe, so only B — which
// never rides key events — did anything). The probes gate on keycode only, so a
// synthetic KeyEvent satisfies them.
padKeyProbe?.let { if (it(KeyEvent(action, keyCode))) return }
when (keyCode) {
// B → back, on release (same edge the real-pad path uses).
KeyEvent.KEYCODE_BUTTON_B -> if (!down) onBackPressedDispatcher.onBackPressed()
// A → activate the focused element (the focus system understands DPAD_CENTER; the
// Compose node focused via the moveFocus hook receives it once the ComposeView
// holds view-focus).
KeyEvent.KEYCODE_BUTTON_A ->
super.dispatchKeyEvent(KeyEvent(action, KeyEvent.KEYCODE_DPAD_CENTER))
// D-pad → Compose's own focus API (a synthetic DPAD KeyEvent can't grant initial
// focus — see [sc2MoveFocus]); one move per press edge.
KeyEvent.KEYCODE_DPAD_UP -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Up)
KeyEvent.KEYCODE_DPAD_DOWN -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Down)
KeyEvent.KEYCODE_DPAD_LEFT -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Left)
KeyEvent.KEYCODE_DPAD_RIGHT -> if (down) moveSc2Focus(androidx.compose.ui.focus.FocusDirection.Right)
else -> super.dispatchKeyEvent(KeyEvent(action, keyCode))
}
}
private fun moveSc2Focus(dir: androidx.compose.ui.focus.FocusDirection) {
val hook = sc2MoveFocus
if (hook == null || !hook(dir)) {
// No composition hook (shouldn't happen) — fall back to the raw key dispatch.
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dirToKey(dir)))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dirToKey(dir)))
}
}
private fun dirToKey(dir: androidx.compose.ui.focus.FocusDirection): Int = when (dir) {
androidx.compose.ui.focus.FocusDirection.Up -> KeyEvent.KEYCODE_DPAD_UP
androidx.compose.ui.focus.FocusDirection.Down -> KeyEvent.KEYCODE_DPAD_DOWN
androidx.compose.ui.focus.FocusDirection.Left -> KeyEvent.KEYCODE_DPAD_LEFT
else -> KeyEvent.KEYCODE_DPAD_RIGHT
}
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
private fun resolveHighRefreshMode() {
@Suppress("DEPRECATION")
@@ -444,8 +109,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 +119,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) {
@@ -522,54 +129,16 @@ class MainActivity : ComponentActivity() {
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
// inside the router: holding it briefly (~1 s, with an on-screen hint) fires
// router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back
// can still leave — but an accidental brush of the four buttons no longer quits.
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an
// accidental brush of the four buttons no longer quits instantly.
gamepadRouter?.onButton(event, bit)
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,
@@ -584,18 +153,7 @@ class MainActivity : ComponentActivity() {
// physical-keyboard layout), keycode fallback — see Keymap docs.
val vk = Keymap.toVk(event)
if (vk != 0) {
// Soft-keyboard events (the IME's virtual device — the stream's
// KeyCaptureView path) carry Shift only as META state, where a real
// keyboard sends discrete Shift transitions — so mirror the meta bit as
// a VK_LSHIFT wrap or every IME capital/symbol lands unshifted on the
// host. Never applied to hardware events: their Shift already went over
// the wire, and a synthetic release here would un-hold a physical Shift
// the user is still pressing.
val imeShift = event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD &&
event.isShiftPressed && vk != 0xA0 && vk != 0xA1
if (down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, true, 0)
NativeBridge.nativeSendKey(handle, vk, down, 0)
if (!down && imeShift) NativeBridge.nativeSendKey(handle, 0xA0, false, 0)
return true // consumed — don't let the system also act on it
}
}
@@ -631,44 +189,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 +232,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 +239,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,407 +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 touchMode: TouchMode? = null,
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
* else, but here it is the one knob a marginal link wants turned off per host.
*/
val lowLatencyMode: Boolean? = null,
/** The timeline presenter's intent pair — cross-client keys, see [Settings.presentPriority]. */
val presentPriority: String? = null,
val smoothBuffer: Int? = null,
/**
* Overlay keys a newer build wrote and this one doesn't model carried through a loadsave
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
* must not erase what a newer one stored.
*/
val extra: Map<String, Any> = emptyMap(),
) {
/** The one resolution seam: this overlay on top of [base]. Pure, so it is fully testable. */
fun apply(base: Settings): Settings = base.copy(
width = width ?: base.width,
height = height ?: base.height,
hz = hz ?: base.hz,
bitrateKbps = bitrateKbps ?: base.bitrateKbps,
renderScale = renderScale ?: base.renderScale,
codec = codec ?: base.codec,
hdrEnabled = hdrEnabled ?: base.hdrEnabled,
compositor = compositor ?: base.compositor,
audioChannels = audioChannels ?: base.audioChannels,
micEnabled = micEnabled ?: base.micEnabled,
touchMode = touchMode ?: base.touchMode,
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
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,
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,
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)
"touch_mode" -> copy(touchMode = null)
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = 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 (touchMode != null) add("touch_mode")
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
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) }
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) }
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", "touch_mode",
"mouse_mode", "invert_scroll", "gamepad", "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"),
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"),
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,8 +1,6 @@
package io.unom.punktfunk
import android.content.Context
import android.os.Build
import android.util.Log
import android.view.Display
/**
@@ -16,14 +14,6 @@ data class Settings(
val height: Int = 0,
val hz: Int = 0,
val bitrateKbps: Int = 0,
/**
* Render-resolution multiplier: the client asks the host to render/encode at `chosen mode ×
* renderScale` and the compositor downscales the larger decoded frame to the SurfaceView
* (`> 1` supersamples for sharpness, at more bandwidth AND decode; `< 1` renders under native
* for a lighter host/link). `1.0` = Native. Applied at connect via [RenderScale.apply], clamped
* even + to the codec's max dimension. Mirrors the Apple/Linux clients' render scale.
*/
val renderScale: Double = 1.0,
/**
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
* can actually present HDR10 (see [displaySupportsHdr]) on an SDR display HDR is never
@@ -83,19 +73,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
@@ -105,72 +82,11 @@ data class Settings(
* otherwise misfire and wait out its timeout despite the host already being reachable.
*/
val autoWakeEnabled: Boolean = true,
/**
* Opt-in: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
* phone's own vibration motor for clip-on gamepads that ship without rumble motors, where
* the phone body is the only actuator in the player's hands. Off by default; read once per
* session by StreamScreen (it hands GamepadFeedback the device vibrator only when set). The
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
*/
val rumbleOnPhone: Boolean = false,
/**
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
* and pass it through AS-IS: the host presents a real `28DE:1302` that its Steam drives
* directly (Linux hosts). ON by default it engages only when such a controller is actually
* present at stream start, so it costs nothing otherwise; the toggle exists for the rare
* 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,
/**
* 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:
@@ -202,7 +118,6 @@ class SettingsStore(context: Context) {
height = prefs.getInt(K_H, 0),
hz = prefs.getInt(K_HZ, 0),
bitrateKbps = prefs.getInt(K_BITRATE, 0),
renderScale = prefs.getFloat(K_RENDER_SCALE, 1.0f).toDouble(),
hdrEnabled = prefs.getBoolean(K_HDR, true),
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
@@ -226,19 +141,7 @@ class SettingsStore(context: Context) {
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
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),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
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) {
@@ -247,7 +150,6 @@ class SettingsStore(context: Context) {
.putInt(K_H, s.height)
.putInt(K_HZ, s.hz)
.putInt(K_BITRATE, s.bitrateKbps)
.putFloat(K_RENDER_SCALE, s.renderScale.toFloat())
.putBoolean(K_HDR, s.hdrEnabled)
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
@@ -259,14 +161,7 @@ class SettingsStore(context: Context) {
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.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_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
}
@@ -275,7 +170,6 @@ class SettingsStore(context: Context) {
const val K_H = "height"
const val K_HZ = "hz"
const val K_BITRATE = "bitrate_kbps"
const val K_RENDER_SCALE = "render_scale"
const val K_HDR = "hdr_enabled"
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
@@ -302,17 +196,7 @@ 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_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
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"
@@ -342,25 +226,11 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
*/
fun displaySupportsHdr(context: Context): Boolean {
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
// (Pixel-class panels included), which would make a genuinely HDR display advertise
// no-HDR and pin the whole session to 8-bit SDR.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
display.mode.supportedHdrTypes.forEach { add(it) }
}
// Union the legacy query defensively — the supported one on minSdk 31, and some vendors
// populate only this on newer APIs.
@Suppress("DEPRECATION")
display.hdrCapabilities?.supportedHdrTypes?.forEach { add(it) }
}
// HDR10/HDR10+ only: the stream is BT.2020 PQ — a Dolby-Vision/HLG-only panel can't present it.
val supported = types.any {
@Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31
val caps = display.hdrCapabilities ?: return false
return caps.supportedHdrTypes.any {
it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS
}
Log.i("punktfunk", "display HDR types=$types → advertise HDR10=$supported")
return supported
}
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
@@ -372,54 +242,6 @@ fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
return Triple(w, h, hz)
}
/**
* Client-side render-scale geometry the Kotlin twin of `punktfunk-core`'s `render_scale` module
* (and the Apple client's `RenderScale`). Multiply a base size, preserve aspect, even-floor (the
* host rejects odd sizes), and clamp uniformly to the codec's per-axis ceiling so a connect can't
* ask for a size the encoder rejects. `1.0` = Native. Pure + covered by [RenderScaleTest].
*/
object RenderScale {
val PRESETS = listOf(0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0)
/** H.264 tops out at 4096 px/axis; HEVC/AV1/auto at 8192 — the host's `codec.rs` walls. */
fun maxDimension(codec: String): Int = if (codec == "h264") 4096 else 8192
/** Clamp a raw multiplier into [0.5, 4.0]; a missing / non-positive / NaN value → 1.0. */
fun sanitize(raw: Double): Double = if (raw > 0.0) raw.coerceIn(0.5, 4.0) else 1.0
/** "Native (1×)" / "1.5×" / "2× · supersample" — the picker label. */
fun label(scale: Double): String = when {
scale == 1.0 -> "Native (1×)"
scale > 1.0 -> "${trim(scale)}× · supersample"
else -> "${trim(scale)}×"
}
private fun trim(s: Double): String =
if (s == s.toLong().toDouble()) s.toLong().toString() else s.toString()
/** Apply [scale] to a base size → a host-valid even, aspect-preserved, codec-clamped (w, h). */
fun apply(baseW: Int, baseH: Int, scale: Double, maxDim: Int): Pair<Int, Int> {
val s = sanitize(scale)
var w = maxOf(baseW, 1) * s
var h = maxOf(baseH, 1) * s
val cap = maxDim.toDouble()
val over = maxOf(w / cap, h / cap)
if (over > 1.0) {
w /= over
h /= over
}
return Pair(evenFloor(w, 320), evenFloor(h, 200))
}
private fun evenFloor(value: Double, minimum: Int): Int {
val v = maxOf(kotlin.math.floor(value).toInt(), minimum).coerceAtLeast(0)
return v / 2 * 2
}
}
/** (scale, label) for the render-scale picker. `1.0` = Native. */
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. */
@@ -456,47 +278,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
}
}
/** 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
}
@@ -525,29 +321,6 @@ val COMPOSITOR_OPTIONS = listOf(
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
/** [Settings.presentPriority] as the wire int `nativeStartVideo` takes (0 = latency, 1 = smooth).
* Unrecognized values resolve to latency same rule as the Apple client. */
fun Settings.presentPriorityWire(): Int = if (presentPriority == "smooth") 1 else 0
/** (stored value, label) for the presenter-intent picker — the Apple client's table verbatim. */
val PRESENT_PRIORITY_OPTIONS = listOf(
"latency" to "Lowest latency",
"smooth" to "Smoothness",
)
/** (frames, label) for the smoothness-buffer picker; each buffered frame one refresh interval
* of jitter absorbed for one interval of added display latency ([hz] labels the cost). */
fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
val periodMs = 1000.0 / maxOf(24, hz)
fun cost(frames: Int) = "+%.0f ms".format(periodMs * frames)
return listOf(
0 to "Automatic",
1 to "1 frame (${cost(1)})",
2 to "2 frames (${cost(2)})",
3 to "3 frames (${cost(3)})",
)
}
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -555,20 +328,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",
"Automatic",
"Xbox 360",
"DualSense",
"Xbox One",
"DualShock 4",
)
File diff suppressed because it is too large Load Diff
@@ -1,187 +0,0 @@
package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* The network speed test: measure the path to a host **over the real data plane** connect, ask
* the host to burst filler for two seconds, report goodput and loss, and offer to apply a
* recommended bitrate in one tap.
*
* The measurement is the easy half. The half that was wrong everywhere for a long time is *where
* the answer goes*: a measured bitrate belongs in the layer the tested host actually resolves
* bitrate from (design/client-settings-profiles.md §5.3). Writing it to the global the
* long-standing behaviour meant measuring the slow retro box downstairs quietly re-tuned the
* desktop too. [SpeedTestTarget] is that decision, and because it depends only on the host it is
* known *before* the result lands, so the button can say where it will write.
*/
sealed interface SpeedTestTarget {
/** No profile in play — the global default, i.e. what has always happened. */
data object Global : SpeedTestTarget
/** The profile this host uses already overrides bitrate, so that override is what it reads. */
data class Profile(val profile: StreamProfile) : SpeedTestTarget
/**
* The host uses a profile, but that profile inherits bitrate. Writing either layer is
* defensible, so the user gets both buttons rather than us guessing which they meant.
*/
data class Ask(val profile: StreamProfile) : SpeedTestTarget
companion object {
/**
* Resolved exactly the way a connect resolves it (see [ProfileStore.resolveFor]): the
* one-off pick this test was started from a pinned card carries one else the host's
* binding. A dangling binding resolves as no profile here too.
*/
fun resolve(
host: KnownHost?,
oneOffProfile: String?,
profiles: ProfileStore,
): SpeedTestTarget {
val profile = profiles.resolveFor(host, oneOffProfile) ?: return Global
return if (profile.overrides.bitrateKbps != null) Profile(profile) else Ask(profile)
}
}
}
/** Where the speed test is: it connects, it measures, then it has an answer or a reason. */
sealed interface SpeedTestPhase {
data object Connecting : SpeedTestPhase
data object Measuring : SpeedTestPhase
data class Failed(val message: String) : SpeedTestPhase
/**
* [recommendedKbps] is 70 % of the measured throughput headroom for the FEC overhead and for
* the loss a real stream will meet, the same margin the desktop clients apply.
*/
data class Done(
val throughputKbps: Int,
val lossPct: Double,
val recommendedKbps: Int,
) : SpeedTestPhase {
val measuredMbps: Double get() = throughputKbps / 1000.0
val recommendedMbps: Double get() = recommendedKbps / 1000.0
}
}
/**
* Connect to [host]:[port], run one burst, and report. Blocking-ish (it suspends on IO) call
* from a coroutine; [onPhase] is invoked as it progresses so the dialog can narrate.
*
* The connect is deliberately minimal: 1280×720@60, no launch, host-default bitrate. Nothing here
* presents a frame, and asking a host to spin up a 4K encode for a three-second measurement would
* be rude to it and slower for us.
*/
suspend fun runSpeedTest(
context: Context,
identity: ClientIdentity,
host: String,
port: Int,
pinHex: String,
onPhase: (SpeedTestPhase) -> Unit,
) {
onPhase(SpeedTestPhase.Connecting)
val probeSettings = Settings(
width = 1280,
height = 720,
hz = 60,
bitrateKbps = 0, // the host's default: this measures the link, not an encoder setting
hdrEnabled = false,
audioChannels = 2,
)
val handle = connectToHost(
context, probeSettings, identity, host, port, pinHex,
launch = null, timeoutMs = SPEED_TEST_CONNECT_TIMEOUT_MS,
)
if (handle == 0L) {
onPhase(
SpeedTestPhase.Failed(
ConnectErrors.connectMessage(NativeBridge.nativeTakeLastError(), requestAccess = false),
),
)
return
}
try {
onPhase(SpeedTestPhase.Measuring)
if (!NativeBridge.nativeSpeedTest(handle, TARGET_KBPS, BURST_MS)) {
onPhase(SpeedTestPhase.Failed("The host wouldn't start a measurement."))
return
}
var waited = 0
while (waited < POLL_BUDGET_MS) {
delay(POLL_INTERVAL_MS.toLong())
waited += POLL_INTERVAL_MS
val r = NativeBridge.nativeProbeResult(handle)
if (r == null || r.size < 3) {
onPhase(SpeedTestPhase.Failed("The session ended before the measurement finished."))
return
}
if (r[0] == 0.0) continue
// Let the last UDP shards land before tearing the session down, or the tail of the
// burst is counted as loss that never happened.
delay(SETTLE_MS)
val settled = NativeBridge.nativeProbeResult(handle) ?: r
val kbps = settled[1].toInt()
onPhase(
SpeedTestPhase.Done(
throughputKbps = kbps,
lossPct = settled[2],
// Integer arithmetic in this order (not `* 0.7`) so the recommendation matches
// the desktop clients' to the kilobit.
recommendedKbps = kbps / 10 * 7,
),
)
return
}
onPhase(SpeedTestPhase.Failed("The measurement timed out."))
} finally {
withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
}
}
/**
* Write a measured bitrate into the layer [target] names. [toProfile] picks the side of a
* [SpeedTestTarget.Ask]; it is ignored for the other targets, which have only one answer. Returns
* a human phrase naming where it went, for the confirmation.
*/
fun applySpeedTestResult(
kbps: Int,
target: SpeedTestTarget,
toProfile: Boolean,
profiles: ProfileStore,
settings: Settings,
onGlobalChange: (Settings) -> Unit,
): String {
val profile = when (target) {
is SpeedTestTarget.Profile -> target.profile
is SpeedTestTarget.Ask -> target.profile.takeIf { toProfile }
SpeedTestTarget.Global -> null
}
return if (profile == null) {
onGlobalChange(settings.copy(bitrateKbps = kbps))
"the default bitrate"
} else {
// Only the bitrate moves — a speed test has nothing to say about the rest of the profile.
// Re-read rather than trusting the copy this dialog was opened with, so a rename or another
// edit in between isn't clobbered.
val live = profiles.byId(profile.id) ?: profile
profiles.save(live.copy(overrides = live.overrides.copy(bitrateKbps = kbps)))
"${live.name}"
}
}
/** Ask for far more than any real link can carry, so the link is what limits the answer. */
private const val TARGET_KBPS = 3_000_000
/** Long enough to fill the pipe and settle, short enough not to interrupt anyone for long. */
private const val BURST_MS = 2_000
private const val POLL_INTERVAL_MS = 250
private const val POLL_BUDGET_MS = 10_000
private const val SETTLE_MS = 400L
private const val SPEED_TEST_CONNECT_TIMEOUT_MS = 15_000
@@ -39,27 +39,12 @@ internal fun StatsOverlay(
s: DoubleArray,
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()
@@ -70,22 +55,18 @@ 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))
}
if (detailed) {
videoFeedLine(s, codecLabel)?.let { statLine(it, Color.White) }
videoFeedLine(s)?.let { statLine(it, Color.White) }
}
if (latValid) {
// Display stage (s[22]s[25], from OnFrameRendered): when a render timestamp landed
@@ -112,27 +93,8 @@ 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 {
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 {
""
}
statLine(
"= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm$presents",
Color.White,
)
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
}
}
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
@@ -189,15 +151,14 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? {
}
/**
* Format the negotiated video-feed descriptor from [codecLabel] plus the trailing four stats
* doubles `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
* `AV1 · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
* Format the negotiated video-feed descriptor from the trailing four stats doubles
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4.
* [codecLabel] is the host-resolved codec (`nativeVideoCodecLabel`); a blank one falls back to
* `HEVC` (the pre-negotiation default) for the brief window before it's resolved.
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
* Android decoder is always HEVC (`video/hevc`).
*/
private fun videoFeedLine(s: DoubleArray, codecLabel: String): String? {
private fun videoFeedLine(s: DoubleArray): String? {
if (s.size < 14) return null
val bitDepth = s[10].toInt()
val primaries = s[11].toInt()
@@ -214,6 +175,5 @@ private fun videoFeedLine(s: DoubleArray, codecLabel: String): String? {
2 -> "4:2:2"
else -> "4:2:0"
}
val codec = codecLabel.ifEmpty { "HEVC" }
return "$codec · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
}
@@ -1,37 +1,20 @@
package io.unom.punktfunk
import android.Manifest
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.net.wifi.WifiManager
import android.os.Build
import android.text.InputType
import android.util.Log
import android.view.KeyEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -41,52 +24,26 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.kit.DsCapture
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
/**
* The immersive stream. Everything it reads about the session comes from [session] the settings
* the connect actually resolved (globals, or a profile's overrides on top of them) and the HOST's
* clipboard decision rather than from a fresh `SettingsStore` load, which could disagree with
* the connect that produced this handle.
*/
@Composable
fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val handle = session.handle
val initialSettings = session.settings
val micEnabled = initialSettings.micEnabled
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val context = LocalContext.current
val activity = context as? MainActivity
// The View hosting this composition — the one that receives the stream's touch/pointer events
// (the gesture Box below is a Compose node inside it), so it is where unbuffered dispatch is
// requested.
val composeView = androidx.compose.ui.platform.LocalView.current
val window = activity?.window
// The negotiated stream refresh, known from the handshake (0 = unknown / older native lib) —
// drives the panel mode pin, the render-rate vote, and the presenter's latch grid.
val streamHz = remember(handle) { NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0 }
val controller = remember(window) {
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
}
@@ -105,12 +62,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
// blanks the numbers for a poll interval.
val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var codecLabel by remember { mutableStateOf("") }
// The panel's LIVE refresh rate, re-read each poll — the HUD flags a session whose panel sits
// below the stream rate (an OEM governor that ignored both the mode pin and the surface hint).
var panelHz by remember { mutableStateOf(0f) }
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
val statsOn = statsVerbosity != StatsVerbosity.OFF
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
@@ -126,13 +80,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
LaunchedEffect(handle, statsOn) {
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
if (statsOn) {
// Codec is resolved at the handshake (Welcome) — fixed for the session, so read its
// label once up front (before the first snapshot renders the video-feed line).
if (codecLabel.isEmpty()) codecLabel = NativeBridge.nativeVideoCodecLabel(handle)
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
panelHz = runCatching { context.display }.getOrNull()?.refreshRate ?: 0f
// The decoder is fixed for the session; fetch its label once it's resolved.
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
}
@@ -199,23 +149,6 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
}.onEach { it.setReferenceCounted(false) }
}
// True while the gamepad exit chord (Select+Start+L1+R1) is held and counting down — drives the
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
var exitArming by remember { mutableStateOf(false) }
// True while the TV remote is acting as a pointer (hold SELECT toggles) — drives the mode hint.
var remotePointerOn by remember { mutableStateOf(false) }
// Focus anchor the soft keyboard is summoned onto AND the pointer-capture grab target (a grab
// needs a focusable view; captured-pointer events land on it). Declared before the effect
// below so the capture callbacks can reach the view once it exists.
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
// The video SurfaceView, hoisted for the same reason: the pointer paths built below map WINDOW
// coordinates onto the picture, and with a letterboxed stream that rect is the video's, not the
// panel's. Set when the view is created.
var videoView by remember { mutableStateOf<SurfaceView?>(null) }
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
wifiLocks.forEach { lock ->
@@ -233,45 +166,13 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.systemBars())
}
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
// Scoped to the stream; the app's other screens keep the default for their text fields.
val priorSoftInput = window?.attributes?.softInputMode
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
// Draw under the display cutout, explicitly. Android 15's SDK-35 edge-to-edge enforcement
// makes ALWAYS the immersive default, but pre-15 devices letterbox the notch as a dead
// black bar unless asked — and the stream's own letterbox is black anyway, so the cutout
// region can never show anything wrong. Captured + restored like the rest of the window
// state so the menus keep their platform-default behaviour.
val priorCutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.attributes?.layoutInDisplayCutoutMode
} else {
null
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.let { w ->
w.attributes = w.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
}
}
}
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
// activity declares configChanges=orientation, so this re-lays out the surface in place without
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
// The prior request is captured and restored on the way out.
//
// COMPACT devices only (sw < 600 dp): on tablets/foldables/desktop windows the lock is a
// large-display anti-pattern (Play flags it; Android 16+ ignores it there outright), and the
// stream doesn't need it — the aspect-ratio letterbox renders correctly in any orientation,
// the lock is purely a phone-ergonomics choice.
val compactDevice = context.resources.configuration.smallestScreenWidthDp < 600
val priorOrientation = activity?.requestedOrientation
if (compactDevice) {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
activity?.streamHandle = handle // route hardware keys to this session
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
@@ -284,231 +185,22 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// the same way the Back gesture does.
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
router.onExitChord = { activity?.requestStreamExit?.invoke() }
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
router.onExitArmed = { armed -> exitArming = armed }
// Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured
// (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look.
// The local cursor is hidden over the stream — the host's own cursor, composited into
// the video, is the one the user sees (twin of the desktop clients' hidden cursor).
val decor = window?.decorView
val priorPointerIcon = decor?.pointerIcon
decor?.pointerIcon = android.view.PointerIcon.getSystemIcon(
context,
android.view.PointerIcon.TYPE_NULL,
)
val mouse = MouseForwarder(
handle,
invertScroll = initialSettings.invertScroll,
captureWanted = initialSettings.mouseMode == MouseMode.CAPTURE,
// The picture's rect in window coordinates (see MouseForwarder.videoRect) — read live,
// so it is right from the frame the SurfaceView is first laid out.
videoRect = {
videoView?.takeIf { it.width > 0 && it.height > 0 }?.let { v ->
val loc = IntArray(2)
v.getLocationInWindow(loc)
android.graphics.Rect(loc[0], loc[1], loc[0] + v.width, loc[1] + v.height)
}
},
)
mouse.onRequestCapture = {
// The grab needs the (focusable) capture view: focus it, then ask. Posted so a
// request racing view attach/focus settles on the next frame.
keyCapture?.let { v ->
v.post {
v.requestFocus()
v.requestPointerCapture()
}
}
}
mouse.onReleaseCapture = { keyCapture?.releasePointerCapture() }
activity?.mouseForwarder = mouse
// TV remote-as-pointer: hold SELECT ≈ 0.8 s to toggle; the D-pad then glides the host
// cursor (see RemotePointer). TV only — a phone's remote-less keys stay on the VK path.
val remote = if (isTv) {
RemotePointer(
handle,
surfaceWidth = { videoView?.width?.takeIf { it > 0 } ?: decor?.width ?: 1920 },
onActiveChanged = { on -> remotePointerOn = on },
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
)
} else {
null
}
activity?.remotePointer = remote
// Shared clipboard (text v1): only when the user setting is on AND the host has a
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
val clip = if (session.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
ClipboardSync(context, handle).also { it.start() }
} else {
null
}
// Pin the panel to the stream's refresh (exact / multiple) for the session. The decoder's
// own ANativeWindow_setFrameRate hint still aligns vsync, but it is advisory — some OEM
// refresh governors ignore it outright and would leave a 120 Hz session on a 60/90 Hz
// panel. TV boxes skip the pin: the native side actively drives the HDMI mode there.
if (isTv) {
activity?.setConsoleHighRefreshRate(false) // the decoder's HDMI mode switch governs
} else {
activity?.setStreamDisplayMode(streamHz)
}
// Touch/pointer events are vsync-batched by default — up to a frame of input latency the
// stream shouldn't pay. Unbuffered dispatch delivers them the moment the kernel does.
// Undone by passing 0 on the way out (API 30+).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
composeView.requestUnbufferedDispatch(android.view.InputDevice.SOURCE_CLASS_POINTER)
}
// Vote the app's RENDER rate up to the stream's (API 35+). The mode pin above governs the
// panel, but the platform separately down-rates a quiet app's choreographer stream
// (frame-rate categories: a non-animating UI reads as "normal" = 60) — observed on-glass
// as 16.6 ms vsync callbacks on a 120 Hz panel, which would pace the presenter at half
// rate. The native side also subdivides onto the panel grid, so this vote is the belt to
// that braces. Reset to no-preference on the way out.
if (Build.VERSION.SDK_INT >= 35 && streamHz > 0) {
composeView.requestedFrameRate = streamHz.toFloat()
}
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
// rumble onto the device's own vibrator — for clip-on pads without rumble motors.
val feedback = GamepadFeedback(
handle,
router,
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
).also { it.start() }
// session closed.
val feedback = GamepadFeedback(handle, router).also { it.start() }
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
router.onSlotClosed = feedback::onDeviceRemoved
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
// already-paired BLE one — and forward its raw reports; the host mirrors a real
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
// through feedback.onHidRaw onto the physical controller. Engages only when such a pad is
// actually present; the wire slot is claimed lazily on its first state report.
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
// claim the interfaces; it resumes in onDispose once the stream releases them.
activity?.stopSc2MenuNav()
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
var sc2UsbReceiver: BroadcastReceiver? = null
if (sc2 != null) {
feedback.onHidRaw = sc2::onHidRaw
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = sc2.findUsbDevice()
when {
usbDev != null && usbManager.hasPermission(usbDev) -> sc2.startUsb(usbDev)
usbDev != null -> {
// One-time system dialog; capture engages on grant (Android remembers the
// grant for as long as the device stays attached).
val action = "io.unom.punktfunk.SC2_USB_PERMISSION"
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != action) return
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (ok) sc2.startUsb(usbDev) else Log.i("punktfunk", "SC2 USB permission denied")
}
}
sc2UsbReceiver = receiver
ContextCompat.registerReceiver(
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
)
usbManager.requestPermission(
usbDev,
PendingIntent.getBroadcast(
context, 0,
Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED -> {
sc2.pairedBleAddress()?.let { addr ->
Log.i("punktfunk", "SC2: no USB pad — using the paired BLE controller $addr")
sc2.startBle(addr)
}
}
}
}
// Sony pad capture (DualSense / Edge / DualShock 4, opt-out): claim a USB-connected
// pad's HID interface and drive it directly — rumble without a kernel force-feedback
// driver, plus adaptive triggers, lightbar, player LEDs and gyro/touchpad, none of which
// the InputDevice path can render (no platform API for any of them). Uncaptured (toggle
// off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path —
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
// hands over deterministically.
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = ds.findUsbDevice()
when {
usbDev != null && usbManager.hasPermission(usbDev) -> ds.startUsb(usbDev)
usbDev != null -> {
// One-time system dialog; capture engages on grant (Android remembers the
// grant for as long as the device stays attached).
val action = "io.unom.punktfunk.DS_USB_PERMISSION"
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != action) return
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (ok) ds.startUsb(usbDev) else Log.i("punktfunk", "Sony pad USB permission denied")
}
}
dsUsbReceiver = receiver
ContextCompat.registerReceiver(
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
)
usbManager.requestPermission(
usbDev,
PendingIntent.getBroadcast(
context, 2, // requestCode 2 — 0/1 are the SC2 stream/menu grants
Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
}
}
onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
feedback.onHidRaw = null
feedback.sink = null
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
// Mouse/remote-pointer teardown: lift held buttons, drop the grab, restore the cursor.
mouse.release()
activity?.mouseForwarder = null
remote?.release()
activity?.remotePointer = null
decor?.pointerIcon = priorPointerIcon
activity?.streamHandle = 0L
activity?.requestStreamExit = null
// Back in the menus: the SC2 (if present) resumes driving the console UI.
activity?.startSc2MenuNav()
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
composeView.requestUnbufferedDispatch(0) // back to ordinary batched dispatch
}
if (Build.VERSION.SDK_INT >= 35) {
composeView.requestedFrameRate = View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
}
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
window?.setSoftInputMode(priorSoftInput)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && priorCutout != null) {
window?.let { w ->
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
}
}
controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -529,60 +221,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
// Leaving the app (Home, task switch, screen off) MUST end the session. Android does not
// suspend a process for going to background, so without this the native worker kept running and
// its QUIC connection kept answering the host's keep-alives — the user was long gone but the
// host still saw a live client and held the session (and its display + encoder) open until the
// OS eventually reclaimed the process, which on a TV box is effectively never.
//
// Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real
// teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit",
// so the host should linger the display and make coming straight back a fast reconnect.
DisposableEffect(handle) {
val lifecycle = (context as? LifecycleOwner)?.lifecycle
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
onDisconnect()
}
}
lifecycle?.addObserver(obs)
onDispose { lifecycle?.removeObserver(obs) }
}
// Auto-engage pointer capture at stream start (setting on + a mouse actually present).
// Delayed a beat: the grab needs window focus and the capture view attached.
LaunchedEffect(handle) {
delay(400)
activity?.mouseForwarder?.engageFromStart()
}
// Fit the picture to the stream's own aspect, letterboxing the rest in black. MediaCodec scales
// whatever it decodes to fill the Surface it renders into, so a 16:9 stream on a 20:9 panel came
// out stretched — the surface has to carry the aspect, because nothing downstream of it can.
// The mode is the negotiated one (known from the handshake, before the first frame); 0/absent —
// an older native lib — falls back to filling, i.e. exactly the previous behaviour.
val videoAspect = remember(handle) {
val size = NativeBridge.nativeVideoSize(handle)
val w = size?.getOrNull(0) ?: 0
val h = size?.getOrNull(1) ?: 0
if (w > 0 && h > 0) w.toFloat() / h.toFloat() else 0f
}
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
// One rect for the picture AND for the input that lands on it. Every absolute mapping —
// direct-pointer touch, multi-touch passthrough, the pen lane — measures against the size of
// the node it sits on, so putting the gesture layer on this same rect keeps all three correct
// by construction rather than by threading an offset through each of them. The cost is that
// trackpad swipes starting inside a letterbox bar don't register; the picture is the surface.
val videoFit = if (videoAspect > 0f) {
Modifier.align(Alignment.Center).aspectRatio(videoAspect)
} else {
Modifier.fillMaxSize()
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
modifier = videoFit,
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
SurfaceView(ctx).apply {
videoView = this
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
// Low-latency mode: rank MediaCodecList decoders for the negotiated
@@ -599,14 +242,6 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
lowLatencyMode,
choice?.lowLatencyFeature ?: false,
isTv,
initialSettings.presentPriorityWire(),
initialSettings.smoothBuffer,
// The panel's own refresh — from the mode TABLE (streamPanelFps),
// because display.refreshRate reports a per-uid override, not the
// panel. Fallback: the (possibly lying) live rate.
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
if (micWanted) NativeBridge.nativeStartMic(handle)
@@ -633,290 +268,22 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// BEFORE the transparent gesture layer below, so it shows through and never eats touches.
if (statsOn) {
stats?.let {
StatsOverlay(
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
panelHz,
Modifier.align(Alignment.TopStart).padding(12.dp),
)
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
}
}
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
// broken. Purely visual; it sits above the video and below the gesture layer.
if (exitArming) {
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
}
// Remote-pointer mode hint — the remote's keys are remapped while it's on, so say so.
if (remotePointerOn) {
RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
}
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up
// in the mouse modes) AND the pointer-capture grab target — it never draws or takes
// touches, it just owns IME focus and receives captured-pointer events.
AndroidView(
modifier = Modifier.size(1.dp),
factory = { ctx ->
KeyCaptureView(ctx).also { v ->
keyCapture = v
// Real IME text path when the host types committed text (see KeyCaptureView).
v.textHandle =
if (NativeBridge.nativeTextInputSupported(handle)) handle else 0L
v.setOnCapturedPointerListener { _, ev ->
(ctx as? MainActivity)?.mouseForwarder?.onCapturedPointer(ev) ?: false
}
}
},
)
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. Passthrough gets no
// keyboard gesture: its fingers belong to the host verbatim (a swipe there may BE a
// host-OS gesture), so intercepting three fingers would corrupt real multi-touch.
// Stylus lane (design/pen-tablet-input.md §7): against a HOST_CAP_PEN host a stylus
// splits out of BOTH touch models onto the pen plane; its heartbeat coroutine keeps a
// stationary held stroke alive (and its cancellation lifts everything on teardown).
val stylus = remember(handle) {
if (NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
}
if (stylus != null) {
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
}
// vocabulary) or real multi-touch passthrough — see TouchInput.kt.
Box(
videoFit.pointerInput(handle, touchMode) {
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
when (touchMode) {
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
TouchMode.TOUCH -> streamTouchPassthrough(handle)
else -> streamTouchInput(
handle,
stylus,
trackpad = touchMode == TouchMode.TRACKPAD,
invertScroll = initialSettings.invertScroll,
onCycleStats = { statsVerbosity = statsVerbosity.next() },
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
)
}
},
)
}
}
/**
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
* chord no longer quits on a quick press the router debounces it on a ~1 s hold so this confirms
* the press registered and tells the user to keep holding. Purely visual; [GamepadRouter.onExitArmed]
* toggles its visibility.
*/
@Composable
private fun ExitChordHint(modifier: Modifier = Modifier) {
Text(
"Hold to quit…",
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
/**
* The remote-pointer mode cue: while active the remote's keys are remapped (D-pad glides the host
* cursor, SELECT clicks), so the overlay both confirms the toggle and teaches the vocabulary.
*/
@Composable
private fun RemotePointerHint(modifier: Modifier = Modifier) {
Text(
"Remote pointer — SELECT click · play/pause right-click · hold SELECT to exit",
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
/**
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
* onto this view. Two IME models, picked by the host's capabilities:
* * **Text path** ([textHandle] set the host advertised `HOST_CAP_TEXT_INPUT`): a real
* editable [HostTextConnection], so the IME gives autocorrect, gesture typing, non-Latin
* composition and emoji, all mirrored to the host as committed text + diffs.
* * **Fallback** (older host): `TYPE_NULL` puts the IME in "dumb keyboard" mode raw
* [KeyEvent]s flow through `MainActivity.dispatchKeyEvent` `Keymap.toVk` the host, the
* exact path a hardware keyboard takes (with the IME-shift wrap documented there).
*
* Doubles as the pointer-capture grab target: a grab needs a focusable view, and captured-pointer
* events are delivered to it (routed to [MouseForwarder.onCapturedPointer] via the listener the
* stream screen installs).
*/
private class KeyCaptureView(context: Context) : View(context) {
init {
isFocusable = true
isFocusableInTouchMode = true
}
/** The session handle when the host types committed text; `0` = VK-only fallback. */
var textHandle: Long = 0L
/** Whether [setImeVisible] last showed the IME — for toggle-style callers (remote pointer). */
var imeShown = false
private set
override fun onCheckIsTextEditor(): Boolean = imeShown
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
// Only an editor while the user has SUMMONED the keyboard (gesture / remote toggle).
// This view holds focus for the whole stream (it's the capture anchor), and with an
// always-live editable connection the IME counts input as active on it — TV IMEs then
// pop their UI the moment a PHYSICAL keyboard key arrives. With no connection, hardware
// typing stays on the raw dispatchKeyEvent → Keymap → wire path and no keyboard appears.
if (!imeShown) return null
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_ENTER_ACTION
return if (textHandle != 0L) {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
HostTextConnection(this, textHandle)
} else {
outAttrs.inputType = InputType.TYPE_NULL
BaseInputConnection(this, false)
}
}
fun setImeVisible(show: Boolean) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
?: return
imeShown = show
if (show) {
requestFocus()
// The view may already be focused from a null-connection state — restart so the
// framework re-queries onCreateInputConnection with the gate now open.
imm.restartInput(this)
imm.showSoftInput(this, 0)
} else {
imm.hideSoftInputFromWindow(windowToken, 0)
imm.restartInput(this) // gate closed — drop the editable connection
}
}
/**
* BACK while the summoned keyboard is up: the IME consumes it pre-IME to dismiss itself, so
* [setImeVisible] never hears about it sync the gate here or a stale `imeShown` leaves the
* editable connection live and physical typing re-pops the keyboard.
*/
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK && imeShown && event.action == KeyEvent.ACTION_UP) {
imeShown = false
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)
?.restartInput(this)
}
return super.onKeyPreIme(keyCode, event)
}
}
/**
* IME host text bridge (the `HOST_CAP_TEXT_INPUT` path): a real **editable** connection, so
* the IME runs its full machinery (autocorrect, gesture typing, non-Latin composition), mirrored
* to the host as it happens. The one piece of host-side state tracked is *what the host currently
* shows of the active composition* ([sentComposition]): composing updates send a common-prefix
* diff (backspaces + the new suffix) so corrections materialize live on the host; a commit
* settles it. [setComposingRegion] adopts already-committed text as the active composition
* (autocorrect-revert / backspace-into-word flows), so the next update diffs against it instead
* of retyping. Newlines become Enter taps; [deleteSurroundingText] becomes Backspace/Delete taps.
*
* Known approximation: diff lengths are counted in Unicode scalars, assuming one host Backspace
* deletes one scalar true for the composition text IMEs actually produce (emoji and other
* multi-unit graphemes commit directly rather than composing).
*/
private class HostTextConnection(
view: KeyCaptureView,
private val handle: Long,
) : BaseInputConnection(view, true) {
/** What the host currently shows of the active composition ("" = none). */
private var sentComposition = ""
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
retype(text.toString())
sentComposition = ""
val ok = super.commitText(text, newCursorPosition)
trimEditable()
return ok
}
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean {
retype(text.toString())
return super.setComposingText(text, newCursorPosition)
}
override fun finishComposingText(): Boolean {
// The composition text stands as committed — the host already shows it verbatim.
sentComposition = ""
return super.finishComposingText()
}
override fun setComposingRegion(start: Int, end: Int): Boolean {
val e = editable
if (e != null) {
val a = start.coerceIn(0, e.length)
val b = end.coerceIn(0, e.length)
sentComposition = e.subSequence(minOf(a, b), maxOf(a, b)).toString()
}
return super.setComposingRegion(start, end)
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
repeat(beforeLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_BACK) }
repeat(afterLength.coerceIn(0, MAX_TAPS)) { tapVk(VK_DELETE) }
return super.deleteSurroundingText(beforeLength, afterLength)
}
override fun performEditorAction(actionCode: Int): Boolean {
tapVk(VK_RETURN)
return true
}
/** Replace the host's view of the composition with [text] via a common-prefix diff. */
private fun retype(text: String) {
var common = sentComposition.commonPrefixWith(text)
// Never split a surrogate pair mid-diff — back off to the pair boundary.
if (common.isNotEmpty() && common.last().isHighSurrogate()) {
common = common.dropLast(1)
}
val stale = sentComposition.substring(common.length)
repeat(stale.codePointCount(0, stale.length).coerceAtMost(MAX_TAPS)) { tapVk(VK_BACK) }
sendText(text.substring(common.length))
sentComposition = text
}
/** Forward literal text, turning newlines into Enter taps (control chars never ride text). */
private fun sendText(s: String) {
var chunk = StringBuilder()
for (ch in s) {
if (ch == '\n') {
if (chunk.isNotEmpty()) {
NativeBridge.nativeSendText(handle, chunk.toString())
chunk = StringBuilder()
}
tapVk(VK_RETURN)
} else {
chunk.append(ch)
}
}
if (chunk.isNotEmpty()) NativeBridge.nativeSendText(handle, chunk.toString())
}
private fun tapVk(vk: Int) {
NativeBridge.nativeSendKey(handle, vk, true, 0)
NativeBridge.nativeSendKey(handle, vk, false, 0)
}
/** Bound the mirror buffer: once nothing is composing, old text serves no purpose. */
private fun trimEditable() {
val e = editable ?: return
if (getComposingSpanStart(e) == -1 && e.length > 4000) e.clear()
}
private companion object {
const val VK_BACK = 0x08
const val VK_RETURN = 0x0D
const val VK_DELETE = 0x2E
const val MAX_TAPS = 256
}
}
@@ -1,195 +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
private const val MAX_SAMPLES = 8
/**
* 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 — a single batch. */
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
@@ -21,10 +19,6 @@ private const val TAP_SLOP = 12f
private const val TAP_DRAG_MS = 250L
private const val SCROLL_DIV = 4f
// Three-finger vertical swipe: the fraction of the view height the centroid must travel to
// summon (up) / dismiss (down) the local soft keyboard.
private const val KB_SWIPE_FRACTION = 0.10f
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
@@ -46,9 +40,7 @@ private const val ACCEL_MAX = 3.0f
*
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier);
* three-finger swipe up/down = [onKeyboard] (summon/dismiss the local soft keyboard, for
* typing on the host).
* windows); three-finger tap = [onCycleStats] (cycle the stats-HUD verbosity tier).
*/
/**
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
@@ -58,26 +50,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 +62,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 {
@@ -121,13 +92,9 @@ 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
@@ -144,7 +111,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
@@ -161,12 +128,6 @@ internal suspend fun PointerInputScope.streamTouchInput(
var maxFingers = 1
var scrolling = false
var scrollCount = 0 // pointer count the scroll centroid is anchored at
// Keyboard-swipe state: the 3+-finger centroid anchor (per finger count, like the
// scroll anchor) and a once-per-gesture latch.
var kbCount = 0
var kbAnchorX = 0f
var kbAnchorY = 0f
var kbFired = false
var prevCx = startX
var prevCy = startY
var upTime = down.uptimeMillis
@@ -181,19 +142,15 @@ 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
}
if (pressed.size > maxFingers) maxFingers = pressed.size
// Dropping below three fingers forgets the keyboard-swipe anchor, so a 3→2→3
// bounce re-anchors instead of reading the count change as swipe travel.
if (pressed.size < 3) kbCount = 0
if (pressed.size == 2) {
// Two fingers → scroll by the centroid delta; never move the cursor.
if (pressed.size >= 2) {
// Two+ fingers → scroll by the centroid delta; never move the cursor.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
// (Re-)anchor whenever the finger COUNT changes, not just on scroll start: the
@@ -211,45 +168,15 @@ 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
}
} else if (pressed.size >= 3) {
// Three+ fingers → the keyboard swipe, never scroll (the documented
// vocabulary is TWO-finger scroll; 3+ only fell into the scroll path as an
// accident of its old `>= 2` bound). Anchor the centroid per finger count
// (same reasoning as the scroll anchor above) and fire once per gesture when
// the vertical travel crosses the threshold: up = show, down = hide.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
if (pressed.size != kbCount) {
kbCount = pressed.size
kbAnchorX = cx
kbAnchorY = cy
} else {
val dy = cy - kbAnchorY
// Real centroid travel disqualifies the tap classification below (else a
// sub-threshold swipe would still fire the three-finger stats tap).
if (abs(dy) > TAP_SLOP || abs(cx - kbAnchorX) > TAP_SLOP) moved = true
if (!kbFired && abs(dy) >= size.height * KB_SWIPE_FRACTION) {
kbFired = true
onKeyboard(dy < 0) // finger up → show, finger down → hide
}
}
// Leaving the scroll state stale would read the 3→2 centroid jump as a wheel
// notch; clearing it makes a return to two fingers re-anchor fresh. Same for
// the trackpad's tracked finger: its prev position froze while 3+ fingers were
// down, so dropping straight back to one finger must re-anchor (zero delta),
// not replay the whole 3-finger phase as one cursor jump.
scrolling = false
scrollCount = 0
trackId = PointerId(Long.MIN_VALUE)
} else if (!scrolling) {
// One finger (skipped once a gesture turned into a scroll, so dropping
// back to one finger doesn't jerk the cursor).
@@ -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,98 +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
/**
* 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 (Font Awesome Free brands
* CC BY 4.0 + Simple Icons CC0 provenance in that directory's README); 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 = 448f,
viewportHeight = 512f,
d = "M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z",
),
"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",
),
)
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 =
ImageVector.Builder(
name = "OsIcon.$token",
defaultWidth = 24.dp,
defaultHeight = 24.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,44 +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,
)
/** Trust state of a host, shown as a colored pill on its card. */
enum class HostStatus(val label: String) {
PAIRED("Paired"),
@@ -1,247 +0,0 @@
package io.unom.punktfunk
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/**
* The profile model the part of this feature that is wrong-or-right rather than pretty-or-ugly.
* A profile is a named bundle of OVERRIDES, not a snapshot: an untouched field keeps following the
* global live, a touched one is recorded even when it equals today's global (a pin), and the only
* way back to inheriting is an explicit reset. These tests are the Kotlin twin of the Rust
* `profiles.rs` suite, so the two can't drift.
*
* `sdk = [36]` for the same reason the screenshot tests pin it: Robolectric ships android-all jars
* only up to API 36 while the app compiles against 37.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class ProfilesTest {
private val base = Settings(
width = 1920,
height = 1080,
bitrateKbps = 20_000,
codec = "hevc",
touchMode = TouchMode.TRACKPAD,
mouseMode = MouseMode.DESKTOP,
)
@Test
fun overlayAppliesOnlyWhatItOverrides() {
val empty = SettingsOverlay()
assertTrue(empty.isEmpty())
assertEquals(base, empty.apply(base))
val overlay = SettingsOverlay(
width = 3840,
height = 2160,
hz = 120,
bitrateKbps = 80_000,
renderScale = 1.5,
codec = "av1",
hdrEnabled = false,
compositor = 4,
audioChannels = 6,
micEnabled = true,
touchMode = TouchMode.POINTER,
mouseMode = MouseMode.CAPTURE,
invertScroll = true,
gamepad = 6,
statsVerbosity = StatsVerbosity.DETAILED,
lowLatencyMode = false,
)
assertFalse(overlay.isEmpty())
val out = overlay.apply(base)
assertEquals(Triple(3840, 2160, 120), Triple(out.width, out.height, out.hz))
assertEquals(80_000, out.bitrateKbps)
assertEquals(1.5, out.renderScale, 0.0)
assertEquals("av1", out.codec)
assertFalse(out.hdrEnabled)
assertEquals(4, out.compositor)
assertEquals(6, out.audioChannels)
assertTrue(out.micEnabled)
assertEquals(TouchMode.POINTER, out.touchMode)
assertEquals(MouseMode.CAPTURE, out.mouseMode)
assertTrue(out.invertScroll)
assertEquals(6, out.gamepad)
assertEquals(StatsVerbosity.DETAILED, out.statsVerbosity)
assertFalse(out.lowLatencyMode)
// Device-scope settings are not in the overlay at all, so no profile can move them.
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
assertEquals(base.libraryEnabled, out.libraryEnabled)
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
assertEquals(base.sc2Capture, out.sc2Capture)
}
@Test
fun anOverrideEqualToTheGlobalIsAPinThatSurvivesTheGlobalMoving() {
val pin = SettingsOverlay(bitrateKbps = 20_000) // exactly what `base` says today
assertFalse(pin.isEmpty())
assertEquals(20_000, pin.apply(base.copy(bitrateKbps = 50_000)).bitrateKbps)
}
@Test
fun absorbRecordsTheTouchedFieldOnly() {
var o = SettingsOverlay()
// One control fires: before = what it was showing, after = what the user picked.
var before = o.apply(base)
o = o.absorb(before, before.copy(codec = "av1"))
assertEquals("av1", o.codec)
assertNull("nothing else may be recorded", o.bitrateKbps)
// Setting it BACK to the global's value is still an override — the pin case, and the whole
// difference between this and diffing against the globals at save time.
before = o.apply(base)
o = o.absorb(before, before.copy(codec = "hevc"))
assertEquals("hevc", o.codec)
assertEquals("hevc", o.apply(base.copy(codec = "h264")).codec)
// Identical snapshots record nothing.
before = o.apply(base)
assertEquals(o, o.absorb(before, before))
}
@Test
fun clearDropsOneOverride() {
val o = SettingsOverlay(width = 3840, height = 2160, codec = "av1")
assertEquals(setOf(SettingsOverlay.FIELD_RESOLUTION, "codec"), o.overridden())
assertNull(o.clear("codec").codec)
// Width and height are one control, so they reset together.
val reset = o.clear(SettingsOverlay.FIELD_RESOLUTION)
assertNull(reset.width)
assertNull(reset.height)
assertEquals(o, o.clear("no_such_field")) // unknown names are a no-op, never a crash
}
@Test
fun catalogRoundTripsAndPreservesWhatItCannotRepresent() {
val store = ProfileStore(RuntimeEnvironment.getApplication())
val game = newProfile("Game").copy(
accent = "#ff8800",
overrides = SettingsOverlay(
width = 3840,
height = 2160,
hz = 120,
// A codec string this build's picker can't show is still stored and still applied:
// the host is the component that decides what it can encode.
codec = "vvc-from-the-future",
extra = mapOf("some_new_axis" to 7),
),
extra = mapOf("future_profile_key" to "kept"),
)
store.save(game)
store.save(newProfile("Work"))
val loaded = store.byId(game.id)!!
assertEquals("Game", loaded.name)
assertEquals("#ff8800", loaded.accent)
assertEquals("vvc-from-the-future", loaded.overrides.codec)
assertEquals(3840, loaded.overrides.width)
// The don't-clobber rule: an older build must not erase a newer one's keys by opening it.
assertEquals(mapOf<String, Any>("some_new_axis" to 7), loaded.overrides.extra)
assertEquals(mapOf<String, Any>("future_profile_key" to "kept"), loaded.extra)
assertEquals("vvc-from-the-future", loaded.overrides.apply(base).codec)
// A profile that overrides nothing is the "inherits everything" one a create starts at.
assertTrue(store.all().first { it.name == "Work" }.overrides.isEmpty())
assertEquals(listOf("Game", "Work"), store.all().map { it.name })
}
@Test
fun resolvePrefersIdsAndRefusesAmbiguity() {
val store = ProfileStore(RuntimeEnvironment.getApplication())
val work = newProfile("Work")
val work2 = newProfile("work") // saved directly: the UI's name guard is what prevents this
val game = newProfile("Game")
listOf(work, work2, game).forEach(store::save)
assertEquals(ProfileResolution.FOUND, store.resolve(work.id).second)
assertEquals(work.id, store.resolve(work.id).first!!.id)
// Two profiles carry this name — refuse rather than pick whichever came first.
assertEquals(ProfileResolution.AMBIGUOUS, store.resolve("Work").second)
assertNull(store.resolve("Work").first)
assertEquals(game.id, store.resolve("GAME").first!!.id) // names match case-insensitively
assertEquals(ProfileResolution.NOT_FOUND, store.resolve("nope").second)
assertEquals(ProfileResolution.NOT_FOUND, store.resolve("").second)
assertTrue(store.nameTaken("GAME"))
assertFalse(store.nameTaken("GAME", except = game.id)) // renaming in place is allowed
assertFalse(store.nameTaken("Travel"))
}
@Test
fun profilePrecedenceIsOneOffThenBindingThenNone() {
val store = ProfileStore(RuntimeEnvironment.getApplication())
val work = newProfile("Work")
val game = newProfile("Game")
listOf(work, game).forEach(store::save)
val bound = host().copy(profileId = work.id)
// A plain tap follows the binding…
assertEquals(work.id, store.resolveFor(bound, oneOff = null)!!.id)
// …a one-off wins over it, by id or by unique name, and never rebinds anything…
assertEquals(game.id, store.resolveFor(bound, oneOff = game.id)!!.id)
assertEquals(game.id, store.resolveFor(bound, oneOff = "game")!!.id)
assertEquals(work.id, store.resolveFor(bound, oneOff = null)!!.id)
// …and the empty reference is a real choice — "force the global defaults" — not "unset".
assertNull(store.resolveFor(bound, oneOff = ""))
// An unbound host is today's behaviour: the globals.
assertNull(store.resolveFor(host(), oneOff = null))
assertNull(store.resolveFor(null, oneOff = null))
}
@Test
fun aDeletedProfileLeavesNoErrorBehind() {
val store = ProfileStore(RuntimeEnvironment.getApplication())
val work = newProfile("Work")
store.save(work)
val h = host().copy(profileId = work.id, pinnedProfileIds = listOf(work.id, work.id))
assertEquals(1, store.pinsFor(h).size) // a duplicate pin is one card, not two
store.delete(work.id)
// A dangling binding resolves as "no profile" — never an error, never a blocked connect —
// and its pinned card simply stops rendering.
assertNull(store.resolveFor(h, oneOff = null))
assertTrue(store.pinsFor(h).isEmpty())
assertEquals(base, base.effectiveFor(store.resolveFor(h, oneOff = null)))
}
/**
* A profile created from the UI gets a colour, and a distinct one the accent is the WHOLE
* signal on a bound host card's chip and a pinned card's tint, so two profiles sharing it (or
* having none) makes those surfaces say less than they look like they're saying.
*/
@Test
fun creationHandsOutADistinctColour() {
val made = mutableListOf<StreamProfile>()
repeat(PROFILE_ACCENTS.size) { made += newProfile("p$it", nextAccent(made)) }
assertEquals(PROFILE_ACCENTS, made.map { it.accent })
// Past the palette it wraps rather than handing out nothing — a duplicate colour beats an
// invisible chip, and the picker is right there.
assertEquals(PROFILE_ACCENTS.first(), nextAccent(made))
// A gap is reused before wrapping.
assertEquals(PROFILE_ACCENTS[2], nextAccent(made.filter { it.accent != PROFILE_ACCENTS[2] }))
// The colour is presentation, so it never reaches the resolved settings.
assertEquals(base, made.first().overrides.apply(base))
}
@Test
fun mintedIdsAreWellFormed() {
val id = newProfileId()
assertEquals(12, id.length)
assertTrue(id.all { it.isDigit() || it in 'a'..'f' })
assertNotEquals(id, newProfileId())
}
private fun host() = KnownHost("192.168.1.42", 9777, "Desk", "a".repeat(64), paired = true)
}
@@ -1,73 +0,0 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure JVM test of the client-side render-scale geometry ([RenderScale]) the Kotlin twin of
* `punktfunk-core`'s `render_scale` module. Run: `./gradlew :app:testDebugUnitTest`.
*/
class RenderScaleTest {
@Test
fun sanitizeClampsAndDefaults() {
assertEquals(1.0, RenderScale.sanitize(0.0), 0.0) // absent / zero → Native
assertEquals(1.0, RenderScale.sanitize(-2.0), 0.0)
assertEquals(1.0, RenderScale.sanitize(Double.NaN), 0.0)
assertEquals(0.5, RenderScale.sanitize(0.1), 0.0) // below the floor
assertEquals(4.0, RenderScale.sanitize(9.0), 0.0) // above the ceiling
assertEquals(1.5, RenderScale.sanitize(1.5), 0.0)
}
@Test
fun maxDimensionIsCodecAware() {
assertEquals(4096, RenderScale.maxDimension("h264"))
assertEquals(8192, RenderScale.maxDimension("hevc"))
assertEquals(8192, RenderScale.maxDimension("av1"))
assertEquals(8192, RenderScale.maxDimension("auto"))
}
@Test
fun nativeIsIdentity() {
assertEquals(1920 to 1080, RenderScale.apply(1920, 1080, 1.0, 8192))
}
@Test
fun supersampleDoubles() {
assertEquals(3840 to 2160, RenderScale.apply(1920, 1080, 2.0, 8192))
}
@Test
fun underRenderHalves() {
assertEquals(960 to 540, RenderScale.apply(1920, 1080, 0.5, 8192))
}
@Test
fun resultsAreEven() {
// 1366×768 × 1.5 = 2049×1152 → even-floored to 2048×1152.
val (w, h) = RenderScale.apply(1366, 768, 1.5, 8192)
assertEquals(0, w % 2)
assertEquals(0, h % 2)
assertEquals(2048 to 1152, w to h)
}
@Test
fun overCeilingClampsUniformly() {
// 4K × 4 = 15360×8640; both exceed 8192 → width lands on cap, 16:9 kept (8192×4608).
val (w, h) = RenderScale.apply(3840, 2160, 4.0, 8192)
assertTrue(w <= 8192 && h <= 8192)
assertEquals(8192 to 4608, w to h)
}
@Test
fun h264CeilingIsTighter() {
// 1080p × 4 = 7680×4320; under H.264's 4096 wall → 4096×2304.
assertEquals(4096 to 2304, RenderScale.apply(1920, 1080, 4.0, 4096))
}
@Test
fun minimumFloorHonoured() {
val (w, h) = RenderScale.apply(400, 300, 0.5, 8192)
assertTrue(w >= 320 && h >= 200)
}
}
@@ -1,127 +0,0 @@
package io.unom.punktfunk
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.Config
/**
* An edit must land in the scope the chips say is selected the one thing the two-layer settings
* surface can get wrong without looking wrong.
*
* The regression this pins: `update` reached the rows as `::update`, and two callable references
* compare EQUAL however different the scope they captured, so Compose skipped the whole detail page
* on a scope switch that moved nothing on screen (the ordinary case a profile inherits the globals
* until it overrides something). Each edit then wrote to the scope the user had just left: change a
* default, switch to a profile, change the same row the globals moved again and the profile
* recorded nothing and back on the defaults the next edit went into the profile, which reads as
* "the default settings can't be changed any more". It needs the real Compose runtime to catch, so
* this drives the actual screen rather than the model underneath it.
*
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 while
* the app compiles against 37.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
class SettingsScopeTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
private val context: Context get() = ApplicationProvider.getApplicationContext()
/**
* "Invert scroll direction" is the row under test: Input is profileable end to end, and that
* toggle is the only toggleable node on the page, so a click needs no fragile lookup.
*/
private fun toggleTheRow() {
compose.onNode(isToggleable()).performClick()
compose.waitForIdle()
}
private fun selectScope(chip: String) {
compose.onNodeWithText(chip).performClick()
compose.waitForIdle()
}
@Test
fun editsFollowTheSelectedScope() {
val profiles = ProfileStore(context)
profiles.save(newProfile("Work", PROFILE_ACCENTS.first()))
// Mirrors App.kt: the screen is fed from state the host recomposes it with.
var saved = Settings()
compose.setContent {
var settings by remember { mutableStateOf(saved) }
SettingsScreen(
initial = settings,
onChange = { settings = it; saved = it },
onBack = {},
initialCategory = SettingsCategory.Input,
)
}
// 1. On the defaults, the globals move and no profile records anything.
toggleTheRow()
assertEquals(true, saved.invertScroll)
assertNull(profiles.all().single().overrides.invertScroll)
// 2. In profile scope the SAME row — untouched by the profile, so it still shows the global
// value and nothing on the page changed — must record an override and leave the globals
// alone. This is the step that used to write straight through to the globals.
selectScope("Work")
toggleTheRow()
assertEquals("the globals must not move while a profile is selected", true, saved.invertScroll)
assertEquals(false, profiles.all().single().overrides.invertScroll)
// 3. Back on the defaults the row is editable again, and the profile keeps its override.
selectScope("Default settings")
toggleTheRow()
assertEquals(false, saved.invertScroll)
assertEquals(
"the profile's override must survive an edit made on the defaults",
false,
profiles.all().single().overrides.invertScroll,
)
}
/** A reset puts the row back to inheriting — and, like an edit, it must obey the live scope. */
@Test
fun resetClearsTheSelectedProfilesOverride() {
val profiles = ProfileStore(context)
profiles.save(newProfile("Work", PROFILE_ACCENTS.first()))
compose.setContent {
var settings by remember { mutableStateOf(Settings()) }
SettingsScreen(
initial = settings,
onChange = { settings = it },
onBack = {},
initialCategory = SettingsCategory.Input,
initialProfileId = profiles.all().single().id,
)
}
toggleTheRow()
assertEquals(true, profiles.all().single().overrides.invertScroll)
compose.onNodeWithText("Reset").performClick()
compose.waitForIdle()
assertNull(profiles.all().single().overrides.invertScroll)
}
}
@@ -1,131 +0,0 @@
package io.unom.punktfunk
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/**
* Where a measured bitrate lands. The measurement itself is the host's job; the decision this code
* makes is which layer to write and the long-standing wrong answer (always the global) is exactly
* what made measuring the slow box downstairs re-tune the desktop too
* (design/client-settings-profiles.md §5.3).
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class SpeedTestTest {
private val store get() = ProfileStore(RuntimeEnvironment.getApplication())
private fun host() = KnownHost("192.168.1.42", 9777, "Desk", "a".repeat(64), paired = true)
@Test
fun anUnboundHostTargetsTheGlobalDefault() {
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(host(), null, store))
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(null, null, store))
}
@Test
fun aProfileThatSetsBitrateIsTheLayerThatHostReads() {
val s = store
val game = newProfile("Game").copy(overrides = SettingsOverlay(bitrateKbps = 50_000))
s.save(game)
val target = SpeedTestTarget.resolve(host().copy(profileId = game.id), null, s)
assertEquals(game.id, (target as SpeedTestTarget.Profile).profile.id)
}
@Test
fun aProfileThatInheritsBitrateAsksWhichLayer() {
val s = store
val work = newProfile("Work") // overrides nothing
s.save(work)
val target = SpeedTestTarget.resolve(host().copy(profileId = work.id), null, s)
// Both layers are defensible here, so the user picks — we don't guess.
assertEquals(work.id, (target as SpeedTestTarget.Ask).profile.id)
}
@Test
fun theOneOffPickWinsAndTheEmptyOneForcesTheDefaults() {
val s = store
val game = newProfile("Game").copy(overrides = SettingsOverlay(bitrateKbps = 50_000))
val work = newProfile("Work")
listOf(game, work).forEach(s::save)
val bound = host().copy(profileId = work.id)
// Testing from a pinned card measures — and writes — that card's profile.
assertEquals(game.id, (SpeedTestTarget.resolve(bound, game.id, s) as SpeedTestTarget.Profile).profile.id)
// "Connect with: Default settings" is a real choice, so its speed test targets the global.
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(bound, "", s))
// A dangling binding resolves as no profile everywhere else; here too.
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(host().copy(profileId = "gone"), null, s))
}
@Test
fun applyingWritesOnlyTheBitrate_andOnlyToTheChosenLayer() {
val s = store
val game = newProfile("Game").copy(
overrides = SettingsOverlay(bitrateKbps = 50_000, width = 3840, height = 2160),
)
s.save(game)
val globals = Settings(bitrateKbps = 20_000, codec = "hevc")
var savedGlobals: Settings? = null
val where = applySpeedTestResult(
kbps = 84_000,
target = SpeedTestTarget.Profile(game),
toProfile = true,
profiles = s,
settings = globals,
onGlobalChange = { savedGlobals = it },
)
assertEquals("“Game”", where)
assertNull("the global must not move when a profile was the target", savedGlobals)
val after = s.byId(game.id)!!.overrides
assertEquals(84_000, after.bitrateKbps)
// Nothing else in the overlay is a speed test's business.
assertEquals(3840, after.width)
assertEquals(2160, after.height)
}
@Test
fun theAskCaseHonoursWhichButtonWasPressed() {
val s = store
val work = newProfile("Work")
s.save(work)
val globals = Settings(bitrateKbps = 20_000)
var savedGlobals: Settings? = null
// "Set as default" writes the global and leaves the profile inheriting.
val whereGlobal = applySpeedTestResult(
42_000, SpeedTestTarget.Ask(work), toProfile = false, profiles = s,
settings = globals, onGlobalChange = { savedGlobals = it },
)
assertEquals("the default bitrate", whereGlobal)
assertEquals(42_000, savedGlobals!!.bitrateKbps)
assertNull(s.byId(work.id)!!.overrides.bitrateKbps)
// "Set in Work" records the override instead — and now that profile stops inheriting.
savedGlobals = null
val whereProfile = applySpeedTestResult(
42_000, SpeedTestTarget.Ask(work), toProfile = true, profiles = s,
settings = globals, onGlobalChange = { savedGlobals = it },
)
assertEquals("“Work”", whereProfile)
assertNull(savedGlobals)
assertEquals(42_000, s.byId(work.id)!!.overrides.bitrateKbps)
}
@Test
fun theRecommendationLeavesHeadroom() {
// 70 % of measured, in the desktop clients' integer order — a stream needs room for the
// FEC overhead and for the loss a burst measurement doesn't see.
val done = SpeedTestPhase.Done(throughputKbps = 100_000, lossPct = 0.4, recommendedKbps = 100_000 / 10 * 7)
assertEquals(70_000, done.recommendedKbps)
assertEquals(100.0, done.measuredMbps, 0.001)
assertEquals(70.0, done.recommendedMbps, 0.001)
assertTrue(done.recommendedKbps < done.throughputKbps)
}
}
@@ -56,21 +56,6 @@ class ScreenshotTest {
@Test
fun settings() = shootRoot("settings") { SettingsScene() }
// One category page per shot: the sub-section headers, the caption-under-control fields and
// the "applies from the next session" footers live inside a category, not on the root list.
@Test
fun settingsDisplay() = shootRoot("settings-display") {
SettingsCategoryScene(io.unom.punktfunk.SettingsCategory.Display)
}
@Test
fun settingsInput() = shootRoot("settings-input") {
SettingsCategoryScene(io.unom.punktfunk.SettingsCategory.Input)
}
@Test
fun settingsProfile() = shootRoot("settings-profile") { SettingsProfileScene() }
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@@ -112,15 +97,6 @@ class ScreenshotTest {
TrustDialog()
}
@Test
fun newProfile() = shootRoot("new-profile") { NewProfileScene() }
@Test
fun speedTest() = shootScreen("speed-test") {
HostsScene()
SpeedTestScene()
}
@Test
fun pair() = shootScreen("pair") {
HostsScene()
@@ -19,12 +19,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.BrandDark
@@ -33,20 +31,11 @@ import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover
import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsCategory
import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.ProfileEditorFields
import io.unom.punktfunk.ProfileStore
import io.unom.punktfunk.SettingsOverlay
import io.unom.punktfunk.SpeedTestDialog
import io.unom.punktfunk.SpeedTestPhase
import io.unom.punktfunk.SpeedTestTarget
import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.HostMenuItem
import io.unom.punktfunk.components.SectionLabel
import io.unom.punktfunk.newProfile
import io.unom.punktfunk.models.HostStatus
// The CI screenshot scenes: the REAL app composables, fed embedded mock state, under the forced
@@ -59,31 +48,15 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = BrandDark, content = content)
}
private data class MockHost(
val name: String,
val address: String,
val status: HostStatus,
val profile: String? = null,
val pin: String? = null,
val accent: Color? = null,
val online: Boolean = false,
)
private data class MockHost(val name: String, val address: String, val status: HostStatus)
// Ordered so an UNCHIPPED card sits beside a CHIPPED one in the same grid row, and a long trust
// label ("Trust on first use") beside a short one ("Paired"). Both are what used to make cards in a
// row step up and down — the grid sizes a row to its tallest item and doesn't stretch the rest — so
// this arrangement is the regression net for it.
private val SAVED = listOf(
MockHost("Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED),
MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU),
MockHost(
"Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED,
profile = "Game", pin = "Work", accent = Color(0xFFFF8A4C), online = true,
),
)
private val DISCOVERED = listOf(
// Discovered ⇒ advertising right now, so both are online.
MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING, online = true),
MockHost("HTPC", "192.168.1.70:9777", HostStatus.TOFU, online = true),
MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING),
MockHost("HTPC", "192.168.1.70:9777", HostStatus.TOFU),
)
/** The connect screen's host grid, reconstructed from the real HostCard/SectionLabel components. */
@@ -113,170 +86,42 @@ internal fun HostsScene() {
}
}
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
// A pinned card is its OWN grid cell right after its host — the same flat list the
// connect screen builds, not a second card crammed into the host's cell.
SAVED.forEach { h ->
item {
HostCard(
h.name, h.address, h.status, online = h.online, enabled = true,
onConnect = {}, onForget = {}, onEdit = {},
// The bound profile is a quiet chip: the card says what a tap will do.
profileLabel = h.profile,
accent = h.accent,
menuItems = listOf(
HostMenuItem("Connect with: Default settings", startsSection = true) {},
HostMenuItem("Connect with: Game") {},
),
// One card in this section has a chip, so every card reserves its space —
// the shot is here to catch a row that steps.
reserveProfileSlot = true,
)
}
if (h.pin != null) {
item {
HostCard(
h.name, h.address, h.status, online = h.online, enabled = true,
onConnect = {}, onForget = null,
profileLabel = h.pin, profileProminent = true, accent = h.accent,
menuItems = listOf(HostMenuItem("Unpin card", startsSection = true) {}),
reserveProfileSlot = true,
)
}
}
items(SAVED) { h ->
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {})
}
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network")
}
items(DISCOVERED) { h ->
HostCard(
h.name, h.address, h.status, online = h.online,
enabled = true, onConnect = {}, onForget = null,
)
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = null)
}
}
}
}
/** A representative non-default settings state, shared by the settings scenes. */
private val SHOT_SETTINGS = Settings(
width = 1920,
height = 1080,
hz = 120,
bitrateKbps = 50_000,
compositor = 1,
gamepad = 2,
micEnabled = true,
statsVerbosity = StatsVerbosity.DETAILED,
touchMode = TouchMode.TRACKPAD,
)
/**
* The real SettingsScreen at its root the shared category map (General / Display / Input /
* Audio / Controllers / About) every client now presents.
*/
/** The real SettingsScreen, fed a representative non-default Settings. */
@Composable
internal fun SettingsScene() {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
SettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {})
}
}
/**
* One category page, seeded through `initialCategory` the sub-section headers, the
* caption-under-control fields and the "applies from the next session" footer only exist inside a
* category, so the root shot alone can't regress-catch them. Display is the richest page.
*/
@Composable
internal fun SettingsCategoryScene(category: SettingsCategory) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
SettingsScreen(
initial = SHOT_SETTINGS,
initial = Settings(
width = 1920,
height = 1080,
hz = 120,
bitrateKbps = 50_000,
compositor = 1,
gamepad = 2,
micEnabled = true,
statsVerbosity = StatsVerbosity.DETAILED,
touchMode = TouchMode.TRACKPAD,
),
onChange = {},
onBack = {},
initialCategory = category,
)
}
}
/**
* The same settings surface in a PROFILE's scope: the scope chips with "Game" selected, only
* profileable rows, every row showing the effective value, and the overridden ones carrying their
* marker and reset. One settings UI, two layers this shot is what proves it stayed one.
*/
@Composable
internal fun SettingsProfileScene() {
val store = ProfileStore(LocalContext.current)
val profile = remember {
val p = newProfile("Game").copy(
accent = "#FF8A4C",
// A representative mix: a resolution and refresh the profile pins, and a codec — the
// rest of the page keeps following the defaults, visibly unmarked.
overrides = SettingsOverlay(width = 3840, height = 2160, hz = 120, codec = "h264"),
)
store.save(p)
store.save(newProfile("Work"))
p
}
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
SettingsScreen(
initial = SHOT_SETTINGS,
onChange = {},
onBack = {},
initialCategory = SettingsCategory.Display,
initialProfileId = profile.id,
)
}
}
/**
* The speed test's result, in its most interesting shape: a host bound to a profile that INHERITS
* bitrate, so both layers are defensible and both buttons are offered. The note under the numbers
* is what stops "Apply" from being a write in an unknown direction.
*/
@Composable
internal fun SpeedTestScene() {
SpeedTestDialog(
hostName = "Living Room PC",
target = SpeedTestTarget.Ask(newProfile("Game")),
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
onApply = {},
onDismiss = {},
)
}
/**
* Creating a profile. Small, but it is the first thing a user meets when they reach for this
* feature and dialogs only get a shot each because a layout slip inside one is invisible from
* every other scene (this one shipped with the field and its caption touching).
*/
@Composable
internal fun NewProfileScene() {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("New profile", style = MaterialTheme.typography.headlineSmall)
// The dialog's own body, not a rebuild of it — the layout under test is the real one.
ProfileEditorFields(
name = "Travel",
accent = "#60A5FA",
duplicate = false,
creating = true,
onNameChange = {},
onAccentChange = {},
)
Text("Duplicate name", style = MaterialTheme.typography.headlineSmall)
ProfileEditorFields(
name = "Game",
accent = "#FF8A4C",
duplicate = true,
creating = false,
onNameChange = {},
onAccentChange = {},
)
}
}
}
/** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */
@Composable
internal fun TrustDialog() {
@@ -366,12 +211,9 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
2.0, 1.0, 5.0, 238.0,
1.0, 0.5, 1.8, 2.6,
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
0.2, 0.3, 236.0, 1.0,
),
verbosity = verbosity,
decoderLabel = "c2.qti.hevc.decoder · low-latency",
codecLabel = "HEVC",
modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
)
}
+3 -3
View File
@@ -2,10 +2,10 @@
// org.jetbrains.kotlin.android (it's an error under AGP 9). The Compose compiler plugin is declared
// here (version + apply false) so modules can apply it version-less; its version pins the build's
// Kotlin (compose-compiler and Kotlin release in lockstep), keeping them matched.
// Toolchain: AGP 9.3.1 · Gradle 9.5.0 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
// Toolchain: AGP 9.2.0 · Gradle 9.4.1 · Kotlin/Compose-compiler 2.3.21 · JDK 21 · Compose BOM
// 2026.05.01 · compileSdk 37 · targetSdk 37 · minSdk 28.
plugins {
id("com.android.application") version "9.3.1" apply false
id("com.android.library") version "9.3.1" apply false
id("com.android.application") version "9.2.1" apply false
id("com.android.library") version "9.2.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
}
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
+2 -10
View File
@@ -33,11 +33,7 @@ dependencies {
// mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads).
// OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256.
implementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("junit:junit:4.13.2") // JVM unit tests for the pure parsers/migrations
// A REAL org.json on the unit-test classpath. android.jar's org.json is stubs that throw
// "Stub!", so the host-store migration test — which asserts over the very JSON blobs the store
// reads and writes — cannot run without it. Explicit test deps precede the mockable android.jar.
testImplementation("org.json:json:20250107")
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
}
// ------------------------------------------------------------------------------------------------
@@ -47,11 +43,7 @@ dependencies {
// /README.md): `cargo install cargo-ndk` + `rustup target add aarch64-linux-android x86_64-linux-android`.
// ------------------------------------------------------------------------------------------------
val repoRoot = rootDir.parentFile.parentFile // clients/android -> clients -> repo root
// CARGO_HOME first: rustup puts every binary in $CARGO_HOME/bin, and the CI image
// (ci/android-ci.Dockerfile) installs the shared toolchain at /usr/local/cargo — the
// historical ~/.cargo fallback is what a GUI Android Studio launch (no env) still needs.
val cargoBin = System.getenv("CARGO_HOME")?.let { "$it/bin" }
?: "${System.getProperty("user.home")}/.cargo/bin"
val cargoBin = "${System.getProperty("user.home")}/.cargo/bin"
// SDK location without depending on AGP's DSL (sdkDirectory isn't in AGP 9's library extension):
// env first (set by Android Studio and by our CLI shell), then local.properties, then the default.
@@ -1,301 +0,0 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.hardware.usb.UsbDevice
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.InputDevice
/**
* One captured Sony pad (DualSense / DualSense Edge / DualShock 4) over USB stream mode only.
* The capture exists to fix what the InputDevice path structurally can't: rumble depends on the
* phone's kernel exposing force feedback (many don't), and adaptive triggers / lightbar / player
* LEDs have NO platform API at all. Claiming the pad's HID interface makes all of it work on any
* phone, plus gyro + touchpad the standard path never captured.
*
* Unlike [Sc2Capture] there is no raw passthrough the host's DualSense/DS4 backends consume
* only typed events and no UI mode: an UNcaptured Sony pad is a perfectly good InputDevice, so
* outside a stream the ordinary path drives the console UI and this class isn't constructed.
* That also makes the InputDevice path the automatic fallback whenever the capture doesn't
* engage (toggle off, permission denied, Bluetooth).
*
* Input: parse ([DsDevice.parseState]) typed mirror on an [GamepadRouter.ExternalPad] (buttons
* diffed, axes on-change the exit chord participates like any pad) + the rich plane (touch
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
* and freed on unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] rumble / trigger / lightbar / player
* LED events addressed to this pad's wire index become USB output reports on the physical pad
* ([DsDevice] builders). Rendering runs on the feedback poll threads; [HidUsbLink.writeRaw] is
* thread-safe (bounded newest-wins queue, submitted by the reader thread). A USB pad holds its
* rumble level until written zero, so a backstop timer re-arms per command and writes the stop
* itself if the poll thread stalls the engine's explicit zeros remain the real stop mechanism.
*/
class DsCapture(
context: Context,
private val router: GamepadRouter,
) : GamepadFeedback.PadFeedbackSink {
private val usb = HidUsbLink(
context,
HidUsbLink.Config(
tag = TAG,
threadName = "pf-ds-usb",
deviceMatch = { it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS },
// No ifaceFilter: the pad's audio interfaces are not HID class, so the link's built-in
// class check already leaves them (and the pad's headset routing) to Android; the
// single HID interface is the only claim.
),
::onReport,
::onLinkClosed,
)
@Volatile private var model: DsDevice.Model? = null
@Volatile private var pad: GamepadRouter.ExternalPad? = null
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
private val state = DsDevice.State()
private var wireButtons = 0
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
private val lastTouchActive = BooleanArray(2)
private val lastTouchX = IntArray(2) { -1 }
private val lastTouchY = IntArray(2) { -1 }
// DS4 composed feedback (its writes are full-state — see DsDevice.ds4Report). Feedback threads.
// The lightbar starts at hid-sony's player-1 blue so the first composed write (usually a
// rumble, before any host Led lands) doesn't black the bar out.
@Volatile private var ds4Low = 0
@Volatile private var ds4High = 0
@Volatile private var ds4Rgb = 0x000040
// Rumble backstop: a USB pad holds its level until told zero, so a stalled poll thread would
// leave the motors running — re-armed per command, cancelled by an explicit (0,0).
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var backstop: Runnable? = null
/** Fired (link thread) when the capture engages or drops — the Controllers screen's status. */
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
val isActive: Boolean get() = model != null
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
fun findUsbDevice(): UsbDevice? = usb.findDevice()
/**
* Start capturing [dev] (permission already granted). Claims the HID interface the kernel
* driver detaches and the pad's InputDevice node vanishes; its router slot (if the router
* already opened one from the pre-claim InputDevice) is released HERE, at claim time, rather
* than waiting for the system's removal callback so the freed wire index is deterministic
* for this capture's ExternalPad instead of racing the first report against the callback. A
* released sibling that still exists as an InputDevice (a same-model Bluetooth pad) lazily
* reopens a slot on its next input event, so over-matching self-heals.
*/
fun startUsb(dev: UsbDevice): Boolean {
if (model != null) return false
val m = DsDevice.modelFor(dev.productId) ?: return false
if (!usb.start(dev)) return false
model = m
for (id in InputDevice.getDeviceIds()) {
val d = InputDevice.getDevice(id) ?: continue
if (d.vendorId == dev.vendorId && d.productId == dev.productId) router.releaseDevice(id)
}
// Release the firmware's lightbar animation once so host lightbar writes take effect
// (the same init hid-playstation/SDL send on open).
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
onActiveChanged?.invoke(true)
return true
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
val m = model
if (m != null) {
// The interfaces are about to release with the kernel driver still detached — a
// mid-rumble teardown would leave the motors running with nobody to stop them.
// EP0-direct (the reader thread is stopping; the queue would never drain).
usb.writeControl(stopReport(m))
}
disarmBackstop()
usb.stop()
val wasActive = model != null
model = null
releaseSlot()
if (wasActive) onActiveChanged?.invoke(false)
}
// ---- link callbacks (link thread) ----
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: return
if (!DsDevice.parseState(m, report, len, state)) return
val p = pad ?: router.openExternal(m.pref)?.also {
pad = it
Log.i(TAG, "captured $m → wire pad ${it.index}")
} ?: return // all 16 wire indices taken — drop until one frees
mirrorTyped(p)
mirrorRich(p, m)
}
private fun onLinkClosed() {
Log.i(TAG, "Sony USB link closed (unplug)")
disarmBackstop()
val wasActive = model != null
model = null
releaseSlot()
if (wasActive) onActiveChanged?.invoke(false)
}
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
var changed = state.buttons xor wireButtons
while (changed != 0) {
val bit = changed and -changed // lowest changed bit
p.button(bit, state.buttons and bit != 0)
changed = changed and bit.inv()
}
wireButtons = state.buttons
axis(p, Gamepad.AXIS_LS_X, state.lsX)
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
axis(p, Gamepad.AXIS_RS_X, state.rsX)
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
axis(p, Gamepad.AXIS_LT, state.lt)
axis(p, Gamepad.AXIS_RT, state.rt)
}
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
if (lastAxis[id] == v) return
lastAxis[id] = v
p.axis(id, v)
}
/**
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
* on change per slot; motion forwarded every report (raw device units the wire is a unit
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
*/
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
for (f in 0 until 2) {
if (state.touchActive[f]) {
val x = (state.touchX[f].coerceIn(0, m.touchW - 1) * 65535) / (m.touchW - 1)
val y = (state.touchY[f].coerceIn(0, m.touchH - 1) * 65535) / (m.touchH - 1)
if (!lastTouchActive[f] || x != lastTouchX[f] || y != lastTouchY[f]) {
p.touch(f, true, x, y)
lastTouchActive[f] = true
lastTouchX[f] = x
lastTouchY[f] = y
}
} else if (lastTouchActive[f]) {
p.touch(f, false, lastTouchX[f], lastTouchY[f])
lastTouchActive[f] = false
}
}
p.motion(state.gyro, state.accel)
}
private fun releaseSlot() {
// Lift any still-touching finger so the host's virtual touchpad doesn't hold a contact.
val p = pad
if (p != null) {
for (f in 0 until 2) if (lastTouchActive[f]) p.touch(f, false, lastTouchX[f], lastTouchY[f])
}
p?.close()
pad = null
wireButtons = 0
lastAxis.fill(Int.MIN_VALUE)
lastTouchActive.fill(false)
lastTouchX.fill(-1)
lastTouchY.fill(-1)
}
// ---- PadFeedbackSink (feedback poll threads) ----
override fun ownsPad(pad: Int): Boolean = pad == this.pad?.index
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
val m = model ?: return
if (low == 0 && high == 0) {
disarmBackstop()
} else {
armBackstop(backstopMs)
}
if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = low
ds4High = high
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
}
}
override fun led(pad: Int, r: Int, g: Int, b: Int) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) {
ds4Rgb = (r shl 16) or (g shl 8) or b
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5LightbarReport(m, r, g, b))
}
}
override fun playerLeds(pad: Int, bits: Int) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no player LEDs on a DS4 (host never sends any)
usb.writeRaw(0, DsDevice.ds5PlayerLedsReport(m, bits))
}
override fun trigger(pad: Int, which: Int, effect: ByteArray) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no adaptive triggers on a DS4
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
}
private fun writeDs4() = usb.writeRaw(
0,
DsDevice.ds4Report(
ds4Low,
ds4High,
(ds4Rgb shr 16) and 0xFF,
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
),
)
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
* composed motor state and carries the current lightbar rather than blacking it out. */
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = 0
ds4High = 0
DsDevice.ds4Report(
0,
0,
(ds4Rgb shr 16) and 0xFF,
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
)
} else {
DsDevice.ds5RumbleReport(m, 0, 0)
}
/** (Re)arm the stalled-poll-thread net: write a rumble stop at the command's backstop. */
private fun armBackstop(ms: Long) {
backstop?.let { mainHandler.removeCallbacks(it) }
val r = Runnable {
backstop = null
model?.let { usb.writeRaw(0, stopReport(it)) }
}
backstop = r
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
}
private fun disarmBackstop() {
backstop?.let { mainHandler.removeCallbacks(it) }
backstop = null
}
private companion object {
const val TAG = "DsCapture"
}
}
@@ -1,340 +0,0 @@
package io.unom.punktfunk.kit
/**
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
* as-is passthrough, nothing rides the wire raw here the host's DualSense/DS4 backends consume
* only typed events (`dualsense_proto.rs` discards `RichInput::HidReport`), so the client parses
* the pad's input reports into the ordinary button/axis wire + the rich touch/motion plane, and
* renders the host's feedback (rumble / adaptive triggers / lightbar / player LEDs) by composing
* USB output reports itself.
*
* Protocol ground truth: the Linux kernel's `hid-playstation` / `hid-sony` structs, SDL's
* `SDL_hidapi_ps5.c` / `SDL_hidapi_ps4.c`, mirrored host-side in `punktfunk-host`'s
* `dualsense_proto.rs` / `dualshock4_proto.rs` this file is the byte-exact inverse of those
* serializers (offsets cross-referenced below). USB only: over Bluetooth the reports shift
* (`0x31` + CRC32) AND Android exposes no raw path to a Classic pad anyway, so the BT case never
* reaches this code an uncaptured pad stays on the ordinary InputDevice path.
*/
object DsDevice {
const val VID_SONY = 0x054C
const val PID_DUALSENSE = 0x0CE6
const val PID_DUALSENSE_EDGE = 0x0DF2
const val PID_DUALSHOCK4_V1 = 0x05C4
const val PID_DUALSHOCK4_V2 = 0x09CC
val USB_PIDS = setOf(PID_DUALSENSE, PID_DUALSENSE_EDGE, PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2)
/**
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds matching
* the physical one), its output-report size (the descriptor-declared size the firmware
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
* onto the wire's 0..65535 space.
*/
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
}
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
fun modelFor(pid: Int): Model? = when (pid) {
PID_DUALSENSE -> Model.DUALSENSE
PID_DUALSENSE_EDGE -> Model.DUALSENSE_EDGE
PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2 -> Model.DUALSHOCK4
else -> null
}
/**
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
* (`Gamepad.BTN_*`) the parse maps device bits straight to the wire, the exact inverse of
* the host's `DsState::from_gamepad` (BTN_A cross, BTN_B circle, BTN_X square,
* BTN_Y triangle; positional, not glyph-order). Gyro/accel stay in raw device units the
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
*/
class State {
var buttons = 0
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
var rsX = 0; var rsY = 0
var lt = 0; var rt = 0 // 0..255
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
val accel = IntArray(3)
val touchActive = BooleanArray(2)
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
val touchY = IntArray(2)
}
// DS5 USB input report 0x01 (64 B) — offsets mirror the host serializer
// (`dualsense_proto.rs::serialize_state`): [1..7) sticks + triggers, [8] hat|face,
// [9]/[10] buttons, [16..28) gyro+accel, [33..41) two 4-byte touch points.
private const val DS5_INPUT_ID = 0x01
// report[8] high nibble (`dualsense_proto::btn0`).
private const val DS5_SQUARE = 0x10
private const val DS5_CROSS = 0x20
private const val DS5_CIRCLE = 0x40
private const val DS5_TRIANGLE = 0x80
// report[9] (`btn1`).
private const val DS5_L1 = 0x01
private const val DS5_R1 = 0x02
private const val DS5_CREATE = 0x10
private const val DS5_OPTIONS = 0x20
private const val DS5_L3 = 0x40
private const val DS5_R3 = 0x80
// report[10] (`btn2`); the FN/BACK bits exist only on the Edge.
private const val DS5_PS = 0x01
private const val DS5_TOUCHPAD = 0x02
private const val DS5_MUTE = 0x04
private const val EDGE_FN_LEFT = 0x10
private const val EDGE_FN_RIGHT = 0x20
private const val EDGE_BACK_LEFT = 0x40
private const val EDGE_BACK_RIGHT = 0x80
// DS4 USB input report 0x01 (64 B) — offsets mirror `dualshock4_proto.rs::serialize_state`:
// [1..5) sticks, [5] hat|face, [6]/[7] buttons, [8]/[9] triggers, [13..25) gyro+accel,
// [35..43) two touch points (same 4-byte packing as the DS5).
private const val DS4_L1 = 0x01
private const val DS4_R1 = 0x02
private const val DS4_SHARE = 0x10
private const val DS4_OPTIONS = 0x20
private const val DS4_L3 = 0x40
private const val DS4_R3 = 0x80
private const val DS4_PS = 0x01
private const val DS4_TOUCHPAD = 0x02
/**
* Parse one USB input report (`0x01`) into [out]. Returns false for any other report id or a
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 those never hit
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
* is long enough to carry them (it always is on glass 64-byte interrupt transfers).
*/
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
if (model == Model.DUALSHOCK4) {
parseDs4(report, len, out)
} else {
parseDs5(model, report, len, out)
}
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
out.rsX = stickX(u8(r, 3))
out.rsY = stickY(u8(r, 4))
out.lt = u8(r, 5)
out.rt = u8(r, 6)
val b8 = u8(r, 8)
val b9 = u8(r, 9)
val b10 = u8(r, 10)
var w = hatBits(b8 and 0x0F)
if (b8 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
if (b8 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
if (b8 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
if (b8 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
if (b9 and DS5_L1 != 0) w = w or Gamepad.BTN_LB
if (b9 and DS5_R1 != 0) w = w or Gamepad.BTN_RB
// L2/R2 digital bits ride the analog axes instead (wire convention).
if (b9 and DS5_CREATE != 0) w = w or Gamepad.BTN_BACK
if (b9 and DS5_OPTIONS != 0) w = w or Gamepad.BTN_START
if (b9 and DS5_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
if (b9 and DS5_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
if (b10 and DS5_PS != 0) w = w or Gamepad.BTN_GUIDE
if (b10 and DS5_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
if (b10 and DS5_MUTE != 0) w = w or Gamepad.BTN_MISC1
if (model == Model.DUALSENSE_EDGE) {
// Wire paddle order matches the host's `edge_paddle_bits` inverse: PADDLE1/2 =
// right/left BACK (the primary pair, Steam R4/L4 convention), PADDLE3/4 = right/left Fn.
if (b10 and EDGE_BACK_RIGHT != 0) w = w or Gamepad.BTN_PADDLE1
if (b10 and EDGE_BACK_LEFT != 0) w = w or Gamepad.BTN_PADDLE2
if (b10 and EDGE_FN_RIGHT != 0) w = w or Gamepad.BTN_PADDLE3
if (b10 and EDGE_FN_LEFT != 0) w = w or Gamepad.BTN_PADDLE4
}
out.buttons = w
if (len >= 28) {
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
}
if (len >= 41) {
unpackTouch(r, 33, out, 0)
unpackTouch(r, 37, out, 1)
}
return true
}
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
out.rsX = stickX(u8(r, 3))
out.rsY = stickY(u8(r, 4))
val b5 = u8(r, 5)
val b6 = u8(r, 6)
val b7 = u8(r, 7)
out.lt = u8(r, 8)
out.rt = u8(r, 9)
var w = hatBits(b5 and 0x0F)
if (b5 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
if (b5 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
if (b5 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
if (b5 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
if (b6 and DS4_L1 != 0) w = w or Gamepad.BTN_LB
if (b6 and DS4_R1 != 0) w = w or Gamepad.BTN_RB
if (b6 and DS4_SHARE != 0) w = w or Gamepad.BTN_BACK
if (b6 and DS4_OPTIONS != 0) w = w or Gamepad.BTN_START
if (b6 and DS4_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
if (b6 and DS4_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
if (b7 and DS4_PS != 0) w = w or Gamepad.BTN_GUIDE
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
out.buttons = w
if (len >= 25) {
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
}
if (len >= 43) {
unpackTouch(r, 35, out, 0)
unpackTouch(r, 39, out, 1)
}
return true
}
/** hat nibble (0=N … 7=NW, 8+=neutral) → wire dpad bits — inverse of the host's `hat()`. */
private fun hatBits(h: Int): Int = when (h) {
0 -> Gamepad.BTN_DPAD_UP
1 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT
2 -> Gamepad.BTN_DPAD_RIGHT
3 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_RIGHT
4 -> Gamepad.BTN_DPAD_DOWN
5 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_LEFT
6 -> Gamepad.BTN_DPAD_LEFT
7 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_LEFT
else -> 0
}
/**
* One 4-byte touch point (shared DS5/DS4 packing `dualsense_proto::pack_touch`): byte0
* bit7 = NOT active + contact id in bits 0..6; 12-bit x/y split across bytes 1..3.
*/
private fun unpackTouch(r: ByteArray, o: Int, out: State, slot: Int) {
val b0 = u8(r, o)
out.touchActive[slot] = b0 and 0x80 == 0
out.touchX[slot] = u8(r, o + 1) or ((u8(r, o + 2) and 0x0F) shl 8)
out.touchY[slot] = (u8(r, o + 2) shr 4) or (u8(r, o + 3) shl 4)
}
private fun u8(r: ByteArray, o: Int): Int = r[o].toInt() and 0xFF
private fun i16(r: ByteArray, o: Int): Int =
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
private fun stickX(raw: Int): Int = raw * 257 - 32768
private fun stickY(raw: Int): Int = (255 - raw) * 257 - 32768
// ---- Output reports ----
//
// Every write is valid-flag-selective: only the flagged channel applies, the firmware keeps
// the rest (the same contract the host's `parse_ds_output` mirrors — an unflagged parse would
// turn every rumble into a lightbar-off). The DS4 is the exception: its builder writes the
// full composed motors+LED state each time with both flags, SDL's proven-on-hardware shape.
// DS5 output report 0x02, report-relative offsets (`dualsense_proto::parse_ds_output`):
// [1] valid_flag0 (bit0 compat vibration, bit1 haptics select, bit2 R2 block, bit3 L2 block),
// [2] valid_flag1 (bit2 lightbar, bit4 player LEDs), [3]/[4] motors, [11..22) R2 effect,
// [22..33) L2 effect, [39] valid_flag2 (bit1 lightbar-setup enable, bit2 vibration2),
// [42] lightbar_setup, [44] player LEDs, [45..48) RGB.
private const val DS5_FLAG0_COMPAT_VIBRATION = 0x01
private const val DS5_FLAG0_HAPTICS_SELECT = 0x02
private const val DS5_FLAG0_R2_EFFECT = 0x04
private const val DS5_FLAG0_L2_EFFECT = 0x08
private const val DS5_FLAG1_LIGHTBAR = 0x04
private const val DS5_FLAG1_PLAYER_LEDS = 0x10
private const val DS5_FLAG2_LIGHTBAR_SETUP = 0x02
private const val DS5_FLAG2_VIBRATION2 = 0x04
private const val DS5_LIGHTBAR_SETUP_LIGHT_OUT = 0x02
/** The 11-byte adaptive-trigger effect block length (mode byte + 10 parameters). */
const val TRIGGER_EFFECT_LEN = 11
private fun newDs5(model: Model): ByteArray = ByteArray(model.outputSize).also { it[0] = 0x02 }
/**
* One-time capture-start report (DS5/Edge): release the firmware's lightbar animation
* (`LIGHTBAR_SETUP_LIGHT_OUT`) so subsequent host lightbar writes take effect the same
* init both hid-playstation and SDL send on open. No-op fields otherwise.
*/
fun ds5InitReport(model: Model): ByteArray = newDs5(model).also {
it[39] = DS5_FLAG2_LIGHTBAR_SETUP.toByte()
it[42] = DS5_LIGHTBAR_SETUP_LIGHT_OUT.toByte()
}
/**
* DS5/Edge rumble at the wire's u16 amplitudes ([low] = heavy/left motor, [high] =
* light/right the host parses `[3]` as high and `[4]` as low, mirrored here). Flags both
* the classic compat-vibration path AND `VIBRATION2` (firmware 2.24's full-range replot;
* older firmware ignores the unknown flag2 bit) the host parser accepts either.
*/
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
it[3] = amp8(high).toByte()
it[4] = amp8(low).toByte()
}
/**
* DS5/Edge adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] is the raw 11-byte
* trigger block from the wire (`HidOutput::Trigger` the game's bytes verbatim), copied to
* the same offsets the host parsed it from ([11..22) R2 / [22..33) L2).
*/
fun ds5TriggerReport(model: Model, which: Int, effect: ByteArray): ByteArray = newDs5(model).also {
val at = if (which == 1) 11 else 22
it[1] = (if (which == 1) DS5_FLAG0_R2_EFFECT else DS5_FLAG0_L2_EFFECT).toByte()
val n = effect.size.coerceAtMost(TRIGGER_EFFECT_LEN)
System.arraycopy(effect, 0, it, at, n)
}
/** DS5/Edge lightbar RGB. */
fun ds5LightbarReport(model: Model, r: Int, g: Int, b: Int): ByteArray = newDs5(model).also {
it[2] = DS5_FLAG1_LIGHTBAR.toByte()
it[45] = r.toByte()
it[46] = g.toByte()
it[47] = b.toByte()
}
/** DS5/Edge player-indicator LEDs (low 5 bits, hid-playstation pattern). */
fun ds5PlayerLedsReport(model: Model, bits: Int): ByteArray = newDs5(model).also {
it[2] = DS5_FLAG1_PLAYER_LEDS.toByte()
it[44] = (bits and 0x1F).toByte()
}
// DS4 output report 0x05 (32 B), report-relative (`dualshock4_proto::parse_ds4_output`):
// [1] valid_flag0 (bit0 motors, bit1 LED, bit2 blink), [4] weak/right motor, [5] strong/left,
// [6..9) RGB, [9]/[10] blink on/off.
private const val DS4_FLAG0_MOTORS = 0x01
private const val DS4_FLAG0_LED = 0x02
/**
* One full-state DS4 write: motors + lightbar together, both flags set the composed-state
* shape SDL uses against real hardware (per-channel selective writes are unproven on DS4
* firmware, unlike the DS5's). [DsCapture] holds the composition. Blink stays untouched.
*/
fun ds4Report(low: Int, high: Int, r: Int, g: Int, b: Int): ByteArray =
ByteArray(Model.DUALSHOCK4.outputSize).also {
it[0] = 0x05
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
it[4] = amp8(high).toByte()
it[5] = amp8(low).toByte()
it[6] = r.toByte()
it[7] = g.toByte()
it[8] = b.toByte()
}
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
// vibrator path's toAmplitude).
private fun amp8(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
}
@@ -36,16 +36,6 @@ object Gamepad {
const val BTN_X = 0x4000
const val BTN_Y = 0x8000
// Extended bits (Moonlight `buttonFlags2 << 16` namespace — `input.rs::gamepad`): the four
// back grips (Steam L4/L5/R4/R5 ≙ Elite P1P4), touchpad click, and the misc/QAM button.
// Android's standard InputDevice path never produces these; the SC2 capture link does.
const val BTN_PADDLE1 = 0x10000
const val BTN_PADDLE2 = 0x20000
const val BTN_PADDLE3 = 0x40000
const val BTN_PADDLE4 = 0x80000
const val BTN_TOUCHPAD = 0x100000
const val BTN_MISC1 = 0x200000
// Axis ids — must equal `input.rs::gamepad::AXIS_*`.
const val AXIS_LS_X = 0
const val AXIS_LS_Y = 1
@@ -64,8 +54,6 @@ object Gamepad {
const val PREF_STEAMDECK = 6
const val PREF_DUALSENSEEDGE = 7
const val PREF_SWITCHPRO = 8
const val PREF_STEAMCONTROLLER2 = 9
const val PREF_STEAMCONTROLLER2_PUCK = 10
// USB vendor ids of the controllers we can identify by VID/PID.
private const val VID_SONY = 0x054C
@@ -93,12 +81,6 @@ object Gamepad {
private val PID_STEAMDECK = setOf(0x1205)
private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142)
// Steam Controller 2: wired (0x1302), BLE (0x1303), and Puck dongles (0x1304/0x1305).
// Sc2Capture normally claims these directly; the plain InputDevice path is only a degraded
// fallback. Keep Puck distinct so even that path requests the native multi-interface identity.
private val PID_STEAMCONTROLLER2 = setOf(0x1302, 0x1303)
private val PID_STEAMCONTROLLER2_PUCK = setOf(0x1304, 0x1305)
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
private val PID_XBOXONE = setOf(
@@ -125,9 +107,6 @@ object Gamepad {
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2_PUCK ->
PREF_STEAMCONTROLLER2_PUCK
vid == VID_VALVE && pid in PID_STEAMCONTROLLER2 -> PREF_STEAMCONTROLLER2
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
else -> PREF_XBOX360
}
@@ -1,6 +1,5 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.graphics.Color
import android.hardware.lights.Light
import android.hardware.lights.LightState
@@ -34,60 +33,16 @@ import java.nio.ByteBuffer
*
* With no controller connected (emulator) rumble/lights become logged no-ops exactly the
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
*
* [deviceVibrator] is the opt-in phone mirror ("Rumble on this phone", off by default): when
* non-null, rumble the host addresses to wire pad 0 (controller 1) is ALSO played on this
* device's own vibration motor for clip-on gamepads that ship without rumble motors, where the
* phone body is the only actuator in the player's hands. StreamScreen passes it only when the
* setting is on (see [deviceBodyVibrator]).
*/
class GamepadFeedback(
private val handle: Long,
private val router: GamepadRouter?,
private val deviceVibrator: Vibrator? = null,
) {
/**
* A capture link's feedback renderer for the wire pads it owns, consulted BEFORE the
* InputDevice vibrator/lights paths. A captured controller has no [android.view.InputDevice]
* (its slot is an [GamepadRouter.ExternalPad] on a synthetic id, so [GamepadRouter.deviceForPad]
* resolves null and the platform paths no-op) the link renders instead, by composing USB
* output reports on the physical pad. This is also the ONLY route to adaptive triggers:
* Android has no platform API for them, so without a sink a Trigger event is log-and-drop.
* Invoked on the feedback poll threads; implementations must be thread-safe.
*/
interface PadFeedbackSink {
/** True when this sink renders feedback for wire pad [pad]; the render methods are only
* invoked while true. Racing a pad close is fine a late render is a harmless no-op. */
fun ownsPad(pad: Int): Boolean
/** One effective rumble command (`(0,0)` = stop now; else a one-shot at this level with
* [backstopMs] as the self-termination net see [GamepadFeedback.renderRumble]). */
fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long)
/** Lightbar RGB. */
fun led(pad: Int, r: Int, g: Int, b: Int)
/** Player-indicator LED bitmask (low 5 bits, hid-playstation layout). */
fun playerLeds(pad: Int, bits: Int)
/** One adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] = the raw DS5 trigger
* block (mode byte + parameters) exactly as the game wrote it host-side. */
fun trigger(pad: Int, which: Int, effect: ByteArray)
}
/**
* The active capture link's sink (a [DsCapture]), or null. Wired by StreamScreen alongside
* [onHidRaw]; cleared before the poll threads stop.
*/
@Volatile
var sink: PadFeedbackSink? = null
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) {
private companion object {
const val TAG = "pf.feedback"
const val TAG_LED: Byte = 0x01
const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03
const val TAG_HID_RAW: Byte = 0x05
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
// A new host renews far below this, so it never actually holds this long there.
const val LEGACY_RUMBLE_MS = 60_000L
}
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 2830). */
@@ -128,26 +83,25 @@ class GamepadFeedback(
while (running) {
val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
// rumble policy engine — it owns every lease/staleness/close decision (uniform
// across all clients; the old 60 s legacy-host exposure is gone) and emits
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
// the backstop (the hardware net under a stalled poll thread).
// ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
// long one-shot.
val pad = ((ev ushr 49) and 0xFL).toInt()
val backstopMs = ((ev ushr 32) and 0xFFFF)
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
renderRumble(
pad,
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
backstopMs,
durationMs,
)
}
}, "pf-rumble").apply { isDaemon = true; start() }
hidoutThread = Thread({
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
val buf = ByteBuffer.allocateDirect(128)
val buf = ByteBuffer.allocateDirect(64)
while (running) {
val n = NativeBridge.nativeNextHidout(handle, buf)
if (n < 0) continue // timeout / closed
@@ -173,9 +127,7 @@ class GamepadFeedback(
runCatching { hidoutThread?.join() }
rumbleThread = null
hidoutThread = null
// Threads are dead — drop any held rumble (incl. the phone mirror's) and close every
// lights session.
runCatching { deviceVibrator?.cancel() }
// Threads are dead — drop any held rumble and close every lights session.
synchronized(bindsLock) {
for (b in rumbleBinds.values) b?.let {
runCatching { it.vm?.cancel() }
@@ -245,24 +197,12 @@ class GamepadFeedback(
/**
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
* addressed to wire pad [pad]. `durationMs` is the engine command's backstop the one-shot's
* self-termination net under a stalled poll thread; the engine emits explicit zero commands at
* every policy stop (lease expiry, legacy staleness, session close), so cancel-on-zero is the
* real stop mechanism.
* addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL the one-shot self-
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
*/
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
Log.i(TAG, "rumble pad=$pad low=$low high=$high backstopMs=$durationMs") // verification line — BEFORE any no-op return
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
// serves have no vibrator of their own, so their bind below is null. It follows
// controller 1 unconditionally rather than only motor-less pads — capability probing
// already decided the bind, and the user opted in.
if (pad == 0) renderDeviceRumble(low, high, durationMs)
// A captured pad's link renders on the physical controller itself (its slot has no
// InputDevice, so the vibrator bind below would resolve null and drop the command).
sink?.takeIf { it.ownsPad(pad) }?.let {
it.rumble(pad, low, high, durationMs)
return
}
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
@@ -306,29 +246,6 @@ class GamepadFeedback(
}
}
/**
* The opt-in phone mirror: play a wire-pad-0 rumble on this device's own vibration motor
* one physical actuator, so both wire motors blend into one effect (the same blend as the
* single-motor controller path). Same envelope semantics too: a one-shot held for the host's
* TTL, cancel on (0,0).
*/
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
val v = deviceVibrator ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
if (lo == 0 && hi == 0) {
runCatching { v.cancel() } // (0,0) = stop
return
}
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
runCatching {
v.vibrate(
if (v.hasAmplitudeControl()) oneShot(a, durationMs)
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
)
}
}
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
private fun toAmplitude(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
@@ -355,63 +272,28 @@ class GamepadFeedback(
val g = buf.get().toInt() and 0xFF
val b = buf.get().toInt() and 0xFF
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null) s.led(pad, r, g, b)
else if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
}
TAG_PLAYER_LEDS -> {
val bits = buf.get().toInt() and 0x1F
val player = playerIndexForBits(bits)
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null) s.playerLeds(pad, bits)
else if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
}
TAG_TRIGGER -> {
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
val effLen = n - 3 // [pad][kind][which] header, then the effect block
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null && effLen > 0) {
// A captured DualSense: the raw trigger block replays onto the physical pad.
val effect = ByteArray(effLen)
buf.get(effect)
Log.i(TAG, "hidout pad=$pad Trigger which=$which effLen=$effLen → captured pad") // verification line
s.trigger(pad, which, effect)
} else {
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No platform adaptive-trigger API — parse-validate the mode + log only.
Log.i(
TAG,
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (no adaptive-trigger renderer for this pad)".format(mode),
)
}
}
TAG_HID_RAW -> {
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
// [kind: 0=output, 1=feature][report bytes, id first]. Handed to the capture link
// for verbatim replay on the physical controller; dropped when no link owns the pad.
val kind = buf.get().toInt() and 0xFF
val len = n - 3
if (len > 0) {
val data = ByteArray(len)
buf.get(data)
onHidRaw?.invoke(pad, kind, data)
}
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
Log.i(
TAG,
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
)
}
else -> Log.d(TAG, "hidout: unknown kind, dropped")
}
}
/**
* Raw HID-report replay hook for the as-is Steam Controller 2 passthrough: invoked (on the
* hidout poll thread) with the wire pad index, the report kind (0 = output report, 1 =
* feature report), and the full report bytes (id first) the host's hidraw consumer wrote.
* `StreamScreen` wires this to the SC2 capture so Steam's rumble/settings land on the
* physical controller.
*/
@Volatile
var onHidRaw: ((pad: Int, kind: Int, data: ByteArray) -> Unit)? = null
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
0b00000 -> 0
@@ -467,18 +349,3 @@ class GamepadFeedback(
}
}
}
/**
* This device's own body vibrator (the phone, not a controller), or null where there is none
* (TVs) gates the "Rumble on this phone" setting's visibility and feeds
* [GamepadFeedback.deviceVibrator] when it's on.
*/
fun deviceBodyVibrator(context: Context): Vibrator? {
val v = if (Build.VERSION.SDK_INT >= 31) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
return v?.takeIf { it.hasVibrator() }
}
@@ -57,14 +57,6 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/
var onExitChord: (() -> Unit)? = null
/**
* Invoked (main thread) with `true` the moment the exit chord completes and the hold countdown
* starts, and `false` when it's cancelled (a button lifted early) or the timer elapses. `StreamScreen`
* wires this to a "hold to quit" hint so the hold is discoverable the chord no longer quits on a
* quick press, and without an on-screen cue that reads as the shortcut being broken.
*/
var onExitArmed: ((armed: Boolean) -> Unit)? = null
private val mainHandler = Handler(Looper.getMainLooper())
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
private var pendingExit: Runnable? = null
@@ -92,37 +84,28 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly
* ([onExitArmed] fires so the UI can show a "hold to quit" hint); [onExitChord] fires only if the
* chord is still held at expiry (a brief accidental brush is ignored), matching `DISCONNECT_HOLD`
* on the SDL/Apple clients. Any controller can leave.
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly;
* [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is
* ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave.
*/
fun onButton(event: KeyEvent, bit: Int) {
val slot = slotFor(event.device) ?: return
when (event.action) {
// repeatCount guard: don't re-send a held button as auto-repeat.
KeyEvent.ACTION_DOWN -> slotButton(slot, bit, down = true, send = event.repeatCount == 0)
KeyEvent.ACTION_UP -> slotButton(slot, bit, down = false, send = true)
}
}
/**
* One button transition on [slot] the shared body behind [onButton] and an [ExternalPad]'s
* transitions: forward the wire event, track held state, and arm/disarm the exit chord.
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
if (down) {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
} else {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv()
// A chord button lifted before the hold elapsed → cancel, unless another pad still
// holds the full chord.
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
disarmExit()
KeyEvent.ACTION_DOWN -> {
// repeatCount guard: don't re-send a held button as auto-repeat.
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
}
KeyEvent.ACTION_UP -> {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv()
// A chord button lifted before the hold elapsed → cancel, unless another pad still
// holds the full chord.
if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) {
disarmExit()
}
}
}
}
@@ -132,7 +115,6 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
if (pendingExit != null) return // already counting down
val r = Runnable {
pendingExit = null
onExitArmed?.invoke(false) // countdown over — drop the hint whether or not we leave
// Fire only if the chord survived the full hold on some pad.
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
if (held.isNotEmpty()) {
@@ -144,15 +126,12 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
}
pendingExit = r
mainHandler.postDelayed(r, EXIT_HOLD_MS)
onExitArmed?.invoke(true) // chord complete → show the "hold to quit" hint
}
/** Cancel a pending exit-chord hold timer. */
private fun disarmExit() {
val wasArmed = pendingExit != null
pendingExit?.let { mainHandler.removeCallbacks(it) }
pendingExit = null
if (wasArmed) onExitArmed?.invoke(false) // released early — drop the hint
}
/**
@@ -173,9 +152,8 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/**
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
* holds no live slot (a pad that just unplugged the update is then dropped) OR the slot is
* an [ExternalPad] (its synthetic id resolves to no InputDevice, so rumble binds naturally
* fall through to the capture link's own feedback path). Read from the feedback poll threads.
* holds no live slot (a pad that just unplugged the update is then dropped). Read from the
* feedback poll threads.
*/
fun deviceForPad(pad: Int): InputDevice? {
for ((deviceId, slot) in slots) {
@@ -184,78 +162,6 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
return null
}
/**
* A capture-link pad occupying a wire slot without an Android [InputDevice] the as-is Steam
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
* the real slots' lifecycle: a stable lowest-free index, Arrival-before-input, held-state
* flush + Remove on [close], and full participation in the emergency exit chord.
*/
inner class ExternalPad internal constructor(private val syntheticId: Int, val index: Int) {
// Live lookup instead of a captured reference: after [close] (or a router release) the
// slot is gone from the table and every entry point below degrades to a safe no-op.
private val slot get() = slots[syntheticId]
/** One button transition (a wire [Gamepad].BTN_* bit). On-change only — the caller diffs. */
fun button(bit: Int, down: Boolean) {
slot?.let { slotButton(it, bit, down, send = true) }
}
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
fun axis(id: Int, value: Int) {
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
}
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
}
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
}
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
* units the host passes them straight into the virtual pad's report). Per report. */
fun motion(gyro: IntArray, accel: IntArray) {
if (slot != null) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
accel[0], accel[1], accel[2],
)
}
}
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
fun close() = closeSlot(syntheticId)
}
/**
* Open a slot for a capture-link pad, declaring [pref] as its kind; null when all 16 wire
* indices are taken. Main thread (like the hot-plug callbacks).
*/
fun openExternal(pref: Int): ExternalPad? {
val index = lowestFreeIndex() ?: return null
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
val syntheticId = EXTERNAL_ID_BASE - index
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
return ExternalPad(syntheticId, index)
}
/**
* Close the slot (if any) for a physical controller a capture link just claimed. The claim
* detaches the kernel driver, so the system's own removal callback would close it moments
* later anyway doing it at claim time makes the freed wire index deterministic for the
* link's [ExternalPad] instead of racing the link's first report against that callback. Safe
* to over-match (a same-VID/PID sibling that still exists as an InputDevice lazily reopens a
* slot on its next input event). Main thread, like the hot-plug callbacks.
*/
fun releaseDevice(deviceId: Int) = closeSlot(deviceId)
/**
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
* the feedback poll threads are joined (they read [deviceForPad]).
@@ -344,14 +250,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
/**
* How long the exit chord must be held before the stream leaves long enough that an
* accidental brush of the four buttons doesn't quit, short enough to feel responsive (the
* on-screen hint covers the gap). Roughly matches SDL/Apple `DISCONNECT_HOLD`.
*/
const val EXIT_HOLD_MS = 1000L
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */
const val EXIT_HOLD_MS = 1500L
}
}
@@ -1,399 +0,0 @@
package io.unom.punktfunk.kit
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
/**
* Generic USB transport for a client-captured HID controller the device-agnostic half of what
* [Sc2UsbLink] pioneered, now shared with the Sony capture ([DsCapture]). Claims the controller
* interface(s) `force = true` detaches the kernel/OS driver, so a captured pad can't
* double-drive the ordinary InputDevice path runs a multiplexed [UsbRequest] read loop, and
* writes the host/capture's reports back to the device (interrupt-OUT when the interface has one,
* else EP0 `SET_REPORT`).
*
* Everything device-specific is [Config]: which attached device to pick, which of its interfaces
* to claim, and an optional keep-alive (feature reports re-sent on a firmware-watchdog cadence
* the SC2's lizard-mode refresh; a DualSense needs none).
*
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
* (an SC2 on-glass round tripped exactly this a 5 s silence heuristic firing on an idle pad).
* The real signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
*/
class HidUsbLink(
private val context: Context,
private val config: Config,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
) {
/**
* The per-device knowledge this transport is parameterized by. [ifaceFilter] narrows WHICH
* HID/vendor-class interfaces get claimed (the class check itself is built in) e.g. the SC2
* Puck's controller slots, or the DualSense's single HID interface among its audio siblings.
* [keepAliveFeatures] are full feature reports (id byte first) re-sent to the streaming
* interface every [keepAliveMs] AND once at claim time; empty = no keep-alive.
*/
class Config(
val tag: String,
val threadName: String,
val deviceMatch: (UsbDevice) -> Boolean,
val ifaceFilter: (UsbDevice, UsbInterface) -> Boolean = { _, _ -> true },
val keepAliveFeatures: List<ByteArray> = emptyList(),
val keepAliveMs: Long = 0,
)
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
/** One claimed interface: its endpoints + the read state the reader thread owns. */
private class Claim(
val iface: UsbInterface,
val epIn: UsbEndpoint,
val epOut: UsbEndpoint?,
) {
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
var inReq: UsbRequest? = null
var outReq: UsbRequest? = null
var outBusy = false
var reports = 0L
}
private var connection: UsbDeviceConnection? = null
private var device: UsbDevice? = null
private var claims: List<Claim> = emptyList()
/** The claim whose IN endpoint last produced data where output/feature writes go.
* Written by the reader thread, read by the feedback thread (feature control transfers). */
@Volatile private var activeClaim: Claim? = null
/** Pending OUT reports, submitted by the reader thread only one thread may drive a
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
* request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
*/
fun start(dev: UsbDevice): Boolean {
if (!usb.hasPermission(dev)) {
Log.e(config.tag, "no USB permission for ${dev.deviceName}")
return false
}
val conn = usb.openDevice(dev) ?: run {
Log.e(config.tag, "openDevice failed for ${dev.deviceName}")
return false
}
val claimed = claimControllerInterfaces(dev, conn)
if (claimed.isEmpty()) {
Log.e(config.tag, "no claimable interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
conn.close()
return false
}
connection = conn
device = dev
claims = claimed
running = true
Log.i(
config.tag,
"USB link up: PID=0x%04x ifaces=%s".format(
dev.productId,
claimed.joinToString {
"%d(in=0x%02x out=%s)".format(
it.iface.id, it.epIn.address,
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
)
},
),
)
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(config.tag, "USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
}
}
}
detachReceiver = receiver
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
if (config.keepAliveFeatures.isNotEmpty()) {
claimed.forEach { sendKeepAlive(conn, it.iface.id) }
}
reader = Thread({ readLoop(conn, claimed) }, config.threadName).apply {
isDaemon = true
start()
}
return true
}
/**
* Claim every candidate controller interface: HID (or vendor-class) interfaces that pass the
* config's [Config.ifaceFilter], with an INT/BULK IN endpoint (OUT optional the fallback is
* EP0 `SET_REPORT`). `force = true` detaches the kernel/OS driver, so the pad also vanishes
* from Android's own input stack while captured.
*/
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
val out = mutableListOf<Claim>()
for (i in 0 until dev.interfaceCount) {
val iface = dev.getInterface(i)
if (!config.ifaceFilter(dev, iface)) continue
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
iface.interfaceClass == 0xFF
if (!hidOrVendor) continue
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (e in 0 until iface.endpointCount) {
val ep = iface.getEndpoint(e)
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
if (!usable) continue
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
}
if (inEp == null) continue
if (conn.claimInterface(iface, true)) {
out.add(Claim(iface, inEp, outEp))
} else {
Log.w(config.tag, "could not claim iface ${iface.id}")
}
}
return out
}
/**
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
*/
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
val live = claims.filter { c ->
val req = UsbRequest()
if (!req.initialize(conn, c.epIn)) {
Log.w(config.tag, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
return@filter false
}
req.clientData = c
c.inReq = req
c.epOut?.let { ep ->
val o = UsbRequest()
if (o.initialize(conn, ep)) {
o.clientData = c
c.outReq = o
} else {
Log.w(config.tag, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
}
}
c.inBuf.clear()
req.queue(c.inBuf)
}
if (live.isEmpty()) {
Log.e(config.tag, "no IN request could be queued")
finishReader(claims)
return
}
val scratch = ByteArray(64)
var lastKeepAlive = android.os.SystemClock.elapsedRealtime()
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
try {
while (running) {
val now = android.os.SystemClock.elapsedRealtime()
if (config.keepAliveFeatures.isNotEmpty() && config.keepAliveMs > 0 &&
now - lastKeepAlive >= config.keepAliveMs
) {
// Refresh the firmware settings on the streaming interface (else every live
// one, before a streaming interface is known) — replaying also repairs state
// some other consumer changed after capture started.
val target = activeClaim
if (target != null) sendKeepAlive(conn, target.iface.id)
else live.forEach { sendKeepAlive(conn, it.iface.id) }
lastKeepAlive = now
}
// Submit the next pending OUT report on the active (else first) interface.
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
if (outTarget != null) {
outQueue.poll()?.let { data ->
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
}
}
val done = try {
conn.requestWait(READ_TIMEOUT_MS)
} catch (_: TimeoutException) {
// A quiet controller is NOT an unplug — keep listening indefinitely; the
// detach broadcast is the real signal.
errorsSince = 0L
continue
}
if (done == null) {
// Hard error. On a real unplug these storm continuously (the detach
// broadcast usually beats us to it); tolerate transient ones.
if (errorsSince == 0L) errorsSince = now
if (now - errorsSince >= ERROR_UNPLUG_MS) {
Log.i(config.tag, "USB request errors persisting ${now - errorsSince} ms — treating as unplug")
break
}
continue
}
errorsSince = 0L
val claim = done.clientData as? Claim ?: continue
if (done === claim.inReq) {
val n = claim.inBuf.position()
if (n > 0) {
claim.inBuf.flip()
claim.inBuf.get(scratch, 0, n)
if (claim.reports++ == 0L) {
Log.i(
config.tag,
"first report on iface %d: id=0x%02x len=%d".format(
claim.iface.id, scratch[0].toInt() and 0xFF, n,
),
)
}
activeClaim = claim
onReport(scratch, n)
}
claim.inBuf.clear()
if (!claim.inReq!!.queue(claim.inBuf)) {
Log.i(config.tag, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
break
}
} else if (done === claim.outReq) {
claim.outBusy = false
}
}
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
}
private fun finishReader(claims: List<Claim>) {
for (c in claims) {
runCatching { c.inReq?.cancel(); c.inReq?.close() }
runCatching { c.outReq?.cancel(); c.outReq?.close() }
c.inReq = null
c.outReq = null
}
}
/**
* Write one raw report to the device: kind 0 = output report (the active interface's
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the sender re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
}
}
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
}
/**
* Write one output report EP0-direct (`SET_REPORT(Output)`), bypassing the interrupt-OUT
* queue for a teardown write that must land while the reader thread is stopping and the
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
* thread: EP0 control transfers are independent of the reader's `requestWait`.
*/
fun writeControl(data: ByteArray) {
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
}
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
}
/**
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
fun stop() {
running = false
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
claims = emptyList()
runCatching { connection?.close() }
connection = null
device = null
}
private companion object {
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
const val ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
}
@@ -106,17 +106,6 @@ object Keymap {
KeyEvent.KEYCODE_DPAD_UP -> 0x26
KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27
KeyEvent.KEYCODE_DPAD_DOWN -> 0x28
// TV-remote SELECT = Enter (a gamepad's press routes via SOURCE_GAMEPAD before this).
KeyEvent.KEYCODE_DPAD_CENTER -> 0x0D
// Consumer/media keys — forwarded to the host while streaming (volume stays local:
// MainActivity's pass-through list wins before the map is consulted).
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
KeyEvent.KEYCODE_MEDIA_PLAY,
KeyEvent.KEYCODE_MEDIA_PAUSE -> 0xB3 // VK_MEDIA_PLAY_PAUSE
KeyEvent.KEYCODE_MEDIA_NEXT -> 0xB0 // VK_MEDIA_NEXT_TRACK
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> 0xB1 // VK_MEDIA_PREV_TRACK
KeyEvent.KEYCODE_MEDIA_STOP -> 0xB2 // VK_MEDIA_STOP
// Modifiers (L/R-specific VKs; the host folds the generic ones onto the left variant)
KeyEvent.KEYCODE_SHIFT_LEFT -> 0xA0
@@ -57,10 +57,6 @@ object NativeBridge {
/** Store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game,
* or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */
launch: String?,
/** This device's display name (rides the Hello as `name`) what the host's pending-approval
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank
* the host falls back to a fingerprint-derived "device abcd1234" label. */
deviceName: String?,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -89,16 +85,6 @@ object NativeBridge {
name: String,
): String
/**
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
* (`""` when none) call right after a `0` handle / `""` fingerprint. A typed host rejection
* yields its wire token ("not-armed", "denied", "approval-timeout", "superseded", "busy",
* "rate-limited", "bound-other", "identity-required", "wire-version"); transport-level causes
* yield "crypto" (wrong PIN / identity mismatch), "timeout", "io", or "error". Lets the UI say
* WHY instead of the old catch-all that blamed the PIN for dead network paths.
*/
external fun nativeTakeLastError(): String
/**
* Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes
* with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive
@@ -149,24 +135,6 @@ object NativeBridge {
*/
external fun nativeProbe(host: String, port: Int, timeoutMs: Int): Boolean
/**
* Start a bandwidth speed test on [handle]: the host bursts filler over the real data plane at
* [targetKbps] of goodput for [durationMs] (each clamped host-side to 3 Gbps / 5 s),
* **briefly pausing video**. Measuring over the stream's own path is the point the answer is
* about the link this host's stream will take, not about generic throughput.
*
* Non-blocking: poll [nativeProbeResult] until it reports done. Starting a probe resets any
* prior measurement. Returns false on a dead handle. Cheap; safe on the main thread.
*/
external fun nativeSpeedTest(handle: Long, targetKbps: Int, durationMs: Int): Boolean
/**
* The current speed-test measurement, partial until `[0] != 0.0`:
* `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`. Zeros before any
* probe, null on a dead handle. Cheap (one lock + a copy); safe to poll on the main thread.
*/
external fun nativeProbeResult(handle: Long): DoubleArray?
/**
* Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport
* defaults today just DSCP/QoS marking on the media sockets. Must be called BEFORE
@@ -183,36 +151,16 @@ object NativeBridge {
*/
external fun nativeVideoMime(handle: Long): String
/**
* The negotiated video mode as `[width, height, refreshHz]`, or `null` on a `0` handle.
* Resolved at the handshake, so it is known before the first frame the stream view sizes
* itself to THIS aspect rather than stretching the picture to the panel's, and pins the
* panel's display mode to the stream refresh. The trailing `refreshHz` was appended later
* (an older native lib returns only `[width, height]` index defensively). Fixed for the
* session; read once. Cheap; UI-safe.
*/
external fun nativeVideoSize(handle: Long): IntArray?
/**
* A short human label for the codec the host resolved (`"H.264"` / `"HEVC"` / `"AV1"` /
* `"PyroWave"`), for the stats HUD's video-feed line, or `""` on a `0` handle. Distinct from
* [nativeVideoMime] because the MIME collapses PyroWave onto `video/hevc` and can't name it.
* Fixed for the session (resolved at the handshake); read once. Cheap; UI-safe.
*/
external fun nativeVideoCodecLabel(handle: Long): String
/**
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
* entirely in Rust (NDK AMediaCodec ANativeWindow) no per-frame JNI. [decoderName] is the
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
* the MIME what the pre-overhaul client always did); [lowLatencyMode] is the user's
* "Low-latency mode" master toggle (ON by default: async loop + per-SoC tuning; off runs the
* original synchronous pipeline as the per-device escape hatch); [lowLatencyFeature] is whether
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
* hint otherwise). [presentPriority]/[smoothBuffer] are the timeline presenter's intent
* (0 = lowest latency / 1 = smoothness; buffer 0 = automatic, else 1..3 frames) the Apple
* client's `present_priority`/`smooth_buffer` pair. No-op if already started.
* hint otherwise). No-op if already started.
*/
external fun nativeStartVideo(
handle: Long,
@@ -221,11 +169,6 @@ object NativeBridge {
lowLatencyMode: Boolean,
lowLatencyFeature: Boolean,
isTv: Boolean,
presentPriority: Int,
smoothBuffer: Int,
/** The display mode's own refresh rate (0 = unknown) the latch grid the presenter
* subdivides onto when the platform down-rates the app's choreographer stream. */
panelFps: Int,
)
/** Stop + join the decode thread without closing the session. No-op on `0`. */
@@ -240,11 +183,11 @@ object NativeBridge {
/**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 30 doubles (unified stats spec, `design/stats-unification.md`):
* Returns 26 doubles (unified stats spec, `design/stats-unification.md`):
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]`
* e2eDispP50Ms, e2eDispP95Ms]`
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capturedecoded headline; 1013
* describe the negotiated video feed bit depth 8/10, CICP primaries/transfer, and the HEVC
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline
@@ -326,66 +269,6 @@ object NativeBridge {
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
/**
* Whether the host advertised full-fidelity stylus injection (`HOST_CAP_PEN`) the gate
* for splitting stylus pointers out of the touch path onto the pen plane. False on `0`.
*/
external fun nativeHostSupportsPen(handle: Long): Boolean
/**
* One stylus batch of STATE-FULL samples (the pen plane; design/pen-tablet-input.md §7):
* [count] × 10 floats, oldest first `[state, tool, x, y, pressure, distance, tilt_deg,
* azimuth_deg, roll_deg, dt_us]`. `state` = the wire in-range/touching/barrel bits; `tool`
* 0=pen 1=eraser; x/y/pressure/distance normalized 0..1; distance/tilt/azimuth/roll < 0 =
* unknown. Send only when [nativeHostSupportsPen]; repeat the last sample 100 ms while the
* pen is in range (the host force-releases a silent stroke after 200 ms).
*/
external fun nativeSendPen(handle: Long, samples: FloatArray, count: Int)
/**
* Whether the host advertised committed-text injection (`HOST_CAP_TEXT_INPUT`) its inject
* backend can type Unicode text directly. Picks the real IME `InputConnection` (autocorrect,
* gesture typing, non-Latin scripts) over the TYPE_NULL raw-key fallback. False on `0`.
*/
external fun nativeTextInputSupported(handle: Long): Boolean
/**
* Committed IME text one `TextInput` wire event per Unicode scalar, in order. Control
* characters are skipped natively (Enter/Backspace ride [nativeSendKey]). Only meaningful
* when [nativeTextInputSupported] returned true older hosts ignore the events.
*/
external fun nativeSendText(handle: Long, text: String)
// ---- Shared clipboard (text v1): Kotlin drives ClipboardManager, Rust the protocol ----
// Opt-in per session (nativeClipControl). Local copies are announced as lazy offers; bytes
// cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host
// copies arrive as "offer:" events, fetched eagerly into the system clipboard.
/** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */
external fun nativeClipSupported(handle: Long): Boolean
/** Session-level clipboard opt-in/out; nothing happens until enabled=true crosses. */
external fun nativeClipControl(handle: Long, enabled: Boolean)
/** Announce "this device's clipboard now holds text". [seq]: monotonic, newest wins. */
external fun nativeClipOfferText(handle: Long, seq: Int)
/** Pull the text of the host's offer [seq] → transfer id echoed on "data:"/"error:", or -1. */
external fun nativeClipFetchText(handle: Long, seq: Int): Int
/** Answer a "fetch:" event with the clipboard's current text (the host is pasting). */
external fun nativeClipServeText(handle: Long, reqId: Int, text: String)
/** Abort a clipboard transfer by id (either direction). */
external fun nativeClipCancel(handle: Long, id: Int)
/**
* Block 250 ms for the next clipboard event, as a compact string: `state:<0|1>` ·
* `offer:<seq>:<hasText>` · `fetch:<reqId>` · `data:<xferId>:<text>` · `cancel:<id>` ·
* `error:<id>:<code>` · `closed` (session gone) null on timeout. Dedicated poll thread.
*/
external fun nativeNextClip(handle: Long): String?
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
@@ -408,38 +291,6 @@ object NativeBridge {
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
/**
* One raw HID input report from a client-captured controller (the as-is Steam Controller 2
* passthrough), forwarded verbatim on the rich-input plane. [buf] is a DIRECT ByteBuffer whose
* first [len] bytes are the report, id byte first (0x42/0x45/0x47 state, 0x43 battery, );
* len is clamped to 64. Called from the capture thread at the controller's own report rate.
*/
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
/**
* One touchpad contact from a client-captured controller (the Sony USB capture), forwarded on
* the rich-input plane (`RichInput::Touchpad`). [finger] is the contact slot (0/1); [x]/[y]
* are normalized 0..65535 in SCREEN convention (+y down the wire's fixed meaning); active
* false lifts the finger. Send on change only the host holds per-slot state.
*/
external fun nativeSendPadTouch(handle: Long, pad: Int, finger: Int, active: Boolean, x: Int, y: Int)
/**
* One motion-sensor sample from a client-captured controller (`RichInput::Motion`): gyro
* pitch/yaw/roll + accel, each a raw signed-16 value in the pad's own units the host passes
* them straight into the virtual DualSense report. Called at the pad's report rate.
*/
external fun nativeSendPadMotion(
handle: Long,
pad: Int,
gyroPitch: Int,
gyroYaw: Int,
gyroRoll: Int,
accelX: Int,
accelY: Int,
accelZ: Int,
)
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
/**
@@ -451,11 +302,10 @@ object NativeBridge {
external fun nativeNextRumble(handle: Long): Long
/**
* Block up to ~100 ms for the next HID-output event, written into [buf] (a direct ByteBuffer,
* capacity >= 128) as `[pad][kind][fields]` (leading pad = the wire pad index to route to):
* Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect, raw as-is
* passthrough report=pad 05 kind report-bytes (kind 0 = output report, 1 = feature report).
* Returns the byte count, or -1 on timeout / session closed.
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
* ByteBuffer, capacity >= 64) as `[pad][kind][fields]` (leading pad = the wire pad index to
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect. Returns the
* byte count, or -1 on timeout / session closed.
*/
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
}

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