Compare commits

..
Author SHA1 Message Date
enricobuehler 6ff62b087c feat(client): say which GPU can do Vulkan Video, and why not when it can't
Field report from an Intel Arc + NVIDIA laptop: pinning the Vulkan rung on the Arc
iGPU silently produced D3D11VA, and there was no way to tell whether the build had
tried at all. That ambiguity was ours, in three places.

The "unavailable" log printed three of the FIVE conjuncts that gate Vulkan Video.
A device with 1.3, the features and a decode queue family — but no codec extension
— logged dev_is_13=true features_ok=true decode_family=true next to the word
"unavailable" and named nothing actionable. It now prints all five, plus which
base extensions are missing, which codec extensions are present, the decode
family's own advertised codec operations, and the device name and vendor. It also
no longer says "VAAPI/software" on Windows, where the rung below is D3D11VA.

The native-vulkan PIN refusal logged `video_decode` alone. On a device that
decodes something but not THIS codec, that reads as a contradiction: refused, yet
video_decode=true. It now carries the caps mask and the codec bit that was wanted,
so "your GPU can't" is distinguishable from "we asked for the wrong thing" — only
the second is our bug.

And `--probe-decode` is new: per-adapter Vulkan Video capability with no session,
no surface and no logical device. For each GPU it answers usable yes/no, the
driver's own decode ops, the extensions, and — when the answer is no — which
conjunct failed, in words. Separate from --list-adapters, which the desktop shells
parse line-by-line for their GPU picker and which therefore keeps printing bare
names.

The listing is ordered like pick_device (discrete first) and marks entry 0 as the
default presenter, because that ordering is very likely the reporter's actual
answer: pick_device ranks DISCRETE_GPU above INTEGRATED_GPU, Vulkan Video decodes
on the PRESENTER's device by design (that is what makes it zero-copy), and
PUNKTFUNK_DECODER does not move the presenter. So on a hybrid laptop, pinning the
decoder while the dGPU presents probes the wrong GPU entirely —
PUNKTFUNK_VK_DEVICE=<index> is the knob that moves it, and the index printed is
that value.

To keep the probe honest, VIDEO_BASE and VIDEO_CODECS moved to module scope and
the five-way AND became video_decode_gate(), called by both the probe and device
creation. A probe holding its own copy of the rule is one that eventually reports
a capability the session then refuses — which reads to everyone as a decoder bug
rather than a probe bug.

Gates: fmt clean; clippy -D warnings over punktfunk-client-session and
pf-presenter. The Linux container was unavailable (the host's disk filled and took
the docker daemon with it), so this ran on the macOS host target only — the
container leg is owed, and CI covers it on the PR.
2026-08-07 12:35:55 +02:00
1177 changed files with 33004 additions and 192974 deletions
+19 -19
View File
@@ -4,21 +4,12 @@
# or an accepted, documented risk. Keep this list TIGHT and justify every entry — an ignore here
# means the audit job stops flagging it, so the reasoning must hold up.
#
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities* — `unmaintained` AND
# `unsound` advisories are warnings that do NOT fail CI. That is deliberate for the two unmaintained
# crates below, but it does mean an unsoundness can sit here unnoticed: RUSTSEC-2026-0221
# (event-listener) did exactly that until the 2026-08-13 sweep. Read the job's warnings, not just
# its exit code.
#
# The two unmaintained ones, both transitive with no successor to bump to, left visible on purpose
# so we keep getting the maintenance signal:
# * audiopus_sys via opus (opus itself IS maintained; only its -sys layer is stuck).
# * paste via BOTH utoipa-axum (host) and rav1d (client decode path) — an earlier version of this
# note named only utoipa-axum, which would have made dropping utoipa-axum look like it cleared
# paste. It would not: every client pulls it through rav1d.
# (rustls-pemfile was dropped 2026-06-29 by removing axum-server's unused tls-rustls feature +
# moving our own PEM parsing to rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11
# bump.)
# NOTE: `cargo audit` (no `--deny warnings`) fails only on *vulnerabilities*, not on the
# `unmaintained` warnings (audiopus_sys via opus, paste via utoipa-axum). Both are transitive, at
# their latest published version with no successor, so there's nothing to bump — left visible on
# purpose so we keep getting the maintenance signal; they do not fail CI. (rustls-pemfile was dropped
# 2026-06-29 by removing axum-server's unused tls-rustls feature + moving our own PEM parsing to
# rustls-pki-types; memmap2's unsoundness was fixed by the 0.9.11 bump.)
[advisories]
ignore = [
@@ -43,8 +34,17 @@ ignore = [
# a constant-time rsa ships (then drop this), the host ever signs an attacker-chosen message with
# this key, or any RSA decryption / key-transport using the private key is added.
"RUSTSEC-2023-0071",
# The quick-xml DoS pair (RUSTSEC-2026-0194/0195) used to be ignored here, with the note
# "revisit when wayland-scanner releases against quick-xml >=0.41". It has: wayland-scanner
# 0.31.11 moved to `quick-xml ^0.41` and the lock is on 0.41.0 as of 2026-08-13, so both
# entries were dropped rather than left as permanent exceptions.
# quick-xml DoS advisories (RUSTSEC-2026-0194 quadratic-time duplicate-attribute check;
# RUSTSEC-2026-0195 unbounded namespace-declaration allocation in NsReader). Both are
# exploited by feeding attacker-controlled XML to a running parser. In this tree quick-xml is
# a BUILD-TIME-ONLY, transitive dependency of `wayland-scanner` (a proc-macro that parses the
# TRUSTED wayland protocol XML files shipped with the wayland-rs crates at compile time). It is
# never linked into any shipped binary and never parses runtime/attacker-controlled input, so
# neither DoS is reachable. There is no fix to bump to: wayland-scanner 0.31.10 (latest) pins
# `quick-xml ^0.39`, and the fixes only exist in quick-xml >=0.41. Revisit (drop these) when
# wayland-scanner releases against quick-xml >=0.41, or if quick-xml is ever pulled onto a
# runtime path that parses untrusted XML.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
]
+15 -36
View File
@@ -1,39 +1,18 @@
# Workspace-wide build flags.
#
# THERE ARE DELIBERATELY NONE. This file is kept as a tombstone so the aarch64 AES cfgs are not
# reintroduced — read this before adding rustflags here.
# 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.
#
# Until 2026-08-13 this file carried:
#
# [target.'cfg(target_arch = "aarch64")']
# rustflags = ["--cfg", "aes_armv8", "--cfg", "polyval_armv8"]
#
# because RustCrypto's `aes` 0.8.x enabled the ARMv8-Crypto hardware AES backend on aarch64 ONLY
# behind `--cfg aes_armv8`, and `polyval` 0.6.x gated its PMULL (carry-less multiply) GHASH path
# behind `--cfg polyval_armv8`. That was a live footgun, not just boilerplate: a RUSTFLAGS
# ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY — it does not merge and does not
# append — so every aarch64 lane that set its own RUSTFLAGS silently dropped both and fell back to
# SOFTWARE AES on the per-packet decrypt path. cargo-ndk sets RUSTFLAGS internally for its linker
# configuration, which means every Android arm64-v8a build was hitting exactly that.
#
# `aes` 0.9 removed the cfg: on aarch64 it runtime-detects with `cpufeatures::new!(features_aes,
# "aes")` (lib.rs), the same way x86_64 AES-NI always did. `polyval` 0.7 likewise selects
# `backend/intrinsics/armv8.rs` by `target_arch` alone. Neither cfg exists any more — passing them
# is inert.
#
# Measured here before deleting them, `crypto/open_in_place` from benches/pipeline.rs (one 1408-byte
# MTU shard, AES-128-GCM, single core, Mac15,14 M3 Ultra, all four runs back to back under the same
# background load):
#
# aes 0.8 + both cfgs 2.19 GiB/s <- what the cfgs bought
# aes 0.8, cfgs stripped 225 MiB/s <- the footgun: ~10x slower, software AES
# aes 0.9 + both cfgs 5.28 GiB/s
# aes 0.9, cfgs stripped 5.28 GiB/s <- identical to 4 s.f.; the cfgs do nothing
#
# The ChaCha20-Poly1305 series of the same bench was the control: it moved 0.07% across the cfg
# toggle at both versions, confirming the toggle reached only the AES path.
#
# So 0.9 without the cfgs is not merely as fast as 0.8 with them — it is ~2.4x faster, and ~24x
# the software fallback. Do not re-add these flags; if a future aarch64 slowdown is suspected,
# re-run `cargo bench -p punktfunk-core --bench pipeline -- in_place` and compare against the
# table above rather than reaching for a cfg.
# 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"]
-6
View File
@@ -1,6 +0,0 @@
<!-- What and why — the diff says how. -->
**User-facing fact changed?** (an install step, a knob, a port, what a feature does, a limit)
→ the docs-site page that owns it is updated in this PR, or this is n/a. Install/repo/port facts
live in `data/platforms.json`. (CONTRIBUTING.md "Where facts live"; `docs-drift` in CI only
catches the mechanical half.)
+2 -2
View File
@@ -6,7 +6,7 @@
# android.yml would mean an `if:` on all ten of its build steps.
#
# What it is for:
# * promote a tested build up a track (beta -> production)
# * promote a tested build up a track (alpha -> production)
# * roll production back by re-pointing it at an older versionCode (to_track=production,
# version_code=<the good one>, from_track blank)
# * halt a rollout (status=halted)
@@ -36,7 +36,7 @@ on:
from_track:
description: 'track to verify it is on, then clear (blank = touch nothing else)'
required: false
default: 'beta'
default: 'alpha'
notes_tag:
description: "tag whose docs/releases/whatsnew/<tag>.txt to attach, e.g. v0.23.0 (blank = none)"
required: false
+14 -122
View File
@@ -36,13 +36,8 @@ on:
- '.gitea/workflows/android.yml'
# Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at
# 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary
# (Play `beta` = open testing: public opt-in, no tester list — but unlike the previous
# `internal` target, every canary now passes Google review before testers see it, so a
# canary lands in hours/days, not minutes). The same canary versionCode is also assigned
# to `alpha` (closed testing) in the same Play edit, so the pre-production-access closed
# testers keep receiving builds without re-opting-in. Production access was granted
# 2026-08-01; before that a tag could only reach `alpha` and someone had to promote it
# by hand in the Console.
# (Play `internal`). Production access was granted 2026-08-01; before that a tag could only
# reach `alpha` and someone had to promote it by hand in the Console.
tags: ['v*']
pull_request:
paths:
@@ -56,21 +51,7 @@ on:
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/android.yml'
# Manual runs are BUILD-ONLY by default. The escape hatch below exists because a push run can
# go missing entirely: merge two PRs seconds apart and Gitea attributes the window's runs to the
# newer head, so the older merge sha gets no run at all — its android change then sits on main
# having never been built, let alone published (2026-08-14: `1e5dca4c`, PR #235, lost its run to
# `b5cace3a` 12 s later). Re-running the PR run does NOT recover it: a re-run replays the original
# `pull_request` event, so every gate below stays false. Only a dispatch with publish=true can
# ship that commit without inventing a filler push.
workflow_dispatch:
inputs:
publish:
# String, not a boolean: matches apple.yml's `testflight` input, which is the form proven
# to evaluate correctly on this Gitea. Compared as `inputs.publish == 'true'` below.
description: "Also publish this build (registry + Google Play). main -> beta+alpha, vX.Y.Z tag -> production at 100%. Default false: a stray click must not reach testers."
required: false
default: "false"
# 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
@@ -82,26 +63,9 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# The C/C++ half of the cache. The one that pays here is the CMake-built vendored libopus
# (audiopus_sys), which kit/build.gradle.kts drives through cargo-ndk once per ABI — three
# from-scratch libopus builds per run until now. The per-ABI compilers come from the NDK via
# cargo-ndk's own CC_<android-triple> vars, which this does not touch; CC_x86_64_unknown_linux_gnu
# covers only the HOST build scripts and proc macros.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
# The Skia console (pf-console-ui over skia-bindings, design/android-skia-console-port.md):
# prebuilt Skia archives for all three Android ABIs come from our own release
# (git.unom.io/unom/skia-binaries, tag = the skia-bindings version) — kit/build.gradle.kts
# bakes that url template in as the default, so this workflow needs no configuration. The
# repo variable, when set, OVERRIDES it (e.g. to stage the next skia-safe bump's archives
# before they are released). 🛑 skia-bindings never fails when no archive matches — it
# silently builds Skia from source. Every ABI's log must show `DOWNLOAD AND INSTALL SUCCEEDED`.
SKIA_BINARIES_URL: ${{ vars.SKIA_BINARIES_URL }}
jobs:
android:
@@ -121,9 +85,8 @@ jobs:
# store listing. Failing here also means a missing file cannot leave a half-published
# release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play.
#
# Canary is exempt on purpose: it has no curated notes. Open-testing users therefore see
# the previous release's text on a canary — cosmetic, and cheaper than gating every main
# push on a notes file.
# Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal
# testers costs nothing.
- name: Play release notes gate (tags only)
if: startsWith(github.ref, 'refs/tags/v')
run: |
@@ -197,22 +160,6 @@ jobs:
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
# Clippy for the ANDROID target. Like the kit tests below, this was running NOWHERE: ci.yml
# lints `--workspace` on the host, where `clients/android/native` and every
# `#[cfg(target_os = "android")]` module elsewhere compile out, and this workflow only ever
# built. Discovered in 2026-08 with five lints already resident — code no gate had ever read.
#
# Placed BEFORE assembleDebug deliberately: a lint failure should cost the ~10 s the lint
# takes, not the full three-ABI build first. It shares sccache and the target dir with the
# build that follows, so the compile is not paid twice.
#
# The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk
# environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why
# both pointer widths are load-bearing and why the environment must not be duplicated here.
- name: Clippy (Android target, deny warnings)
working-directory: clients/android
run: ./gradlew :kit:cargoNdkClippy --stacktrace
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
@@ -221,50 +168,6 @@ jobs:
working-directory: clients/android
run: ./gradlew :kit:testDebugUnitTest --stacktrace
# The cross-client contract in `clients/shared/console-vectors.json` — the console palette
# table, the settings section names and the screen-transition motion, each of which exists in
# three hand-written copies (here, pf-console-ui, the Apple client). The other two check it
# from their own suites; this is Android's side.
#
# FILTERED, not a plain `:app:testDebugUnitTest`: that task also runs the ~20 Roborazzi
# screenshot scenes, which are a release-artifact job (android-screenshots.yml, gated to v*
# tags) and have no business adding a minute to every push. The filter is what lets the
# contract gate here without dragging the rest of the app suite in with it.
#
# ⚠ The filter is an ALLOWLIST, so a test class that is not named here does not run — it
# reads as coverage in the tree and gates nothing. `ProfilesTest` and `StatsOverlayAudioTest`
# sat outside it and were only noticed when the hi-res audio work added cases to both; the
# HUD ones had never run in CI at all. Adding a test class to `app/src/test` is therefore
# only half the job: add it here too, or it is decoration.
#
# That audit found NINE more in the same state (gamepad palette/rows/UI, OS icons, render
# scale, safe area, SC2 bluetooth grant, settings scope, speed test) — every one of them
# passing, so nothing was hiding, but none of them gating either. They are all listed now.
# The list is deliberately explicit rather than a package glob: the unfiltered task also
# drags in the ~20 Roborazzi screenshot scenes above, and a glob would quietly re-admit them
# the moment someone added one.
- name: console parity vectors + app-module logic tests
working-directory: clients/android
run: >-
./gradlew :app:testDebugUnitTest
--tests 'io.unom.punktfunk.ConsoleVectorsTest'
--tests 'io.unom.punktfunk.HomeTilesTest'
--tests 'io.unom.punktfunk.GamepadSettingsLayoutTest'
--tests 'io.unom.punktfunk.ConsoleSubScreenRowsTest'
--tests 'io.unom.punktfunk.ConsoleSubScreenRoutesTest'
--tests 'io.unom.punktfunk.ProfilesTest'
--tests 'io.unom.punktfunk.StatsOverlayAudioTest'
--tests 'io.unom.punktfunk.GamepadPaletteTest'
--tests 'io.unom.punktfunk.GamepadSettingsRowsTest'
--tests 'io.unom.punktfunk.GamepadUiTest'
--tests 'io.unom.punktfunk.OsIconsTest'
--tests 'io.unom.punktfunk.RenderScaleTest'
--tests 'io.unom.punktfunk.SafeAreaTest'
--tests 'io.unom.punktfunk.Sc2BluetoothGrantTest'
--tests 'io.unom.punktfunk.SettingsScopeTest'
--tests 'io.unom.punktfunk.SpeedTestTest'
--stacktrace
- name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android
env:
@@ -274,18 +177,15 @@ jobs:
# Single source of the version name + the Play track for the release steps below. versionCode
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
- name: Version + channel
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
case "$GITHUB_REF" in
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production"; ALSO="" ;;
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="beta"; ALSO="alpha" ;;
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;;
*) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
esac
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
echo "PLAY_ALSO_TRACK=$ALSO" >> "$GITHUB_ENV"
# Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB).
# On a tag the gate step above already proved this exists, so the else branch is only
# ever the canary path. See docs/releases/README.md.
@@ -295,12 +195,10 @@ jobs:
else
echo "no Play release notes at $NOTES (canary — Play keeps the previous text)"
fi
echo "android version $VN -> Play track '$TRACK'${ALSO:+ (+ '$ALSO')}"
echo "android version $VN -> Play track '$TRACK'"
- name: Build Release (signed AAB + universal APK)
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
working-directory: clients/android
env:
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
@@ -331,13 +229,11 @@ jobs:
retention-days: 30
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
# Generic registry is public for reads — matches windows-client.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
# to the unified Gitea Release.
- name: Publish to generic registry + attach to Gitea release
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
REGISTRY: git.unom.io
OWNER: unom
@@ -371,8 +267,7 @@ jobs:
# Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the
# real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no
# pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview.
# Track: canary main -> `beta` (open testing) + the same versionCode on `alpha` (closed
# testing) in the same Play edit; a vX.Y.Z release -> `production` at 100% (`completed`).
# Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`).
#
# A tag therefore ships to real users with no further click. Two things keep that honest:
# the tag is only pushed once every platform is green, and Play reviews each production
@@ -380,16 +275,13 @@ jobs:
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
- name: Upload to Google Play
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
run: |
echo "uploading to Play track '$PLAY_TRACK'${PLAY_ALSO_TRACK:+ (+ '$PLAY_ALSO_TRACK')}"
echo "uploading to Play track '$PLAY_TRACK'"
set -- --package io.unom.punktfunk \
--aab clients/android/app/build/outputs/bundle/release/app-release.aab \
--track "$PLAY_TRACK" --status completed
if [ -n "${PLAY_ALSO_TRACK:-}" ]; then set -- "$@" --also-track "$PLAY_ALSO_TRACK"; fi
if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi
python3 clients/android/ci/play-upload.py "$@"
+51 -600
View File
@@ -1,86 +1,12 @@
# Apple client CI **and** distribution — everything that runs on the self-hosted macOS runner
# (home-mac-mini-1, host mode; see scripts/ci/setup-macos-runner.sh), in dependency order:
# Apple client CI runs on the self-hosted macOS runner (home-mac-mini-1, host mode;
# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into
# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent
# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST.
#
# swift — build the Rust core into PunktfunkCore.xcframework, then build + test the Swift
# package. Network-dependent tests (RemoteFirstLightTests) self-skip without
# PUNKTFUNK_REMOTE_HOST. Runs on pushes, tags AND pull requests.
# distribute — needs: swift. The signed/notarized artifacts:
# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached
# to the Gitea release on tag pushes
# macOS (App Store) -> archive + upload to TestFlight (App Store Connect)
# iOS -> archive + upload to TestFlight, plus an exported .ipa
# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets,
# nightly -Zbuild-std, in build-xcframework.sh)
# screenshots — needs: swift. App Store Connect screenshots of the REAL UI, attached to the run
# as a zip artifact. Best-effort, so a capture gap never reds the core signal.
#
# ⚠ WHY THIS FILE IS ONE FILE. `distribute` used to live in its own workflow called `release.yml` —
# a name that described neither what it did (Apple only) nor how releases actually work here (every
# platform's packaging workflow attaches to the same Gitea release on a v* tag, and announce.yml is
# the manual "go"). The name was the smaller problem. The real one: Gitea has no cross-workflow
# `needs`, so nothing sequenced it against apple.yml's tests — a canary main push uploaded iOS,
# macOS and tvOS builds to TestFlight even when `swift test` had just failed on the same commit,
# and the two files' `paths:` filters had already drifted apart, so it was possible for one to fire
# without the other. Merging is what makes `needs: swift` expressible. Do not split them again.
#
# The trigger list is deliberately NARROW on crates/: everything here is built from
# `crates/punktfunk-core` (via scripts/build-xcframework.sh) and nothing else in the workspace.
# VERIFY THAT BEFORE WIDENING OR TRUSTING IT — punktfunk-core's only path dependency is its own
# vendored fec-rs, under crates/punktfunk-core/vendor/:
# sed -n '/^\[dependencies\]/,/^\[/p' crates/punktfunk-core/Cargo.toml | grep path
# If punktfunk-core ever gains a path dep on a sibling crate, add that crate here. Cargo.lock is a
# partial safety net (it moves when the dep is ADDED) but not a complete one — later edits to that
# crate would not fire this workflow. This is the same class of gap flatpak.yml documents.
#
# ── Signing / distribution notes (all of these belong to `distribute`) ────────────────────────────
#
# One App Store listing for all platforms (universal purchase): every target shares the
# bundle ID io.unom.punktfunk.
#
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
#
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
# step is continue-on-error until they exist):
# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record
# (universal purchase).
# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the
# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/).
# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
# .pkg is installer-signed with it.
#
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
# a launchable app.
#
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
# ID Application + Apple Distribution, with the WWDR intermediate present (so they show as
# *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain.
# One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access:
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <login-pw> \
# ~/Library/Keychains/login.keychain-db
#
# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API
# key — notarization, TestFlight upload, automatic-signing profile fetch).
#
# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
# loud warning.
# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI
# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches
# 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
@@ -93,39 +19,30 @@ concurrency:
on:
push:
# Canary: a relevant main push builds + tests, then uploads the iOS + macOS + tvOS builds to
# TestFlight (Apple's own canary channel) — no notarized DMG (that's stable-only; see the
# per-step gates). Heavy on the shared mac-mini runner, hence the tight paths filter.
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/punktfunk-core/**'
- 'crates/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'scripts/ci/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store. Tag runs are matched by
# `tags:` and are NOT subject to the paths filter above.
tags: ['v*']
pull_request:
paths:
- 'crates/punktfunk-core/**'
- 'crates/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'scripts/ci/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
workflow_dispatch:
inputs:
testflight:
description: "Upload the iOS/macOS/tvOS builds to TestFlight (true/false)"
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
@@ -144,11 +61,11 @@ env:
jobs:
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
# with the release-signing job below (which loads the App Store Connect key). Untrusted PR code could
# persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's "require
# approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral runners. The
# `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and still runs
# same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
# code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's
# "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral
# runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and
# still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
swift:
runs-on: macos-arm64
if: >-
@@ -168,10 +85,17 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
# Shared compile cache. The script handles the macOS side (user-prefix install +
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
# 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: sh scripts/ci/ensure-sccache.sh
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
@@ -203,496 +127,24 @@ jobs:
working-directory: clients/apple
run: swift test
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
# ── Distribution ────────────────────────────────────────────────────────────────────────────────
# `needs: swift` is the entire reason this lives here rather than in its own file: it is what makes
# a failed `swift test` stop a TestFlight upload. Never demote it to a parallel job.
distribute:
needs: swift
# Pushes to main (canary), v* tags (stable) and manual dispatch — never pull requests.
if: gitea.event_name != 'pull_request'
runs-on: macos-arm64
timeout-minutes: 120
env:
TEAM_ID: F4H37KF6WC
PROJECT: clients/apple/Punktfunk.xcodeproj
steps:
- uses: actions/checkout@v4
- name: Select release Xcode
run: |
DEV_DIR=""
for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do
case "$app" in *beta*|*Beta*) continue;; esac
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
done
if [ -z "$DEV_DIR" ]; then
for app in /Applications/Xcode*.app; do
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
done
echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds."
fi
[ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; }
# Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must
# keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the
# running dyld rejects, killing proc-macro loads (see build-xcframework.sh).
echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV"
DEVELOPER_DIR="$DEV_DIR" xcodebuild -version
- name: Version from tag
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
# GITHUB_RUN_NUMBER is REPO-WIDE in Gitea (not per-workflow as on GitHub): consecutive runs
# of different workflows get consecutive numbers. That is why folding the old release.yml
# into this file could not reset the build number and strand TestFlight, which rejects a
# non-increasing CFBundleVersion. It also means this climbs by ~8 per push rather than by 1
# — monotonic either way, which is all App Store Connect asks.
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
- name: Rust toolchain (mac + iOS + tvOS slices)
run: |
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
# nightly + -Zbuild-std, so ensure nightly + rust-src are present.
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
command -v cmake >/dev/null || "$BREW" install cmake
echo "$BREW_BIN" >> "$GITHUB_PATH"
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
# Shared compile cache. The script handles the macOS side — see scripts/ci/ensure-sccache.sh.
- name: sccache (self-healing install)
run: sh scripts/ci/ensure-sccache.sh
- 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.
#
# The directory is still named `release` after the workflow this job used to live in. Left
# alone deliberately: renaming it would orphan a warm ~760 MB tree and buy nothing.
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
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
#
# This repeats the `swift` job's mac-slice build, and that is the intended trade: the two
# jobs share the runner's sccache and DerivedData, so the overlap is cheap, whereas passing
# an xcframework between jobs would mean uploading/downloading it through Gitea's artifact
# backend (the one that already forces upload-artifact@v3) on every run.
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
- name: Stage App Store Connect API key
env:
ASC_P8: ${{ secrets.ASC_API_KEY_P8 }}
run: |
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
chmod 600 "$RUNNER_TEMP/asc.p8"
- name: macOS — archive, codesign Developer ID, notarize, DMG
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
# provisioning-profile gate at archive time; we re-assert that authorization below by
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
# Bundle is a single static binary.
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk \
-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
APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app"
# Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build
# uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix.
RESOLVED="$RUNNER_TEMP/macos.entitlements"
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
# network/device keys are self-asserted for Developer ID, but a keychain access group
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
# entitlements authorize the access group, exactly like the App Store build's profile
# does. Located by profile Name among the profiles installed on the runner (see header).
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
PROFILE_SRC=""
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
[ -e "$p" ] || continue
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
done
if [ -n "$PROFILE_SRC" ]; then
# Must land BEFORE codesign so it's sealed into the bundle.
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
echo "embedded Developer ID profile: $PROFILE_SRC"
else
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
fi
codesign --force --options runtime --timestamp \
--entitlements "$RESOLVED" \
--sign "Developer ID Application" "$APP"
codesign --verify --strict --verbose=2 "$APP"
# Notarized DMG.
STAGE="$RUNNER_TEMP/dmg-stage"
mkdir -p "$STAGE"
cp -R "$APP" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg"
hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \
--key "$RUNNER_TEMP/asc.p8" \
--key-id "${{ secrets.ASC_API_KEY_ID }}" \
--issuer "${{ secrets.ASC_API_ISSUER_ID }}"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
echo "DMG=$DMG" >> "$GITHUB_ENV"
- name: Attach DMG 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)
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
- name: macOS App Store — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Best-effort until the App Store Connect record has the macOS platform + the
# "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer
# Installer" cert are on the runner (see the header). The macOS app is sandboxed
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
continue-on-error: true
run: |
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
# 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
# (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
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk macOS App Store Distribution"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-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"
cat > "$RUNNER_TEMP/export-macos-appstore.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>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>
<key>provisioningProfiles</key>
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \
-exportPath "$RUNNER_TEMP/export-macos-appstore" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: iOS — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
continue-on-error: true
run: |
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
# 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.
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"
cat > "$RUNNER_TEMP/export-appstore.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>upload</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 "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \
-exportPath "$RUNNER_TEMP/export-appstore" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-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 / the screenshots job below). 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.
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
# on the runner (xcodebuild -downloadPlatform tvOS).
continue-on-error: true
run: |
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
# 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).
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"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-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"
cat > "$RUNNER_TEMP/export-tvos.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>upload</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></dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \
-exportPath "$RUNNER_TEMP/export-tvos" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
# first, and is a separate job so a capture hiccup can never red the core signal.
#
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13") + Apple TV (1920×1080), captured
# on the Simulator (`simctl io screenshot`, no Screen Recording grant needed). The tvOS slice is
# Tier-3 (nightly -Zbuild-std, same as the distribute job — slow cold, cached on the self-hosted
# runner). The tvOS scene list is explicit: the gamepad-console scenes are iOS/macOS-only, and an
# unknown scene name falls back to a NORMAL app launch — the capture would silently be of the
# real empty app. macOS stays deliberately NOT in CI: the runner is headless (no window-server
# session), so the mac window capture can't run there — generate it locally on a GUI Mac with
# `clients/apple/tools/screenshots.sh macos`.
# Scope = the two REQUIRED iOS sizes (iPhone 6.9" + iPad 13"), captured on the Simulator
# (`simctl io screenshot`, no Screen Recording grant needed). macOS and tvOS are deliberately
# NOT in CI: the self-hosted runner is headless (no window-server session), so the mac window
# capture can't run there; tvOS needs the Tier-3 build-std slice. Generate those two locally on
# a GUI Mac with `clients/apple/tools/screenshots.sh macos tvos`.
screenshots:
needs: swift
if: gitea.event_name != 'pull_request'
runs-on: macos-arm64
timeout-minutes: 90
timeout-minutes: 75
steps:
- uses: actions/checkout@v4
- name: Rust toolchain + iOS Simulator targets (+ nightly for the tvOS slices)
- name: Rust toolchain + iOS Simulator targets
run: |
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
@@ -702,15 +154,18 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
# nightly + -Zbuild-std, so ensure nightly + rust-src are present (see the swift job).
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
# Shared compile cache. The script handles the macOS side (user-prefix install +
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
# 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: sh scripts/ci/ensure-sccache.sh
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)
@@ -728,7 +183,7 @@ 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 the distribute job already cures)
- 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
@@ -742,10 +197,10 @@ jobs:
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
fi
- name: Build PunktfunkCore.xcframework (mac + iOS + tvOS slices)
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
- name: Capture screenshots (iPhone 6.9" + iPad 13" + Apple TV; auto-creates the Simulators)
- name: Capture screenshots (iPhone 6.9" + iPad 13"; auto-creates the Simulators)
working-directory: clients/apple
env:
SETTLE: "8" # Simulators settle slower than a local run
@@ -753,10 +208,6 @@ jobs:
# Independent invocations: one platform failing skips it, not the other.
bash tools/screenshots.sh ios || echo "::warning::iOS (iPhone 6.9\") screenshots skipped"
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
# tvOS shoots only the scenes that exist there — the 0609 gamepad-console scenes are
# compiled out on tvOS (native focus engine), and an unknown name = a normal app launch.
SCENES="01-stream 02-hosts 11-library 05-settings 03-pair" \
bash tools/screenshots.sh tvos || echo "::warning::Apple TV screenshots skipped"
echo "Produced:"; ls -la screenshots || true
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
+8 -273
View File
@@ -48,26 +48,7 @@ on:
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
tags: ['v*']
# REBUILDING A PUBLISHED RELEASE, because on a rolling distro the ground moves under one.
# Arch went FFmpeg 8 -> 9 (every libav soname +1) four minutes before v0.25.0 was tagged, so
# the release's punktfunk-host was linked in a builder image that still had 8 and shipped
# `libavcodec.so=62-64`. No up-to-date Arch box can satisfy that — and pacman prepares the
# whole transaction at once, so it did not merely block our package, it blocked those users'
# entire `pacman -Syu`. The repair is a rebuild of the SAME upstream version at a HIGHER
# pkgrel; nothing else reaches a box that already has the broken build recorded in its db.
# The workflow file at the tag can never carry inputs added after it was tagged, so dispatch
# this from `main`: it checks the tag's SOURCE out, publishes to the STABLE repo, and
# replaces the release-page assets. Same lever for any future "the distro moved" rebuild.
workflow_dispatch:
inputs:
release_tag:
description: 'Rebuild this published release (e.g. v0.25.0) into the stable `punktfunk` repo. Empty = ordinary canary build of the dispatched ref.'
required: false
default: ''
pkgrel:
description: 'pkgrel for that rebuild — MUST be above the published one (2, 3, …); a same-pkgrel republish is invisible to pacman. Ignored without release_tag.'
required: false
default: '2'
env:
REGISTRY: git.unom.io
@@ -81,14 +62,6 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# The C/C++ half of the cache (aws-lc-sys, vendored libopus, openh264's C++). Safe at workflow
# level — no cross-compiling job here (see ci.yml's `rust` job for that trap). NOTE these ALSO
# have to be named in the makepkg step's `sudo -u builder env …` list: sudo's env_reset drops
# everything not listed, which is why the sccache vars are already spelled out there.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
CARGO_INCREMENTAL: "0"
jobs:
@@ -121,52 +94,7 @@ jobs:
}
bun --version
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
# packaging/arch/PKGBUILD binds punktfunk-host to the exact libav sonames it linked
# (`libavcodec.so=63-64` …), so a builder one FFmpeg major behind Arch emits a package
# that NOBODY can install — and takes the user's whole `pacman -Syu` down with it, since
# pacman prepares the transaction as a unit. That is exactly how v0.25.0 shipped: PR #108
# re-keyed this image for FFmpeg 9, the release tag fired four minutes later, and the job
# still got the FFmpeg-8 `:latest`. The image is a cache and is allowed to lag — but never
# on this one axis. So heal it in-job and shout, instead of building a dead package.
# (Runs BEFORE checkout: a stale image should be repaired before anything depends on it.)
- name: FFmpeg soname parity with today's Arch (heals a stale builder image)
run: |
export LC_ALL=C # `Provides` is a localized field name
# Piped (never a TTY here) pacman prints each field on ONE line, unwrapped.
sonames() { sed -n 's/^Provides *: *//p' | tr ' ' '\n' | grep -E '^lib(av|sw)[a-z]*\.so=' | sort | tr '\n' ' '; }
# A SEPARATE --dbpath: this refreshes only a throwaway view of the repos, so the
# container's own db never enters the partial-upgrade state a bare `pacman -Sy` leaves.
mkdir -p /tmp/pf-archsync
if ! pacman -Sy --dbpath /tmp/pf-archsync --logfile /dev/null >/dev/null 2>&1; then
echo "::warning::could not refresh the Arch db — skipping the FFmpeg parity check"
exit 0
fi
HAVE="$(pacman -Qi ffmpeg | sonames)"
WANT="$(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sonames)"
echo "builder ffmpeg $(pacman -Q ffmpeg | cut -d' ' -f2): $HAVE"
echo "arch ffmpeg $(pacman -Si --dbpath /tmp/pf-archsync ffmpeg | sed -n 's/^Version *: *//p'): $WANT"
if [ "$HAVE" = "$WANT" ]; then
echo "OK: the builder links the FFmpeg every up-to-date Arch box already has"
exit 0
fi
echo "::warning::arch-ci is stale ACROSS AN FFMPEG SONAME BUMP — upgrading it for this run."
echo "::warning::Bump the 'refreshed:' date in ci/arch-ci.Dockerfile so the IMAGE carries it."
pacman -Syu --noconfirm || true
HAVE="$(pacman -Qi ffmpeg | sonames)"
if [ "$HAVE" != "$WANT" ]; then
echo "::error::builder still links $HAVE while Arch ships $WANT."
echo "::error::Building on would publish a package no Arch box can install."
exit 1
fi
echo "healed: builder now links $HAVE"
- uses: actions/checkout@v4
with:
# A dispatched release rebuild takes its WORKFLOW from the ref you dispatch (the only
# way it can carry inputs the tag predates) and its SOURCE from the tag. Empty string
# = checkout's own default, i.e. the triggering ref, for every other trigger.
ref: ${{ github.event.inputs.release_tag }}
# Cache cargo's git dir too, not just the registry: the workspace includes
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
@@ -199,68 +127,17 @@ jobs:
# Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable
# release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising
# it to `1.` would sort canaries ABOVE the release and is not an option.)
env:
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
REBUILD_PKGREL: ${{ github.event.inputs.pkgrel }}
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
if [ -n "${RELEASE_TAG:-}" ]; then
# Dispatched rebuild of a published release (see the workflow_dispatch note at the
# top): same upstream version, higher pkgrel, straight into the stable repo.
# ⚠ Keep that pkgrel SINGLE-DIGIT. Gitea's Arch registry picks the version its .db
# advertises by STRING order (the same trap the canary zero-padding below exists for),
# so "0.25.0-10" sorts BELOW "0.25.0-2" and the rebuild would never be advertised.
V="${RELEASE_TAG#v}"
R="${REBUILD_PKGREL:-2}"
REPO=punktfunk
case "$R" in
''|*[!0-9.]*) echo "::error::pkgrel '$R' is not digits+dots"; exit 1 ;;
1) echo "::error::pkgrel 1 is the published build — a rebuild MUST go up (2, 3, …)"; exit 1 ;;
esac
else
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
esac
fi
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
esac
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
echo "REPO=$REPO" >> "$GITHUB_ENV"
echo "pacman $V-$R -> repo '$REPO'"
# ── The web console, built once per (web+sdk content, bun) instead of once per job ─────────
# Shares deb.yml's key family — see the fuller note there. Unlike the RPM leg this needs no
# hand-off macro: makepkg builds with PF_SRCDIR pointing at this workspace, so a restored
# web/.output is already exactly where PKGBUILD's build-if-missing guard looks for it.
#
# Built here as root, BEFORE the makepkg step's `chown -R builder:` sweeps the tree, so the
# bundle ends up owned like everything else the builder user is handed.
- name: Web console cache key
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
- name: Build the web console (cache miss only)
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
bun run build
- name: The console must exist (cache hit or fresh build)
run: |
if [ ! -f web/.output/server/index.mjs ]; then
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
exit 1
fi
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
echo "web console present: $(du -sh web/.output | cut -f1)"
- name: Build packages (makepkg)
run: |
git config --global --add safe.directory "$PWD"
@@ -293,59 +170,8 @@ jobs:
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" \
CMAKE_C_COMPILER_LAUNCHER="$CMAKE_C_COMPILER_LAUNCHER" \
CMAKE_CXX_COMPILER_LAUNCHER="$CMAKE_CXX_COMPILER_LAUNCHER" \
CC_x86_64_unknown_linux_gnu="$CC_x86_64_unknown_linux_gnu" \
CXX_x86_64_unknown_linux_gnu="$CXX_x86_64_unknown_linux_gnu" \
makepkg -f -d --holdver
ls -lh "$GITHUB_WORKSPACE/dist"
# Visibility only. The stats have to be read as the SAME user that ran the compiles —
# sccache keeps its stats in a per-user server process, so a root `--show-stats` here
# would report an idle server and zero everything.
sudo -u builder env 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" \
sccache --show-stats || true
# The host must ship a VERSIONED libav soname dep, and nothing else in this pipeline proves
# it. packaging/arch/PKGBUILD lists bare `libavcodec.so` etc. and relies on makepkg rewriting
# each into `libavcodec.so=<soname>-<arch>` from the built binary's DT_NEEDED; if that
# rewrite ever stops happening — Arch dropping the soname `provides`, someone "tidying" the
# entries out of `depends`, a makepkg change — the dep silently degrades to an unversioned
# name that ANY ffmpeg satisfies. That is precisely the 2026-08-08 state in which `pacman
# -Syu` walked every Arch/CachyOS install across the FFmpeg 8 -> 9 soname bump and left the
# host unable to start (exit 127 before main(), restart loop). The failure is invisible in a
# green build and only shows up as a bricked box weeks later, so assert it here.
- name: Assert the host pins the FFmpeg soname
run: |
PKG="$(ls "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst | head -1)"
DEPS="$(bsdtar -xOf "$PKG" .PKGINFO | sed -n 's/^depend = //p')"
echo "$DEPS" | sed 's/^/ depend = /'
for lib in libavcodec libavutil; do
echo "$DEPS" | grep -qE "^$lib\.so=[0-9]+-[0-9]+$" || {
echo "::error::punktfunk-host declares no VERSIONED $lib.so dependency."
echo "::error::makepkg did not expand the bare soname from DT_NEEDED, so pacman can"
echo "::error::upgrade FFmpeg across a soname break and brick the install."
echo "::error::See the depends comment in packaging/arch/PKGBUILD."
exit 1
}
done
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
# 0.26.0-1 setcap'd `cap_sys_nice=ep` on the host from this package's .INSTALL scriptlet and
# killed desktop streaming on every KDE box — with a green board, because nothing here ever
# looked at what the built package would DO. The lesson recorded then was "verify the
# PACKAGE, never the board"; this is that, and pacman is the channel where it matters most,
# since capabilities live in the scriptlet rather than in package metadata.
#
# Host must carry NOTHING, the worker exactly cap_sys_nice=ep. `--self-test` runs first so a
# guard that has quietly lost the ability to fail takes the job down rather than approving a
# release. (Only the host package is checked: the client/web/scripting packages ship neither
# binary and the script skips them by itself.)
- name: Assert the capability matrix (Arch package)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
bash scripts/ci/assert-cap-matrix.sh "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
# completely different dependency set, published into the same repo so `pacman -S
@@ -384,63 +210,6 @@ jobs:
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
fi
# THE GATE THIS PIPELINE WAS MISSING. The soname assert above proves the libav dep is
# VERSIONED; it cannot prove the version is one that EXISTS. v0.25.0 passed it and still
# shipped `libavcodec.so=62-64` to a world that had moved to 63 — every affected user got
# "unable to satisfy dependency … required by punktfunk-host", and because pacman prepares
# one transaction, their whole system upgrade stopped there. So ask the only question that
# matters before publishing: would a real, up-to-date Arch box install this?
#
# An empty --dbpath is what makes the answer honest. It means "nothing is installed", so
# pacman must satisfy every dependency FROM THE REPOS exactly as a user's box does. Checking
# against the builder's own installed set instead would let a stale ffmpeg satisfy the stale
# bound and hide the break completely — the very illusion that shipped v0.25.0. `--print`
# resolves and prints; it downloads nothing and installs nothing. Verified against the real
# broken artifact on an ffmpeg-9 box: it reproduces the user-visible failure verbatim.
- name: Assert every package installs on an up-to-date Arch box
run: |
export LC_ALL=C
mkdir -p /tmp/pf-instcheck
if ! pacman -Sy --dbpath /tmp/pf-instcheck --logfile /dev/null >/dev/null 2>&1; then
echo "::error::could not sync the Arch db — cannot prove these packages install"
exit 1
fi
check() { # check FILE -> 0 installable, 1 not (reason on stdout)
pacman -U --print --noconfirm --dbpath /tmp/pf-instcheck --logfile /dev/null "$1" 2>&1
}
ls dist/*.pkg.tar.zst >/dev/null 2>&1 || { echo "::error::nothing in dist/ to check"; exit 1; }
rc=0
for pkg in dist/*.pkg.tar.zst; do
if out="$(check "$pkg")"; then
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
else
rc=1
echo "::error::$(basename "$pkg") CANNOT be installed on an up-to-date Arch box:"
echo "$out" | sed 's/^/ /'
fi
done
# gamescope stays best-effort, exactly as its build step is: a companion that cannot
# install is dropped from the upload with a warning, never a reason to withhold the
# packages this workflow exists to publish. (It is also the one package that can be
# restored from a cache older than the current Arch snapshot.)
for pkg in dist-gamescope/*.pkg.tar.zst; do
[ -e "$pkg" ] || continue
if out="$(check "$pkg")"; then
echo "OK $(basename "$pkg") ($(echo "$out" | wc -l) targets resolve)"
else
echo "::warning::$(basename "$pkg") is not installable on current Arch — NOT publishing it"
echo "$out" | sed 's/^/ /'
rm -f "$pkg"
fi
done
if [ "$rc" != 0 ]; then
echo "::error::refusing to publish: pacman would reject this on a current box, and a"
echo "::error::rejected dependency blocks the user's ENTIRE upgrade, not just punktfunk."
echo "::error::Usual cause: the arch-ci builder image lags Arch across a soname bump —"
echo "::error::bump 'refreshed:' in ci/arch-ci.Dockerfile, let docker.yml republish it, re-run."
exit 1
fi
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
# per-partition-set), which is the standing packaging verdict behind the on-device
@@ -468,48 +237,14 @@ jobs:
done
echo "published to $OWNER/arch/$REPO"
# On a real release, also attach the packages to the unified Gitea Release. A dispatched
# rebuild attaches to that SAME release object: the release page is a distribution surface
# too, and leaving the superseded .pkg.tar.zst sitting on it is one click away from handing
# someone the exact break the rebuild exists to fix.
- name: Attach packages to the Gitea release (stable tags + release rebuilds)
if: startsWith(gitea.ref, 'refs/tags/v') || github.event.inputs.release_tag != ''
# On a real release, also attach the packages to the unified Gitea Release.
- name: Attach packages to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
run: |
. scripts/ci/gitea-release.sh
TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
RID=$(ensure_release "$TAG" "$TAG" auto)
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for pkg in dist/*.pkg.tar.zst; do
upsert_asset "$RID" "$pkg"
done
# A rebuild bumps pkgrel, so its FILENAMES differ from the ones already attached, and
# upsert_asset only replaces by name — the superseded set would survive untouched.
# Drop every pacman asset (and .sha256 sidecar) this upload did not just write.
#
# ⚠⚠ THIS MUST LIVE IN THE WORKFLOW, NOT IN scripts/ci/gitea-release.sh. The sourced
# script comes from the CHECKED-OUT TREE, which on a release rebuild is the OLD TAG —
# so it can only ever offer the helpers that existed when that tag was cut. A helper
# added for this feature is therefore guaranteed ABSENT in the one code path that
# calls it: the first attempt failed with `prune_release_assets: command not found`
# after publishing perfectly. Only the workflow file itself is taken from the ref you
# dispatch. Same reason a packaging fix made after a tag does NOT reach a rebuild of
# that tag — the PKGBUILD is the tag's too.
if [ -n "${RELEASE_TAG:-}" ]; then
KEEP="$(cd dist && printf '%s ' *.pkg.tar.zst)"
# An UNMATCHED glob would come through literally and match nothing in the keep set —
# i.e. "delete every pacman asset on the release". Skip entirely instead.
case "$KEEP" in *'*'*) KEEP="" ;; esac
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
if [ -n "$KEEP" ]; then
curl -fsS "$API/releases/$RID/assets" -H "Authorization: token $GITEA_TOKEN" \
| python3 -c "import json,sys;k=set(sys.argv[1].split());k|={n+'.sha256' for n in k};print('\n'.join('%s %s'%(a['id'],a['name']) for a in json.load(sys.stdin) if a.get('name','').endswith(('.pkg.tar.zst','.pkg.tar.zst.sha256')) and a['name'] not in k))" "$KEEP" \
| while read -r id name; do
[ -n "$id" ] || continue
echo "dropping superseded release asset: $name"
curl -fsS -o /dev/null -X DELETE "$API/releases/$RID/assets/$id" \
-H "Authorization: token $GITEA_TOKEN" || true
done
fi
fi
+7 -312
View File
@@ -2,35 +2,19 @@
# license-allowlist gate (CRA Annex I Part II: know your components; catch a bad dep the moment
# it lands).
# * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
# ⚠ ALL FIVE Rust lockfiles, each named with its own `--file`: a bare `cargo audit`
# reads only the root one, which is how the drivers lock went unscanned for so
# long despite already being in this job's `paths:` filter.
# * 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). 2026-08-14: docs-site's own deps
# are current (fumadocs/tanstack/react bumped; build + tsc + serve verified),
# but every remaining advisory is pinned INSIDE @unom/ui 0.9.2's dependency
# tree (@payloadcms/* → fast-uri/image-size/sharp, next 16.x, sass→immutable) —
# nothing bumpable from this lockfile, and overrides would fork what the CMS
# actually ships. The fix belongs in the @unom/ui package repo; flip this to
# blocking after a ui release with a clean payload chain lands here.
# * cargo-about → license-allowlist gate over the host + driver workspaces (about.toml `accepted`);
# * 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.)
# * miri → NON-BLOCKING interpretation of the few FFI-free leaf crates, one of them
# cross-compiled to MSVC layout. Not a supply-chain scan; it lives here because
# audit.yml already has exactly the shape it needs (weekly cron,
# workflow_dispatch, the rust-ci container, the same cache pattern) and because
# ci.yml runs on every push against a fleet where 37 of 46 jobs contend for
# ubuntu-24.04. See the `miri:` job below for what it does and does not buy.
# * c-abi-asan → NON-BLOCKING ASAN+LSAN run of the C ABI harness (tests/c/run.sh under
# PF_SAN=address): both sides of the abi.rs boundary instrumented at once, and
# the only automated check on its Box::into_raw/from_raw leak contract. Same
# here-not-ci.yml reasoning as miri — plus -Zbuild-std defeats sccache, so it
# must not ride the per-push leg.
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile/allowlist
# change, and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
@@ -52,9 +36,6 @@ on:
paths:
- 'Cargo.lock'
- 'packaging/windows/drivers/Cargo.lock'
- 'packaging/windows/pf-vkhdr-layer/Cargo.lock'
- 'tools/win-input-matrix/Cargo.lock'
- 'tools/hid-descriptor-dump/Cargo.lock'
- 'web/bun.lock'
- 'docs-site/bun.lock'
- 'sdk/bun.lock'
@@ -63,13 +44,6 @@ on:
- 'about.toml'
- '.gitea/workflows/audit.yml'
workflow_dispatch:
# NOTE on the `paths:` list above and the `miri:` job: `crates/pf-driver-proto/**` is deliberately
# NOT listed, even though that crate is what the Miri job exists to watch. `paths:` is a
# WORKFLOW-level filter — adding it would fire all six jobs (three bun trees, pnpm, cargo-audit,
# the license gate) on every driver-proto edit, onto a fleet where 37 of 46 jobs contend for
# ubuntu-24.04, to run one 2-minute job. Weekly cron + workflow_dispatch is the day-one cadence;
# revisit once the job has a green history, and if you do, prefer moving miri to its own workflow
# file over widening this filter.
jobs:
cargo-audit:
@@ -91,15 +65,7 @@ jobs:
run: |
git config --global --add safe.directory "$PWD"
command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit
# Bare `cargo audit` scans ONLY the root Cargo.lock. The other three Rust workspaces are
# separate locks and were silently never scanned — the drivers one despite already being
# in this job's `paths:` filter, so edits to it triggered a run that then ignored it.
# Each needs its own `--file`. `pf-vkhdr-layer` had no lockfile at all until 2026-08-13.
cargo audit
cargo audit --file packaging/windows/drivers/Cargo.lock
cargo audit --file packaging/windows/pf-vkhdr-layer/Cargo.lock
cargo audit --file tools/win-input-matrix/Cargo.lock
cargo audit --file tools/hid-descriptor-dump/Cargo.lock
bun-audit:
strategy:
@@ -125,27 +91,8 @@ jobs:
# 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).
#
# web carries two ignores, the ONLY ones in a blocking tree — both image-size advisories
# (GHSA-w3rx-r6r6-pgpr ICNS, GHSA-5p2g-fcmc-qvqq JXL/HEIF infinite-loop DoS). They are
# unfixable AND unreachable:
# * unfixable — the vulnerable range is `<= 2.0.2` and 2.0.2 IS latest; upstream has
# published no patched release, so no override can clear them.
# * unreachable — image-size rides in under `@unom/ui @payloadcms/richtext-lexical
# … payload`, and @payloadcms/richtext-lexical is a PEER of @unom/ui that only its
# `./richtext` export needs. The console imports section/toast/button/card/dialog/
# form/*/material/tabs — never `./richtext` — so payload is auto-installed peer weight
# that no bundle, and no request path, ever touches.
# Drop these the moment image-size ships a fix, or @unom/ui marks that peer optional
# (peerDependenciesMeta) and the chain leaves web/bun.lock entirely — either one makes the
# bare `bun audit` green again. Scoped per-tree so sdk/plugin-kit stay strictly fail-on-any.
- name: bun audit
run: |
if [ "${{ matrix.tree }}" = "web" ]; then
bun audit --ignore=GHSA-w3rx-r6r6-pgpr --ignore=GHSA-5p2g-fcmc-qvqq
else
bun audit
fi
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
@@ -211,255 +158,3 @@ jobs:
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
# ── Miri ─────────────────────────────────────────────────────────────────────────────────────
# WHAT THIS BUYS, precisely — one thing, and it is worth having:
# It interprets `pf-driver-proto` CROSS-COMPILED TO `x86_64-pc-windows-msvc`, on a Linux
# runner, with no Windows box anywhere in the loop. That crate is `#![forbid(unsafe_code)]`
# and is path-dep'd by BOTH the main workspace and the driver workspace, so it is the layout
# oracle for every frame and IOCTL crossing that boundary — and drift there is silent
# corruption, not a compile error. Nothing else in CI checks it at MSVC layout.
# On the first run ever performed against this repo it found a real defect: a layout test
# reading an align-8 struct out of an align-1 stack buffer, which had passed on every machine
# and every CI leg since it was written because a stack `[u8; 40]` usually lands 8-aligned.
#
# WHAT IT DOES NOT BUY — do not let anyone report this as unsafe coverage, and do not publish a
# "Miri coverage" percentage; it would be noise. Miri can execute on the order of 2% of the
# host's unsafe. It cannot run ash, windows-rs, ffmpeg, CUDA or the WDK, and in those crates
# the unsafe *is* the foreign call, so there is nothing for an interpreter to execute. This
# job is a targeted instrument for three leaf surfaces, not a safety net.
#
# NON-BLOCKING, deliberately, and via a step-level `||` — NOT job-level `continue-on-error`,
# which act_runner does not reliably honor (same reasoning as docs-site-audit above; a red job
# here would take the whole run red). Flip to blocking only after several weeks of green
# establish the nightly-drift rate.
#
# Do NOT add crates here because they merely compile under Miri. Add them because they contain
# pure-Rust unsafe or a layout contract worth interpreting. Explicitly excluded:
# * pf-bitstream — its compile did not finish in 27 min at 2.1 GB RSS, and it is
# `forbid(unsafe_code)`, so there is nothing to find. Do not re-add it.
# * pf-update-check — ring; every FFI crate — dies on the first foreign call. Structural.
# * punktfunk-core in bulk — `-- fec packet crypto` selects 63 tests and was killed at a
# 25-minute cap with not one test reported complete. Only the narrow
# `fec::gf8` selection below is affordable, and it was timed before it
# was committed. Do not widen this filter without timing the result.
#
# MEASURED, not estimated — 192.168.1.25 (Ubuntu, 8 cores), on the DATED toolchain this job
# actually installs, with a COLD target dir and a COLD sysroot cache (so each step's figure
# includes building the Miri sysroot it needs) and a warm cargo registry. Every step below has
# been run start to finish; nothing here is extrapolated:
# step A 21 + 12 + 4 pass 43 s
# step B 21 pass 26 s
# step C 2 pass 63 s
# TOTAL 132 s cold. Interpretation itself is ~10 s of that; the rest is compiling, plus ~38 s
# of one-time sysroot builds (21 s host + 17 s MSVC) that the cache below then carries.
# Warm, the three steps are ~6 s / ~3 s / ~10 s. `timeout-minutes: 30` is therefore vast
# headroom, kept deliberately so a first fully-uncached run — which additionally downloads a
# ~400 MB toolchain and the registry — cannot trip it.
# If you add a step, MEASURE IT FIRST. The estimate this job replaced said "under 15 s across
# all four steps" and was extrapolated from a partial run; the real punktfunk-core figure was
# >25 min. Extrapolation is exactly how that happened.
miri:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
env:
# A DATED nightly, bumped deliberately — exactly like rust-toolchain.toml, and for the same
# reason. The cache keys below carry this value, so bumping it self-invalidates them.
# ⚠ `nightly-<date>` names the day rustup PUBLISHED the build, and that build is compiled
# from the PREVIOUS day's commit. This pin therefore resolves to
# `rustc 1.99.0-nightly (969b803cb 2026-08-09)` [verified by installing it], NOT the
# `12c36e253 2026-08-10` that the rust-safety programme doc's §7 table cites — that figure
# came from the ROLLING `nightly` channel and was mislabelled as the dated one. Harmless,
# but do not "fix" the date to chase that hash: all three steps below were re-run and are
# green on the dated toolchain this job actually installs.
MIRI_TOOLCHAIN: nightly-2026-08-10
# A GUARD, not a fix for a present problem: audit.yml sets no sccache — only ci.yml does, at
# workflow level (ci.yml:27). `cargo-miri` REPLACES rustc and cannot be wrapped; it prints
# "Ignoring `RUSTC_WRAPPER` environment variable, Miri does not support wrapping" and
# carries on [verified]. This keeps a future workflow-level sccache from becoming a puzzle.
RUSTC_WRAPPER: ""
# -Zmiri-disable-isolation: pf-gpu's tests mkdir, and Miri aborts them without it [verified].
# -Zmiri-symbolic-alignment-check: the whole point — it refuses to let an accidentally
# favourable stack slot stand in for an alignment guarantee. This is the flag that caught
# the pf-driver-proto defect.
# NOTE the absence of -Zmiri-ignore-leaks. Miri leak-checks by DEFAULT, and that is the one
# leak-detection capability it offers here. None of the crates below leaks, so the job is
# green. The tree does contain DELIBERATE leaks (pf-umdf-util/src/section.rs `ViewCell`,
# gamepad_raii.rs leak-on-timeout) — when coverage ever reaches them, annotate those two
# sites; do not blanket-disable the check.
MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-symbolic-alignment-check
steps:
- uses: actions/checkout@v4
# Two caches, split on purpose so a Cargo.lock change does not re-download a ~400 MB
# toolchain. Both use their OWN `miri-` key prefix — never a shared one.
# The Miri sysroot is per-toolchain and per-target (two are built here: host + MSVC), so it
# belongs with the toolchain, not with the lockfile.
- name: cache the nightly toolchain + Miri sysroots
uses: actions/cache@v4
with:
path: |
/usr/local/rustup/toolchains/${{ env.MIRI_TOOLCHAIN }}-x86_64-unknown-linux-gnu
~/.cache/miri
key: miri-toolchain-v1-${{ env.MIRI_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
with:
path: /usr/local/cargo/registry
key: miri-registry-v1-${{ hashFiles('Cargo.lock') }}
restore-keys: miri-registry-v1-
# The image needs no change for this: ci/rust-ci.Dockerfile:51-54 installs via rustup and
# `chmod -R a+w`s both RUSTUP_HOME and CARGO_HOME, so a job can add a toolchain at runtime.
# `rust-src` is required — cargo-miri builds its sysroot from source, per target.
#
# This does NOT disturb the 1.96.0 pin: `cargo +<toolchain>` overrides rust-toolchain.toml
# for that single invocation only, so `cargo fmt` / `clippy` keep resolving 1.96.0 and the
# fmt-parity contract in CLAUDE.md is untouched. The two echo lines below keep that claim
# honest in the log. They are deliberately NOT `rustup show active-toolchain`: that command
# RESOLVES the toolchain file and would install the whole 1.96.0 toolchain just to print a
# line, in a job where every cargo call is `+$MIRI_TOOLCHAIN` and 1.96.0 is never needed.
# Deliberately NOT `rustup override set` — that writes persistent per-directory state into
# the runner's rustup config, which leaks into unrelated later jobs on a self-hosted fleet.
# Deliberately NOT a second rust-toolchain.toml in a subdirectory — that would apply to
# every cargo invocation under that subtree including fmt, which is the drift the root pin
# exists to prevent.
- name: install the pinned nightly + miri
run: |
git config --global --add safe.directory "$PWD"
rustup toolchain install "$MIRI_TOOLCHAIN" \
--profile minimal \
--component miri,rust-src \
--target x86_64-pc-windows-msvc
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
cargo +"$MIRI_TOOLCHAIN" --version
# A run that reports `0 passed` is a selection that matched nothing, not a success — that
# exact mistake has already cost one round-trip here. So each step below checks a zero exit
# AND that at least one target reported a non-zero pass count, which is what catches a
# crate rename or a `--` filter that stops matching. (Each step legitimately prints several
# `0 passed` lines too — the empty bin/doctest targets — so the check is "at least one
# non-zero", not "no zeroes".) Expected counts at the time of writing: 21 + 12 + 4.
- name: miri — FFI-free leaf crates (native)
run: |
set -o pipefail
ok=1
cargo +"$MIRI_TOOLCHAIN" miri test \
-p pf-driver-proto -p pf-host-config -p pf-gpu 2>&1 | tee /tmp/miri-native.log || ok=0
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-native.log || ok=0
[ "$ok" = 1 ] || echo "::warning::miri (FFI-free leaf crates, native) did not pass — non-blocking; see punktfunk-planning design/rust-safety-programme.md §7"
# THE step that justifies the job: pf-driver-proto at MSVC layout, on Linux, no Windows box.
# Expected: 21 passed. If this one ever goes red, treat it as a layout-contract break
# between the host and driver workspaces until proven otherwise.
- name: miri — pf-driver-proto at x86_64-pc-windows-msvc layout
run: |
set -o pipefail
ok=1
cargo +"$MIRI_TOOLCHAIN" miri test \
-p pf-driver-proto --target x86_64-pc-windows-msvc 2>&1 | tee /tmp/miri-msvc.log || ok=0
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-msvc.log || ok=0
[ "$ok" = 1 ] || echo "::warning::miri (pf-driver-proto @ MSVC layout) did not pass — non-blocking, but this is the layout oracle for every frame and IOCTL; see design/rust-safety-programme.md §7"
# fec-rs dispatches its GF(2^8) multiply through RUNTIME `is_x86_feature_detected!`. Under
# Miri that detection reports the COMPILE-TIME target features, so WITHOUT these RUSTFLAGS
# the step silently interprets the scalar fallback and is worthless. Verified both ways on
# 192.168.1.25: bare, `avx2=false ssse3=false`; with the flags, `avx2=true ssse3=true` and
# `_mm256_shuffle_epi8` genuinely executes under the interpreter. GFNI stays false either
# way — Miri does not implement it — so the gfni branch is simply not covered here.
#
# ⚠ x86_64 ONLY, and it must stay that way. A RUSTFLAGS env var OVERRIDES config rustflags
# ENTIRELY — it does not merge. That used to cost the aarch64 `--cfg aes_armv8` /
# `--cfg polyval_armv8` decrypt flags; the aes 0.9 / polyval 0.7 bump retired those cfgs
# (see the tombstone in .cargo/config.toml), so there is nothing left for an override to
# drop here. Keep the pin anyway: these target-features are meaningless off x86_64.
# Narrow selection is mandatory, not an optimisation: see the punktfunk-core note above.
- name: miri — punktfunk-core fec::gf8, taking the real AVX2/SSSE3 branches
env:
RUSTFLAGS: -C target-feature=+avx2,+ssse3
run: |
set -o pipefail
ok=1
cargo +"$MIRI_TOOLCHAIN" miri test \
-p punktfunk-core --lib -- fec::gf8 2>&1 | tee /tmp/miri-gf8.log || ok=0
grep -qE 'test result: ok\. [1-9][0-9]* passed' /tmp/miri-gf8.log || ok=0
[ "$ok" = 1 ] || echo "::warning::miri (punktfunk-core fec::gf8, AVX2/SSSE3) did not pass — non-blocking; see design/rust-safety-programme.md §7"
# ASAN + LSAN over the C ABI harness — §6.1 of design/rust-safety-programme.md, its rank-1
# tooling item. crates/punktfunk-core/tests/c/run.sh already proves the staticlib links and
# round-trips 4 frames byte-exact from C on every push (ci.yml); PF_SAN=address rebuilds BOTH
# sides instrumented — the staticlib on nightly with -Zsanitizer/-Zbuild-std (std itself
# included), the harness with clang -fsanitize — so ASAN sees the seam a Rust-only tool cannot,
# and LSAN (detect_leaks=1, the script's default) becomes the one automated check on abi.rs's
# Box::into_raw/from_raw leak contract.
# Proven to fail on 192.168.1.25: deleting a single punktfunk_session_free() from harness.c
# makes LSAN report the ~308 Rust-side allocations behind the handle and run.sh exit 1.
# What it does NOT see: the invalid-InputKind-discriminant UB at abi.rs (that needs the
# validator, tracked in §5 of the programme doc), and nothing GPU/Windows — this is the
# default-feature (quic-less, opus-less) core only.
c-abi-asan:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 30
env:
# The SAME dated pin as the miri job above, deliberately — one nightly date to bump for
# both jobs (they have no toolchain interaction; sharing the date just halves the chores).
SAN_TOOLCHAIN: nightly-2026-08-10
# Same guard as the miri job: audit.yml sets no sccache today, and -Zbuild-std could not
# use it anyway. Keeps a future workflow-level sccache from becoming a puzzle.
RUSTC_WRAPPER: ""
steps:
- uses: actions/checkout@v4
# Own `san-` key prefixes — never shared with the miri caches, per the cache-poisoning
# note there (and so an incomplete save from one job can never starve the other).
- name: cache the nightly toolchain
uses: actions/cache@v4
with:
path: /usr/local/rustup/toolchains/${{ env.SAN_TOOLCHAIN }}-x86_64-unknown-linux-gnu
key: san-toolchain-v1-${{ env.SAN_TOOLCHAIN }}
- name: cache the cargo registry
uses: actions/cache@v4
with:
path: /usr/local/cargo/registry
key: san-registry-v1-${{ hashFiles('Cargo.lock') }}
restore-keys: san-registry-v1-
# rust-src is required: -Zbuild-std compiles std from source so it is instrumented too —
# without that, LSAN cannot attribute allocations made inside std (Vec, Box, HashMap).
- name: install the pinned nightly + rust-src
run: |
git config --global --add safe.directory "$PWD"
rustup toolchain install "$SAN_TOOLCHAIN" --profile minimal --component rust-src
echo "root pin, untouched by this job: $(grep -E '^channel' rust-toolchain.toml)"
cargo +"$SAN_TOOLCHAIN" --version
# The image installs clang but Ubuntu does not always pull the compiler-rt sanitizer
# runtime with it (verified absent on a stock 26.04 box). Probe with an actual ASAN link
# and self-heal via apt if it fails — container jobs on this fleet run as root (the
# bun-audit job's apt-get above relies on the same fact).
- name: ensure clang's ASAN runtime
run: |
if ! echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe 2>/dev/null; then
apt-get update && apt-get install -y --no-install-recommends "libclang-rt-$(clang -dumpversion | cut -d. -f1)-dev"
echo 'int main(void){return 0;}' | clang -fsanitize=address -x c - -o /tmp/asan-probe
fi
# run.sh handles everything behind PF_SAN (nightly build, target path, clang flags,
# ASAN_OPTIONS=detect_leaks=1) and exits non-zero on any report. The grep is the
# proved-it-ran guard, same reasoning as the miri steps: a script change that silently
# skips the harness must not read as green. run.sh expects bash and PATH cargo — both true
# in this container. PF_SAN_TOOLCHAIN pins the script's `cargo +<toolchain>` to the dated
# nightly installed above — without it the script would ask for the ROLLING `nightly`
# channel, which this job deliberately does not install.
- name: C ABI harness under ASAN+LSAN
run: |
set -o pipefail
ok=1
PF_SAN=address PF_SAN_TOOLCHAIN="$SAN_TOOLCHAIN" \
bash crates/punktfunk-core/tests/c/run.sh 2>&1 | tee /tmp/asan-harness.log || ok=0
grep -q 'PASS: 4 frames round-tripped byte-exact' /tmp/asan-harness.log || ok=0
[ "$ok" = 1 ] || echo "::warning::c-abi-asan did not pass — non-blocking on day one; see design/rust-safety-programme.md §6.1. An LSAN report here means the abi.rs into_raw/from_raw contract broke."
+7 -2
View File
@@ -38,9 +38,14 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
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: |
+25 -145
View File
@@ -23,12 +23,6 @@ on:
# 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.
#
# RUSTC_WRAPPER covers RUST compilations and nothing else. The C/C++ half of this workspace —
# aws-lc-sys, openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys, pyrowave —
# was paid in full on every run until the CMAKE_*_COMPILER_LAUNCHER / CC_* wiring below existed.
# Linking is the third phase and is cacheable by nothing: that one is addressed in the builder
# images with mold (ci/cargo-config-mold.toml).
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
@@ -36,11 +30,6 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# Route CMake-driven C/C++ through the same cache. Safe at workflow level: it names no
# triple, and cmake-rs overrides it per-invocation with a `-D` flag when cc-rs reports a
# wrapper, so the two can never double-wrap into `sccache sccache cc`.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -51,25 +40,18 @@ jobs:
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 90
env:
# cc-rs recognises `sccache` as a compiler wrapper when it leads CC/CXX, and cmake-rs then
# forwards it as -DCMAKE_C_COMPILER_LAUNCHER, so this covers both build-script styles.
#
# ⚠ JOB-scoped, NOT workflow-scoped, and it must stay that way: the `rust-arm64` job below
# runs in the cross image, which sets CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc
# (ci/rust-ci-arm64cross.Dockerfile) — a wrapper that strips arm64 include dirs off
# HOST-targeted compiles so ffmpeg-sys-next's probe resolves against the amd64 headers.
# Setting this at workflow level would silently overwrite that wrapper and break the cross
# build in a way that looks like a header mismatch, not a CI config error.
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
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
@@ -114,14 +96,8 @@ jobs:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
# Namespaced `-ci-` so a fork PR's cargo-home cache can never land in the `cargo-home-`
# pool the SIGNED release builds (deb.yml / android.yml) restore: registry/src holds
# already-extracted crate sources that cargo compiles WITHOUT re-checksumming past
# `.cargo-ok`, so a poisoned entry would be arbitrary Rust source compiled into a release
# artifact with no Cargo.lock diff. security-review 2026-08-15 finding 5. (The definitive
# control is operator-side: Gitea's "require approval for fork PRs".)
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-ci-
key: cargo-home-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-
- uses: actions/cache@v4
with:
path: target
@@ -129,71 +105,27 @@ jobs:
# out of disk mid-build and actions/cache saved a truncated target/ (a dep's .rmeta
# went missing -> E0463 "can't find crate"). A suffix bump wouldn't help — restore-keys
# would fall back to the poisoned prefix — so the prefix itself is versioned.
#
# `-debug-`: THIS JOB BUILDS DEBUG. deb.yml builds RELEASE and used to share this exact
# key, with a comment claiming the release build "reuses ci.yml's clean artifacts" — it
# never could. actions/cache is first-saver-wins on an exact key and this job is the
# faster of the two, so what landed under the shared key was always a debug-only target/
# (target/debug, no target/release). deb.yml restored a tree containing nothing it could
# use and, because the key was already taken, never got to save its own — so every
# release build re-linked from scratch, forever. Splitting the profiles into separate key
# families is the fix; do not merge them again, however tempting the dedupe looks.
key: cargo-target-debug-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-debug-v3-${{ env.rustc }}-
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-v3-${{ env.rustc }}-
- name: Format
run: cargo fmt --all --check
# rust-safety WP2c: three textual gates for classes no lint covers — unsafe fn markers
# carrying no contract, panic across an extern boundary (an abort since 1.81), and
# process-global safe APIs (env::set_var & co, count-ratcheted). Pure grep/awk, no cargo.
# Both failure modes were demonstrated before this became blocking (planted instances).
- name: Unsafe-hygiene grep gates
run: sh scripts/ci/check-unsafe-hygiene.sh
- name: Clippy (deny warnings)
run: cargo clippy --workspace --all-targets --locked -- -D warnings
# WP19 (rust-safety): the hardened NATIVE-ONLY host — no Moonlight-compat planes, no
# `rusty_enet` (transpiled C ENet), no `rsa`. Kept compiling here so the cfg boundary can't
# rot, and the dependency claim is ASSERTED, not assumed: `cargo tree -i` must find neither
# crate in the native-only graph (it exits non-zero with "nothing depends on" — inverted).
- name: Clippy + tree (native-only host, no gamestream feature)
run: |
cargo clippy -p punktfunk-host --no-default-features --features pyrowave \
--all-targets --locked -- -D warnings
if cargo tree -p punktfunk-host --no-default-features --features pyrowave \
--locked -i rusty_enet 2>/dev/null | grep -q rusty_enet; then
echo "native-only build still depends on rusty_enet"; exit 1; fi
if cargo tree -p punktfunk-host --no-default-features --features pyrowave \
--locked -i rsa 2>/dev/null | grep -q "^rsa"; then
echo "native-only build still depends on rsa"; exit 1; fi
- name: Build
run: cargo build --workspace --locked
- name: Test (unit + loopback + proptest + C ABI harness)
run: cargo test --workspace --locked
# The deep half of the docs-drift gates (the `docs-drift` job checks the docs-site copy
# and the textual rest): the committed spec must match what the binary actually serves.
# Build already compiled punktfunk-host with default features, so this re-links at worst.
# Byte diff on purpose — the generator is deterministic, and if that ever stops being
# true it deserves to surface here.
- name: OpenAPI spec drift gate
run: |
cargo run -p punktfunk-host --locked -- openapi > /tmp/openapi.regen.json
diff -u api/openapi.json /tmp/openapi.regen.json >/dev/null || {
echo "::error::api/openapi.json is stale — regenerate: cargo run -p punktfunk-host -- openapi > api/openapi.json && cp api/openapi.json docs-site/public/openapi.json"
exit 1
}
# 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 the `undocumented_unsafe_blocks` deny
# (now hoisted into [workspace.lints]) — pf-encode's stated unsafe-proof gate —
# `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.)
#
@@ -233,17 +165,7 @@ jobs:
- name: Verify generated header is committed & up to date
run: |
cargo build -p punktfunk-core --locked >/tmp/core-build.log 2>&1 \
|| { cat /tmp/core-build.log; exit 1; }
cat /tmp/core-build.log
# build.rs demotes a cbindgen failure to a warning and then writes NOTHING — the
# checked-in header stays untouched and the drift check below stays green while the
# header is silently stale. So first assert the regeneration actually happened.
# (cargo replays build-script warnings from cache, so this holds on cached builds too.)
grep -q "punktfunk-core: wrote" /tmp/core-build.log
if grep -q "cbindgen failed" /tmp/core-build.log; then
echo "cbindgen failed to parse the ABI surface — header NOT regenerated" && exit 1
fi
cargo build -p punktfunk-core --locked
git config --global --add safe.directory "$PWD"
git diff --exit-code include/punktfunk_core.h \
|| (echo "include/punktfunk_core.h is stale — commit the regenerated header" && exit 1)
@@ -266,9 +188,14 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
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"
@@ -277,28 +204,14 @@ jobs:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
# Namespaced `-ci-` so a fork PR's cargo-home cache can never land in the `cargo-home-`
# pool the SIGNED release builds (deb.yml / android.yml) restore: registry/src holds
# already-extracted crate sources that cargo compiles WITHOUT re-checksumming past
# `.cargo-ok`, so a poisoned entry would be arbitrary Rust source compiled into a release
# artifact with no Cargo.lock diff. security-review 2026-08-15 finding 5. (The definitive
# control is operator-side: Gitea's "require approval for fork PRs".)
key: cargo-home-ci-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-ci-
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.
#
# `-debug-` in the name is load-bearing. This job builds DEBUG (clippy + a
# `cargo build`), while deb.yml's arm64 leg builds RELEASE into the same
# target/aarch64-unknown-linux-gnu tree. They used to share this exact key, and
# actions/cache is first-saver-wins on an exact key: this job finishes in ~1.5 min and
# saved a debug-only tree, so the .deb leg's release artifacts were NEVER persisted and
# it re-linked everything from sccache on every run. Same disease as the amd64 pair —
# see the note on deb.yml's `cargo-target-release-v1-` key.
key: cargo-target-arm64-debug-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-arm64-debug-v1-${{ env.rustc }}-
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: |
@@ -314,14 +227,6 @@ jobs:
cargo build --release --target aarch64-unknown-linux-gnu --locked \
-p punktfunk-client-session --no-default-features
# Visibility only — but the ONLY way to tell a warm cache from a cold one after the fact.
# Every Rust job in this repo ends with this line for that reason; a hit rate that quietly
# collapses (a toolchain bump, a flag change, an S3 outage) is otherwise invisible and just
# looks like "CI got slower".
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
web:
runs-on: ubuntu-24.04
container:
@@ -403,28 +308,3 @@ jobs:
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
- name: bun.nix drift gate
run: sh scripts/ci/check-bun-nix.sh
# Docs drift gates — pure git-grep textual checks, no cargo, no bun install (the deep half,
# regenerating the OpenAPI spec from the built host, rides in the `rust` job above). Same
# reasoning as bun-nix for being UNFILTERED: docs drift arrives through commits that look
# unrelated to docs — a renamed env var, a removed subcommand, a moved page.
docs-drift:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
steps:
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
- uses: actions/checkout@v4
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
# still exist, data/platforms.json parses.
- name: Docs drift gates
run: sh scripts/ci/check-docs-drift.sh
# Internal links only: /docs/* page links in docs-site content, relative file links in
# the repo's markdown. External URLs and #anchors are deliberately not checked.
- name: Docs link check
run: sh scripts/ci/check-docs-links.sh
+38 -310
View File
@@ -1,6 +1,6 @@
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Debian and
# Ubuntu boxes get new builds via `apt update && apt upgrade`. Five jobs; the four builders all
# publish to the same apt distribution/component, and the fifth checks the result:
# 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).
@@ -11,17 +11,8 @@
# 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 — and, for free, on Debian 13.
# (A 26.04-built host .deb is uninstallable on 24.04 — the reason this job
# exists; see packaging/debian/README.md.)
# build-publish-gamescope
# — the patched `punktfunk-gamescope`, on DEBIAN 13. It lived in the host job
# until 2026-08 and never once succeeded there: noble's wayland is 1.22.0
# and the vendored wlroots floors it at 1.23.1, so v0.26.0 and v0.27.0 both
# shipped without the package while the docs told apt users to install it.
# smoke-install — installs what was just published, from the registry, in pristine
# ubuntu:24.04 / ubuntu:26.04 / debian:trixie images. Nothing here used to
# install a package it built, which is how both of the above survived.
# 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
@@ -79,11 +70,6 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# CMake-driven C/C++ through the same cache (aws-lc-sys, the vendored libopus). Workflow level
# is safe — it names no triple; the CC_*/CXX_* half is per-job below, because the arm64 leg's
# image needs its own CC_x86_64 wrapper. See ci.yml's `rust` job for the full note.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -94,18 +80,18 @@ jobs:
container:
image: 192.168.1.58:5010/punktfunk-rust-ci:latest
timeout-minutes: 90
env:
# ⚠ Job-scoped, never workflow-scoped: build-publish-client-arm64 runs in the cross image,
# whose own CC_x86_64_unknown_linux_gnu=pf-host-cc must survive. See ci.yml's `rust` job.
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
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).
@@ -143,9 +129,7 @@ jobs:
apt-get install -y --no-install-recommends dpkg-dev python3 \
libgtk-4-dev libadwaita-1-dev libsdl3-dev
# The cargo-home (registry/git) cache IS shared with ci.yml — those are pure downloads,
# profile-independent, and deduping them across the fleet is the whole point. The target
# cache is NOT; see below.
# Share ci.yml's cache keys so the release build reuses its registry + target artifacts.
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
@@ -158,20 +142,10 @@ jobs:
- uses: actions/cache@v4
with:
path: target
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml).
#
# `-release-`, and NOT ci.yml's key. This step used to read
# `cargo-target-v3-<rustc>-<lock>` — byte-identical to ci.yml's — under a comment saying
# the release build "reuses its clean artifacts". It never did, and could not: ci.yml
# builds DEBUG, this job builds RELEASE, and actions/cache is first-saver-wins on an
# exact key. ci.yml's `rust` job finishes in ~6 min against this job's ~7-8, so ci.yml
# always won the save, this job always restored a target/ with an empty target/release,
# and — the expensive half — its own release artifacts were then never persisted,
# because the key it would have saved under was already taken. Every canary .deb has
# therefore been a from-scratch release build (sccache-assisted, but every link and
# every build script re-run) for as long as both keys have existed.
key: cargo-target-release-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-release-v3-${{ env.rustc }}-
# -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml). Shares the
# key with ci.yml so the release build reuses its clean artifacts.
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-v3-${{ env.rustc }}-
- name: Build release clients
env:
@@ -189,31 +163,7 @@ jobs:
cargo build --release --locked \
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
# ── The web console, built ONCE per (web+sdk content, bun) rather than once per job ────────
# This bundle was being rebuilt six times on every push — ci.yml, here, both RPM legs, arch,
# and the docker app image — at ~2.5 min each, for output that is a pure function of web/ and
# sdk/. windows-host.yml has cached it this way for a while; this is the same arrangement for
# the Linux packaging legs, sharing one key family so a hit in one warms the others.
#
# The bun version is IN THE KEY. Each builder image installs its own bun (rust-ci, fedora-rpm
# and arch-ci each run the bun.sh installer at image-build time), so without it a bundle built
# by one image's bun could be shipped by a job running a different one. They are usually the
# same version and do share; when they diverge, they simply stop sharing instead of silently
# mixing.
- name: Web console cache key
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
- name: Build + smoke-boot web console (bun preset)
# Skipped when the cache already holds this exact (web+sdk, bun) bundle — the assertion step
# below is what makes that safe. The bundle in the cache was smoke-booted by the run that
# produced it, and ci.yml's `web` job still builds and tests the console on every push.
if: steps.webconsole.outputs.cache-hit != 'true'
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
# (our Bun.serve TLS entry), so prove the build IS a bun bundle and serves /login.
# No TLS env here, so the custom entry binds plain HTTP — the smoke curl stays simple.
@@ -226,12 +176,7 @@ jobs:
}
export PATH="$HOME/.bun/bin:$PATH"
cd web
# --ignore-scripts, like every other web install in CI (ci.yml, web-screenshots.yml,
# windows-host.yml, the SDK installs). This was the ONE site still running lifecycle
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
# neither consumes nor commits, whose only effect here is to make the install depend on
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
bun install --frozen-lockfile --ignore-scripts
bun install --frozen-lockfile
bun run build
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
@@ -243,22 +188,6 @@ jobs:
echo "web console smoke: /login -> $code"
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under bun"; exit 1; }
# ⚠ NOT optional, and it must run on BOTH paths (cache hit and fresh build). The packaging
# scripts treat a missing web/.output as "build it yourself", which is right for a local run
# and wrong here: it would silently turn a broken cache restore into a slow, quiet rebuild, or
# — with the build step skipped and the restore empty — into a package with no console at all.
# windows-host.yml shipped exactly that in 0.22.1/0.22.2 (an unset WEB_OUTPUT_DIR behind a
# single Write-Host), which is why its equivalent step throws too. Fail loudly instead.
- name: The console must exist (cache hit or fresh build)
run: |
if [ ! -f web/.output/server/index.mjs ]; then
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
exit 1
fi
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
echo "web console present: $(du -sh web/.output | cut -f1)"
- name: Build .debs
run: |
export PATH="$HOME/.bun/bin:$PATH"
@@ -269,13 +198,6 @@ jobs:
# 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
# Visibility only. With the target cache now actually saving release artifacts (see the
# cache key note above), this is how a regression in that arrangement becomes visible:
# a run that suddenly reports thousands of misses is a cache that stopped restoring.
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -320,17 +242,18 @@ jobs:
container:
image: 192.168.1.58:5010/punktfunk-rust-ci-noble:latest
timeout-minutes: 90
env:
# ⚠ Job-scoped — see build-publish above and ci.yml's `rust` job.
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
steps:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
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: |
@@ -366,12 +289,9 @@ jobs:
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). It is also the only job using
# this prefix, so — unlike the amd64/arm64 pairs above — it has always been able to save
# and restore its own release artifacts. `-release-` is spelled out anyway so the whole
# file reads consistently and nobody "unifies" it back into a shared key later.
key: cargo-target-noble-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-noble-release-v1-${{ env.rustc }}-
# 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:
@@ -390,14 +310,8 @@ jobs:
# 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.
#
# punktfunk-encode-worker IS in this invocation: it is the capability-carrying PyroWave
# encode worker that ships next to the host in /usr/bin, and build-deb.sh only builds it
# if the artifact is missing — building it here keeps it on the same sccache pass as the
# host. Unlike the tray it shares the host's dependency graph by design (v1 accepts that
# the worker links the same FFmpeg), so feature unification here is harmless.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-encode-worker
-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
@@ -406,23 +320,6 @@ jobs:
run: |
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
# Visibility only — placed here, right after the last compile, rather than at the end of the
# job: the gamescope gate below must stay the final step (see its own note).
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
# capability metadata — the postinst applies them — so this reads the postinst that will
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
# from exactly that postinst and killed every KDE desktop session while every board stayed
# green: host must carry NOTHING, worker exactly cap_sys_nice=ep. `--self-test` first so a
# guard that can no longer fail takes the job down instead of waving the release through.
- name: Assert the capability matrix (host .deb)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -450,112 +347,6 @@ jobs:
upsert_asset "$RID" "$DEB"
done
# ---------------------------------------------------------------------------------------------
# `punktfunk-gamescope` for apt — the patched build that gives a gamescope session HDR, a cursor,
# and the client's real refresh rate instead of a hardcoded 60 Hz. Same reasoning as the RPM leg
# in rpm.yml: without a package, a Debian/Ubuntu box has no route to it except compiling by hand.
#
# ⚠ THIS IS ITS OWN JOB, ON DEBIAN 13, BECAUSE THE NOBLE BUILD COULD NEVER HAVE WORKED.
# It used to be a few best-effort steps inside build-publish-host (Ubuntu 24.04), where it failed
# every single time:
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
# Our pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1; noble ships 1.22.0 and
# always will. v0.26.0 AND v0.27.0 both shipped with no gamescope .deb — while the release notes
# and docs-site told apt users to install it — because every rung of that path was a `::warning::`
# that returned 0, and the one hard gate ran last by design (so good artifacts still publish) and
# was simply never acted on. Moving it to trixie makes the build possible; making it a job of its
# own makes its failure visible instead of a footnote in someone else's log.
#
# Debian 13 is the OLDEST apt distro the tree configures on (wayland 1.23.1, libxcb-errors,
# libdisplay-info 0.2). The binary it produces needs GLIBC_2.38 and links no libstdc++, so what
# actually bounds it is wayland: Debian 13 and Ubuntu 26.04 YES, Ubuntu 24.04 NO — and 24.04
# could not run this binary however it was built, so nothing was lost by leaving noble.
build-publish-gamescope:
runs-on: ubuntu-24.04
container:
image: 192.168.1.58:5010/punktfunk-gamescope-trixie:latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# Byte-identical to the other jobs' version step (pf-version.sh is deterministic per commit)
# — but only DISTRIBUTION is used here. The package version is the gamescope upstream
# version + our patch level, which build-gamescope-deb.sh derives itself; it deliberately
# does NOT follow the punktfunk version line, because this package moves on its own cadence.
- name: Channel
run: |
git config --global --add safe.directory "$PWD"
case "$GITHUB_REF" in
refs/tags/v*) DIST=stable ;;
*) DIST=canary ;;
esac
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "gamescope -> apt distribution '$DIST'"
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
# Keyed `-trixie-` so the noble cache entries (which only ever held misses) can't be hit.
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-trixie-${{ hashFiles('packaging/gamescope/**') }}
# NOT best-effort, unlike the noble version of this step. Every dependency now comes from the
# image (which asserts the wayland floor at build time), so a failure here is a real
# regression in the tree or the pin — exactly the thing the previous arrangement hid.
- name: Build the patched gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# `--extra-fallback libdisplay-info` is what makes ONE .deb serve both Debian 13 and
# Ubuntu 26.04. Built against the distro's copy, the package picks up
# `Depends: libdisplay-info2 (>= 0.2.0)` on trixie — and Ubuntu 26.04 carries
# libdisplay-info **3** (0.3.0), so apt refuses it there ("Depends libdisplay-info2 …" —
# measured, not predicted). gamescope vendors the library as a submodule, so linking the
# vendored copy drops the dependency entirely. Same reasoning the build script already
# applies to wlroots: a binary we SHIP must not follow the build host's shared libraries.
run: |
bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
--extra-fallback libdisplay-info
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
# beside the compositor, which is the only route to an HDR10 swapchain for a nested game.
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
# The binary must RUN, not merely link: `--version` is what the old job used as its ship
# gate, and it is the cheapest proof that the static-libstdc++ trick and the vendored wlroots
# actually produced a working compositor.
- name: Build the .deb
run: |
gs-cache/usr/bin/punktfunk-gamescope --version
bash packaging/debian/build-gamescope-deb.sh --stage gs-cache
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for DEB in dist/punktfunk-gamescope_*.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 gamescope to $OWNER/debian $DISTRIBUTION/$COMPONENT"
- name: Attach the gamescope .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/punktfunk-gamescope_*.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
@@ -572,14 +363,14 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
#
# NOTE this job deliberately sets no CC_x86_64_unknown_linux_gnu: the cross image already
# points it at /usr/local/bin/pf-host-cc, which is what keeps ffmpeg-sys-next's HOST probe
# from picking up arm64 include dirs. The target-side compiles go through
# CC_aarch64_unknown_linux_gnu (also set by the image) and are not sccache-wrapped.
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
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.
@@ -616,13 +407,8 @@ jobs:
path: target
# Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/
# and must never share the amd64 jobs' target cache.
#
# `-release-`: this key was previously identical to ci.yml's `rust-arm64` key, which
# builds DEBUG (clippy) and finishes in ~1.5 min against this job's ~5. Exactly the
# amd64 collision described on the release key above — ci.yml won every save, this job
# restored a tree with no release artifacts and could never persist its own.
key: cargo-target-arm64-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-arm64-release-v1-${{ env.rustc }}-
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:
@@ -635,10 +421,6 @@ jobs:
readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \
| grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; }
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -665,57 +447,3 @@ jobs:
for DEB in dist/*.deb; do
upsert_asset "$RID" "$DEB"
done
# ---------------------------------------------------------------------------------------------
# Does the thing we just published actually INSTALL? Nothing in this repo asked that before, and
# the cost of not asking was two independent, long-lived facts nobody knew:
# * `punktfunk-host` has installed cleanly on Debian 13 for a long time — while docs-site said
# "Debian isn't a supported target … nobody has verified it".
# * `punktfunk-gamescope` was missing from apt entirely across two releases.
# Both are exactly what a five-minute install check catches, so it is now a job.
#
# It runs on the RUNNER (no `container:`) and drives docker directly — the same access
# docker.yml's image builds use — because the check must happen in a pristine distro image, not
# in a builder image with the deps already present.
#
# It installs FROM THE REGISTRY, after the publish jobs, rather than from a local .deb: that
# exercises the real path a user takes (repo key, apt distribution, dependency resolution against
# the distro's own package set), and it matches this workflow's established order — publish the
# good artifacts first, go red afterwards, never let a gate withhold a shipping fix.
smoke-install:
needs: [build-publish, build-publish-host, build-publish-gamescope]
# `needs` for ORDER only — this must still run when a builder went red, or the failure that
# matters most (a package that did not publish) is exactly the one that skips its own check and
# leaves the run looking merely "partly red" instead of saying what a user would hit.
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Recomputes the SAME version string the builder jobs stamped — pf-version.sh is
# deterministic per commit and GITHUB_RUN_NUMBER is shared across a run's jobs — so the check
# below can insist the registry is serving THIS run's build. Without that, a smoke job that
# beats the index regeneration installs the previous build, passes, and proves nothing.
- name: Channel + expected version
run: |
git config --global --add safe.directory "$PWD"
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 "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
echo "EXPECT_VERSION=$V" >> "$GITHUB_ENV"
echo "expecting $V in apt distribution '$DIST'"
# The support matrix, asserted rather than asserted-in-prose. Each row names the packages
# that MUST install on that distro; anything absent from the row is expected not to and is
# not checked here (the client's glibc 2.43 floor keeps it off 24.04 and Debian 13 —
# see docs-site/content/docs/debian.md).
- name: Install from the apt registry on every supported distro
run: bash scripts/ci/deb-install-smoke.sh
env:
PF_APT_DISTRIBUTION: ${{ env.DISTRIBUTION }}
PF_EXPECT_VERSION: ${{ env.EXPECT_VERSION }}
+13 -59
View File
@@ -1,23 +1,17 @@
# Deploy-only: bring up the unom-1 pieces that live in THIS repo but whose normal deploys
# are coupled to heavy build workflows — docs to docker.yml's 5-image matrix, the flatpak
# server to flatpak.yml's full flatpak-builder run, the nix cache to nix.yml's full Rust
# build. This workflow does NONE of those builds: it just (re)places the compose files and
# pulls the already-published images, so unom/infra's deploy-all can bring a fresh unom-1
# fully up in a single dispatch without triggering those rebuilds.
# Deploy-only: bring up the two unom-1 pieces that live in THIS repo but whose normal
# deploys are coupled to heavy build workflows — docs to docker.yml's 5-image matrix,
# the flatpak server to flatpak.yml's full flatpak-builder run. This workflow does
# NEITHER build: it just (re)places the compose files and pulls the already-published
# images, so unom/infra's deploy-all can bring a fresh unom-1 fully up in a single
# dispatch without triggering those rebuilds.
#
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
# brings it up on :3220.
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
# on the next client build, or restored from the unom-1 backup
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
# an empty repo until then; that is expected.
# nix-cache -> brings up the caddy:2-alpine Nix binary cache on :3250. Same content/config
# split: the cache CONTENT is republished by nix.yml on the next main push
# that moves the flake. An empty cache is harmless — every path 404s and
# users build from source, which is the pre-cache status quo.
# winget -> brings up the winget REST source on :3240; catalogue shipped by
# windows-host.yml on stable tags.
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
# brings it up on :3220.
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
# on the next client build, or restored from the unom-1 backup
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
# an empty repo until then; that is expected.
#
# Dispatched by unom/infra scripts/deploy-all.sh: `dispatch-and-wait.sh punktfunk
# deploy-services.yml`. Uses the same secret set docker.yml/flatpak.yml already rely on:
@@ -106,46 +100,6 @@ jobs:
cd ~/unom-flatpak
docker compose -f compose.production.yml up -d
nix-cache:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync nix cache 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-nix-cache/ (drop the packaging/nix/server/ prefix).
source: "packaging/nix/server/compose.production.yml,packaging/nix/server/Caddyfile,packaging/nix/server/prune.sh"
target: "~/unom-nix-cache"
strip_components: 3
overwrite: true
- name: Start nix binary cache server
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
# ./site (the cache CONTENT) is NOT shipped here — nix.yml rsyncs it on each main push
# that moves the flake, same content/config split as the flatpak repo and the winget
# catalogue. Ensure the bind-mount source exists so caddy starts; an empty cache is
# harmless, it just 404s every path and users build from source as they do today.
mkdir -p ~/unom-nix-cache/site/nar
cd ~/unom-nix-cache
docker compose -f compose.production.yml up -d
# A cache that 404s a miss is healthy; one that cannot answer at all is not.
sleep 3
curl -fsS http://127.0.0.1:3250/nix-cache-info \
|| echo "NOTE: no cache content yet - push to main with the flake touched to populate it"
winget:
runs-on: ubuntu-24.04
timeout-minutes: 10
-19
View File
@@ -96,12 +96,6 @@ jobs:
# (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
# Debian 13 gamescope builder. The gamescope .deb used to be built in the noble image
# and NEVER once succeeded there — noble's wayland is 1.22.0 and the vendored wlroots
# 0.19.3 floors it at 1.23.1, so two releases shipped without the package. trixie is the
# oldest apt distro the tree configures on. Consumed by deb.yml's build-publish-gamescope.
- image: punktfunk-gamescope-trixie
dockerfile: ci/gamescope-trixie.Dockerfile
- image: punktfunk-fedora-rpm
dockerfile: ci/fedora-rpm.Dockerfile
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
@@ -118,13 +112,6 @@ jobs:
# arch.yml runs in it; ~1 GB of per-run pacman traffic became image layers.
- image: punktfunk-arch-ci
dockerfile: ci/arch-ci.Dockerfile
# Flatpak builder (flatpak + flatpak-builder + ostree + the GNOME 50 runtime/SDK
# and the rust-stable/llvm20 extensions) — flatpak.yml runs in it. ~5 min of
# per-run dnf plus a 168 s restore of a 1.5 GB runtime cache became image layers;
# the ci/ tree is also where the runtime pins now live, so a GNOME bump in the
# manifest means bumping this Dockerfile's ARGs in the same commit.
- image: punktfunk-flatpak-ci
dockerfile: ci/flatpak-ci.Dockerfile
steps:
- uses: actions/checkout@v4
@@ -370,12 +357,6 @@ jobs:
envs: REGISTRY_TOKEN
script: |
set -euo pipefail
# Log out on EVERY exit path: unlike the ephemeral LAN-registry runners, this is a
# long-lived internet-facing VM, so a `write:package` PAT left base64-encoded in
# ~/.docker/config.json is credential-at-rest on the most exposed host in the estate.
# The LAN jobs above already `docker logout`; this one omitted it. security-review
# 2026-08-15 finding 14.
trap 'docker logout git.unom.io || true' EXIT
printf '%s' "$REGISTRY_TOKEN" | docker login git.unom.io -u enricobuehler --password-stdin
cd ~/punktfunk-docs
docker compose -f compose.production.yml pull docs
+67 -126
View File
@@ -7,7 +7,7 @@
# Gitea has NO flatpak/ostree registry, so the bundle lives in the generic registry:
# PUT https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/<version>/<file>
# GET https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/<version>/<file>
# On tags the bundle is ALSO attached to the Gitea release (mirrors apple.yml's DMG).
# On tags the bundle is ALSO attached to the Gitea release (mirrors release.yml's DMG).
#
# PRIVILEGED-BUILD CONSTRAINT: flatpak-builder runs bubblewrap, which needs user namespaces.
# In a Gitea/act_runner Docker executor that means the job container must be --privileged
@@ -37,7 +37,7 @@ on:
# binary's dependency closure must be listed here — including the native decode rungs, or a
# commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly
# stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core
# and never enters the Linux closure (windows-client.yml carries it instead).
# and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead).
paths:
- 'clients/linux/**'
- 'clients/session/**'
@@ -68,18 +68,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
container:
# ci/flatpak-ci.Dockerfile — Fedora 43 with flatpak + flatpak-builder + ostree + node,
# and the manifest's Flathub runtime set already installed into /root/.local/share/flatpak.
# MEASURED on run 18855: the two dnf steps this replaces cost 303 s and the actions/cache
# restore of those runtimes another 168 s, on a job whose actual compile is ~6 min.
#
# ⚠ BOOTSTRAP (same rule as every other LAN builder — see docker.yml's header): the
# registry must already hold :latest. The commit that introduced this image also touches
# this file, so both workflows fire together on that one merge and this job can lose the
# race to docker.yml's push. That first run fails on the image pull; re-run it once
# docker.yml is green. Nothing self-heals it here — a `dnf install` fallback would only
# paper over a stale image, and the container never starts to run one anyway.
#
# 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
@@ -92,7 +81,7 @@ jobs:
# 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: 192.168.1.58:5010/punktfunk-flatpak-ci:latest
image: fedora:43
options: --privileged --network host
steps:
# DNS fix — MUST run before any network step. fedora:43's nsswitch.conf is
@@ -105,12 +94,11 @@ jobs:
# was masked as an intermittent "busy runner drops DNS" and papered over
# with retry.sh — but it's deterministic on a runner where the resolve
# module tips that way (surfaced when jobs began landing on home-runner-2).
# Drop the `resolve` entry so host lookups use plain `dns`. The image already
# ships it fixed (and, unlike this job, applies the sed AFTER its last dnf — the
# whack-a-mole that needed a second copy in the old Tooling step, because a systemd
# upgrade's authselect trigger regenerates the file). Kept as a cheap idempotent
# guard for a :latest that lags a ci/ change.
- name: Fix container DNS (drop nss-resolve — baked, this is a guard)
# Drop the `resolve` entry so host lookups use plain `dns`. NOTE: this alone is not
# 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
@@ -126,30 +114,27 @@ jobs:
# genuine upstream blips.
cat /etc/resolv.conf || true
# node comes from the image now (act_runner execs a JS action with the CONTAINER's
# node and injects none of its own), so checkout needs no install step ahead of it.
# 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.
- name: node for the JS actions
run: dnf -y install nodejs
- uses: actions/checkout@v4
- name: Tooling (baked — assert, don't install)
- name: Tooling
run: |
# A GUARD, not an install. Everything below used to be dnf'd here (303 s/run with the
# node step); it now lives in ci/flatpak-ci.Dockerfile. If :latest ever lags a change to
# that file the fix is to let docker.yml republish it — dnf-ing on top of a stale image
# would hide the drift and cost the time this image exists to save. Fail loudly instead.
for t in flatpak flatpak-builder ostree git python3 gpg rsync ssh curl jq node; do
command -v "$t" >/dev/null \
|| { echo "::error::$t is missing from punktfunk-flatpak-ci — docker.yml must republish :latest"; exit 1; }
done
python3 -c 'import aiohttp, tomlkit' \
|| { echo "::error::flatpak-cargo-generator.py's deps (aiohttp/tomlkit) missing from the image"; exit 1; }
# The runtimes are baked too, but a miss here is survivable: the prefetch step below
# pulls whatever is absent from Flathub, retried. Warn rather than fail — silently
# paying ~1.5 GB per run is the failure mode worth naming.
flatpak list --user --columns=ref | grep -q . \
|| echo "::warning::no Flathub runtimes in the image — the prefetch step will download them (~1.5 GB)"
# flatpak-cargo-generator.py (master) needs aiohttp + tomlkit (NOT the old `toml`).
# gnupg2/rsync/openssh-clients: sign the OSTree repo + rsync it to unom-1 (see the deploy step).
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
gnupg2 rsync openssh-clients
# Belt-and-suspenders: keep nsswitch on plain `dns` even if this dnf transaction pulled
# in a fresh systemd-resolved (it does — flatpak recommends xdg-desktop-portal ->
# pipewire/wireplumber -> systemd-networkd/-resolved). Verified on the real runner
# (2026-07-11) this dnf install does NOT actually rewrite /etc/nsswitch.conf — no
# authselect trigger fires — so this line alone was never the fix for the failures
# below. See the retry.sh bump for the real cause.
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# Flathub provides the GNOME runtime/SDK + the rust-stable and llvm20 extensions.
# The remote is baked as well; this stays because it is a no-op when present and the
# one network call cheap enough to keep as a guard.
#
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
@@ -168,12 +153,17 @@ jobs:
git config --global --add safe.directory "$PWD"
# This job was the fleet's single heaviest network consumer: every run re-downloaded
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and every
# crate source. The runtimes were cached here from ~/.local/share/flatpak until
# 2026-08-17 and are now IMAGE LAYERS instead (ci/flatpak-ci.Dockerfile). That cache
# is not merely redundant now, it is harmful: restoring it would spend 168 s
# overwriting the baked installation with an older copy of itself. The crate sources
# stay cached — they are keyed on Cargo.lock, which no image can pin.
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and
# every crate source. Both live in well-defined directories, both are idempotently
# verified/extended by the steps below, and the central cache server restores them
# at LAN speed — so cache them. Keyed on what actually pins them: the manifest tree
# (runtimes/extensions) and manifest+Cargo.lock (crate sources + builder state).
- name: Cache Flathub runtimes
uses: actions/cache@v4
with:
path: ~/.local/share/flatpak
key: flatpak-runtimes-${{ hashFiles('packaging/flatpak/**') }}
restore-keys: flatpak-runtimes-
- name: Cache flatpak-builder state (crate sources, ccache)
uses: actions/cache@v4
with:
@@ -225,46 +215,39 @@ jobs:
# repo therefore produces a single-branch summary that CLOBBERS the other channel on the
# server — the exact bug that made `app/io.unom.Punktfunk/x86_64/stable` unresolvable
# ("No such ref") after a canary main-push overwrote the post-release summary, even though
# the stable commit's objects were still on disk. Fix: seed every published channel into
# the local repo first; the build below then only ADDS this run's commit and the
# regenerated+signed summary keeps both channels.
#
# HOW, and why not the rsync this replaces. Mirroring the whole published repo down over
# ssh transferred the entire thing every run, because the local repo starts empty and has
# nothing to diff against — rsync said so itself on run 18855:
# received 3,835,169,389 bytes … total size is 3,845,084,524 speedup is 1.00
# 3.84 GB and 180 s off a Hetzner box, to publish a ~28 MB commit, growing by that much
# again every build (the repo is never pruned and the upload runs without --delete). What
# the summary actually needs is each channel's TIP, so pull exactly that over HTTP:
# `--depth=0` takes the requested commit and none of its parents, and `--mirror` writes
# refs/heads/* where build-update-repo looks for them. The current channel's tip is worth
# having for a second reason — it is the new commit's parent, so --generate-static-deltas
# can still emit the from-parent delta that makes `flatpak update` incremental.
#
# This needs no deploy secret at all (it reads the public repo), so unlike the ssh version
# it also seeds correctly on a fork or a secretless run. gpg verification is off for the
# same reason the rsync had none: every object pulled here is re-signed by the deploy step
# below before it is republished.
# the stable commit's objects were still on disk. Fix: mirror the published repo DOWN first,
# so the local repo carries every existing branch; the build below then only ADDS this run's
# commit and the regenerated+signed summary keeps both channels. No-op on a fresh repo (first
# publish) or when the deploy secrets aren't set (the build still produces a valid bundle).
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_SSH_KEY:-}" ]; then
echo "::warning::DEPLOY_* not set — no seed; building a fresh single-branch repo."
exit 0
fi
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
mkdir -p "$PWD/repo"
ostree --repo="$PWD/repo" init --mode=archive
ostree --repo="$PWD/repo" remote add --if-not-exists --no-gpg-verify unom "$REPO_URL/repo/"
# Probe (retried) whether a published repo exists at all: ONLY a real 404 may continue
# with a fresh repo. A transient network failure must FAIL the job instead — treating a
# flaky link as "nothing published yet" is exactly how a single-branch summary comes to
# clobber the other channel (the bug described above).
if bash scripts/ci/retry.sh 5 curl -fsS -o /dev/null "$REPO_URL/repo/summary"; then
for ref in $(bash scripts/ci/retry.sh 5 ostree --repo="$PWD/repo" remote refs unom); do
case "$ref" in unom:app/$APP_ID/x86_64/*) ;; *) continue ;; esac
bash scripts/ci/retry.sh 5 ostree --repo="$PWD/repo" pull --mirror --depth=0 \
unom "${ref#unom:}"
done
elif [ "$(curl -sS -o /dev/null -w '%{http_code}' "$REPO_URL/repo/summary")" = 404 ]; then
echo "::warning::no published repo at $REPO_URL (first publish) — continuing fresh"
# Pull the currently-published repo (all channels' objects + refs) into the repo the build
# will extend. No --delete: the local repo starts empty, so this only ADDS.
# Probe first (retried) whether a published repo exists at all: ONLY that case may
# continue with a fresh repo. A transient network failure must FAIL the job instead —
# a blanket `rsync || continue` here is exactly how a flaky link produces the
# single-branch summary that clobbers the other channel (the bug described above).
PRESENT=$(bash scripts/ci/retry.sh 5 $SSH "$DEST" \
"[ -d $DEPLOY_DIR/site/repo/refs ] && echo present || echo absent")
if [ "$PRESENT" = present ]; then
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" \
"$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/"
else
echo "::error::$REPO_URL/repo/summary unreachable — refusing to build a summary that would drop a channel"
exit 1
echo "::warning::no published repo on the server (first publish) — continuing fresh"
fi
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
@@ -280,52 +263,24 @@ jobs:
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
# --disable-rofiles-fuse is the container-safe path (no FUSE).
# --disable-updates ("only download missing sources, never update to latest vcs
# version") is what makes a restored .flatpak-builder cache actually save network:
# every `type: git` source in the manifest is pinned to a COMMIT SHA (gamescope, glm,
# stb — plus gamescope's submodules, pinned by their gitlinks), so there is nothing to
# update to and re-fetching them only buys upstream flakiness. See the build step below
# for the mechanism.
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
# load-sensitive runner, same flathub.org resolution path.
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST"
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--download-only --disable-updates \
--download-only \
"$PWD/build-dir" "$MANIFEST"
- name: Build the flatpak (offline — deps + sources prefetched above)
run: |
# Everything is already local (state dir warmed by the prefetch step), so this long
# step needs no network; --install-deps-from stays as a no-op safety net.
#
# --disable-updates is LOAD-BEARING, not tidiness: without it this step was never
# actually offline. flatpak-builder runs the DOWNLOAD PHASE again as part of every
# build (builder-main.c calls builder_manifest_download() unconditionally — only
# --disable-download skips it), and it passes `update_vcs = !--disable-updates`. With
# updates on (the default) builder_git_mirror_repo() ALWAYS does a live `git ls-remote`
# + `git fetch` for every git source and every submodule, even ones pinned to an
# immutable commit sha. So this step re-fetched five repos on each run — gamescope plus
# its wlroots / libliftoff / vkroots / libdisplay-info submodules — outside retry.sh,
# and one HTTP 503 from gitlab.freedesktop.org killed the job minutes in:
# Fetching git repo https://gitlab.freedesktop.org/emersion/libdisplay-info, ref refs/tags/0.3.0
# error: RPC failed; HTTP 503 … fatal: expected 'acknowledgments'
# Failed to download sources: module gamescope-wsi-layer: … exited with code 128
# (Those three submodules are not even built — the module sets enable_gamescope=false;
# they get mirrored only because flatpak-builder clones submodules by default.)
# With the flag, builder_git_mirror_repo() short-circuits on `git cat-file -e <commit>`
# against the warm mirror and returns BEFORE any network call, so an upstream blip can
# no longer reach this step. It can never change what is built either: every git source
# here is commit-pinned (see the manifest), so "don't update" is a semantic no-op.
# Anything genuinely missing still downloads, so a cold state dir self-heals.
#
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
# (canary or stable) so the matching hosted .flatpakref resolves deterministically
# (manifest sets no branch).
flatpak-builder --user --force-clean --disable-rofiles-fuse \
--default-branch="$FLATPAK_BRANCH" \
--disable-updates \
--install-deps-from=flathub \
--repo="$PWD/repo" \
"$PWD/build-dir" "$MANIFEST"
@@ -447,20 +402,6 @@ jobs:
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
# 4) Bound the published repo. Every canary adds ~28 MB that nothing ever removed —
# the upload runs without --delete (deliberately: see above) and the local repo is
# no longer a full mirror that could carry a deletion over. Left alone it had
# reached 3.84 GB, on a box that has run out of disk before. `ostree prune` is the
# safe tool for it: --refs-only touches ONLY commits no ref points at (superseded
# canaries), and --keep-younger-than spares anything recent, so a client mid-pull
# or a box a few builds behind still resolves every object it asks for. Guarded on
# ostree existing there, and never allowed to fail the deploy — the bundle and the
# repo are already published by this point, and a full disk is a slower problem
# than a red release.
bash scripts/ci/retry.sh 3 $SSH "$DEST" \
"command -v ostree >/dev/null && ostree --repo=\$HOME/$DEPLOY_DIR/site/repo prune --refs-only --keep-younger-than='30 days ago' \
|| echo 'no ostree on the deploy host — repo not pruned'" \
|| echo "::warning::prune step failed — published repo may be growing unbounded"
- name: Attach bundle to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
-69
View File
@@ -1,69 +0,0 @@
# Smoke test for the guided installer (scripts/install.sh, docs-and-onboarding overhaul WP4).
# Runs the script unattended inside a clean container per package family against the REAL
# package registry — the one path a textual gate can't cover: does the repo line, the key import
# and the install actually work today on a fresh box. `--no-start` because a container has no
# user systemd; the script degrades to printing the enable command, which is also under test.
#
# Path-filtered on purpose: it pulls ~100 MB of packages per family, so it runs when the script
# or its fact source changes, not on every push (check-docs-drift.sh gate 6 covers the cheap
# half — the install lines in the script must match data/platforms.json verbatim — on every push).
name: installer-smoke
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- scripts/install.sh
- data/platforms.json
- .gitea/workflows/installer-smoke.yml
pull_request:
paths:
- scripts/install.sh
- data/platforms.json
- .gitea/workflows/installer-smoke.yml
jobs:
smoke:
name: smoke (${{ matrix.family }})
runs-on: ubuntu-24.04
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
# actions/checkout needs git + node + CA certs in the container; curl is the
# script's own prerequisite (it says so and stops without it).
- family: debian-13
image: debian:trixie
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
- family: fedora-44
image: fedora:44
prep: dnf install -y -q curl git nodejs
- family: arch
image: archlinux:base
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
container:
image: ${{ matrix.image }}
steps:
- name: Prepare the container (${{ matrix.family }})
run: ${{ matrix.prep }}
- uses: actions/checkout@v4
# No tty → the script runs as --yes; --no-start because there is no user systemd here.
# Root without sudo → the script's sudo shim, another path under test.
- name: Run the installer unattended
run: sh scripts/install.sh --yes --no-start
- name: The host is installed and conflict-free
run: |
punktfunk-host --version
punktfunk-host detect-conflicts
- name: Re-running is a no-op install
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
- name: --uninstall takes the packages and the repo off again
run: |
sh scripts/install.sh --yes --uninstall
! command -v punktfunk-host
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
+10 -41
View File
@@ -29,12 +29,6 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# The C/C++ half of the cache — same wiring as ci.yml/deb.yml. Safe at workflow level: no
# cross-compiling job here.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -51,9 +45,14 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
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,
@@ -68,8 +67,7 @@ jobs:
libgl1-mesa-dri mesa-vulkan-drivers \
adwaita-icon-theme fonts-cantarell fonts-dejavu-core
# Reuse the workspace cargo caches. The cargo-home (download) cache is shared verbatim —
# it is profile-independent.
# Reuse the workspace cargo caches (same keys as ci.yml/deb.yml).
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
@@ -82,20 +80,8 @@ jobs:
- uses: actions/cache@v4
with:
path: target
# This job builds RELEASE (see the build step) in the same image and target layout as
# deb.yml's `build-publish`, so it wants THAT tree — it used to name ci.yml's key, which
# holds a debug build and gave it nothing. (Third participant in the collision documented
# on ci.yml's `cargo-target-debug-v3-` key.)
#
# Its OWN exact key with deb's prefix as a FALLBACK restore-key, deliberately: both
# workflows run on a v* tag, and an exact-key match would make them race for the single
# save slot — this job builds one crate, so if it won that race it would replace deb's
# full release tree with a nearly empty one for the rest of the lockfile's life. This way
# it always READS the warm tree and never blocks the job that fills it.
key: cargo-target-shots-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-target-shots-release-v1-${{ env.rustc }}-
cargo-target-release-v3-${{ env.rustc }}-
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-target-v3-${{ env.rustc }}-
- name: Build client
run: cargo build --release -p punktfunk-client-linux --locked
@@ -111,20 +97,3 @@ jobs:
name: punktfunk-linux-client-screenshots
path: clients/linux/screenshots
retention-days: 30
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
# blocked reusing these shots for the docs. Publish them to the generic package registry
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
# public repo:
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
- name: Publish screenshots to the package registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
for f in clients/linux/screenshots/*.png; do
name=$(basename "$f")
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
echo "published $BASE/$name"
done
+17 -219
View File
@@ -4,28 +4,13 @@
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
# in ci.yml for that story).
#
# Three tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push — so the two cheap tiers gate every PR and the expensive one runs
# only where its cost buys something (a published cache):
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
# without building them. Catches the failures that actually happen to this flake — a
# renamed file, a callPackage argument that no longer exists, a syntax error, a package
# attribute dropped from packages.nix.
#
# ⚠ It does NOT, on its own, check the NixOS module. `nix flake check` handles
# `nixosModules` by forcing the value and asserting it is a lambda taking an open
# attribute set — nothing more (nix's own source: `// FIXME: if we have a 'nixpkgs'
# input, use it to check the module.`). MEASURED: a module setting a nonexistent
# OPTION, referencing a nonexistent `pkgs` attribute AND calling a nonexistent `lib`
# function passes clean, printing `checking NixOS module ... all checks passed!`. This
# header used to claim the module was covered here; it was not, for the module's whole
# life. It is covered NOW because `checks.<system>.nixos-module`
# (packaging/nix/module-check.nix) evaluates it against real nixpkgs and asserts on the
# rendered systemd units — and because those assertions are pure Nix, INSTANTIATING
# that check runs them, so `--no-build` is enough. Keep them pure: a shell script in
# the derivation body would only run under a full `nix flake check`, which builds the
# hour-long Rust packages.
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
# the NixOS module without building them. Catches the failures that actually happen to
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
# error, a package attribute dropped from packages.nix.
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
@@ -33,28 +18,9 @@
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
#
# * cache — PUSH TO MAIN ONLY. Builds the Rust packages + gamescope for real and publishes every
# punktfunk store path to the binary cache at https://nix.unom.io, so a NixOS user gets
# prebuilt binaries instead of an hour of rustc and a gamescope compile. This is the
# expensive tier and it is why the job timeout is 180 rather than 90.
#
# It needs NO extra trigger for releases: a release bumps the workspace version in
# Cargo.toml, which is already in the path filter below, so the tag's content is
# published by the version-bump commit on main.
#
# Only OUR paths are published — see the step for why that is both correct and the
# difference between ~300 MB and several GB per publish.
#
# The Rust packages and punktfunk-gamescope are still not built on PRs: they are the expensive ones
# and their inputs are already gated by the `rust` job in ci.yml. Build them on a PR by hand on a
# Nix box, or with the `build-rust` / `build-gamescope` dispatch inputs below.
#
# ⚠ punktfunk-gamescope matters more than it looks: `host.gamescopeHdr` DEFAULTS TRUE, so it is on
# the critical path of every `services.punktfunk.host.enable = true` build. It patches whatever
# gamescope the pinned nixpkgs carries, so a nixpkgs bump — not a change of ours — is what breaks
# it, and the first person to find out would be an operator whose system rebuild fails. The `cache`
# tier now compiles it on every main push, so a flake.lock bump that breaks it goes red HERE; the
# dispatch input below is for checking it on a branch before merging.
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
@@ -100,10 +66,6 @@ on:
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
type: boolean
default: false
build-gamescope:
description: "Also build punktfunk-gamescope (patched gamescope from source; run after a flake.lock bump)"
type: boolean
default: false
jobs:
flake:
@@ -121,15 +83,8 @@ jobs:
# real node (so actions/checkout works with no pre-checkout install dance), and audit.yml
# already pulls it on this fleet, so it is proven to resolve here. Nix is installed below.
image: node:22-bookworm
# 180, not 90: the `cache` tier on a main push compiles the whole Rust workspace AND gamescope
# from source inside the nix sandbox, where the sccache every other Rust job leans on cannot
# reach (no network in a derivation, and RUSTC_WRAPPER is not set inside one).
timeout-minutes: 180
timeout-minutes: 90
env:
# Where the published cache lives on unom-1, and the URL users substitute from. Kept next to
# the flatpak repo (3230) and winget source (3240) — see packaging/nix/server/.
DEPLOY_DIR: unom-nix-cache
CACHE_URL: https://nix.unom.io
# The flake needs both experimental features. Also baked into the installer's --extra-conf
# below; this covers any step that shells out before that config is read.
NIX_CONFIG: "experimental-features = nix-command flakes"
@@ -147,12 +102,11 @@ jobs:
steps:
- uses: actions/checkout@v4
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout;
# rsync + ssh to ship the built cache to unom-1. (node:22-bookworm is the full image and
# already has all but rsync — this is belt-and-braces against a future slim-image swap, and
# costs one cached apt call.)
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout.
# (node:22-bookworm is the full image and already has all three — this is belt-and-braces
# against a future slim-image swap, and costs one cached apt call.)
- name: Installer prerequisites
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git rsync openssh-client
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
# `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks
# to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the
@@ -173,19 +127,7 @@ jobs:
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
# here — so record the headroom, or a future failure is a guess.
- name: Environment
# Disk AND memory. This job's recurring failure is an OOM kill, and `df` cannot explain
# one — a run that dies at exit 137 with only disk numbers in the log is a guess.
run: |
df -h / /nix /tmp || true
free -h 2>/dev/null || grep -E '^(MemTotal|MemAvailable|SwapTotal)' /proc/meminfo || true
nproc 2>/dev/null || true
# THE number for this job's recurring exit 137. `free` and /proc/meminfo report the HOST
# inside a container, so they showed 125Gi total / 48Gi available on a run that then got
# bun SIGKILLed (19444) — a cgroup cap is invisible to them and is the only remaining
# explanation. cgroup v2 first, then v1; "max" means uncapped.
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|| echo "no cgroup memory limit readable"
run: df -h / /nix /tmp || true
# Evaluates + instantiates every flake output without building any of it.
- name: nix flake check (eval only)
@@ -217,153 +159,9 @@ jobs:
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
echo "bun packages OK: $web $scripting"
# ── binary cache (push to main only) ───────────────────────────────────────────────────────
#
# Decided against a bucket on storage.unom.io even though sccache already uses it and the
# credentials already exist: it is local RustFS on the home uplink with no CDN in front, so
# every NixOS user's download would come off the same pipe every CI runner uses — and S3
# answers 403, not 404, for a missing key, which nix treats as a hard error rather than a
# cache miss (see packaging/nix/server/Caddyfile). unom-1 already serves the flatpak repo
# this way from a cloud IP; a Nix cache is the same static-files-behind-caddy shape.
#
# Gitea itself cannot host this at all: it has 23 package registry types and none is Nix, and
# the binary cache protocol wants fixed anonymous paths at a URL root (/nix-cache-info,
# /<hash>.narinfo, /nar/<hash>.nar.xz) that /api/packages/{owner}/generic/… cannot express.
- name: Cache publish preflight
id: cachecfg
if: ${{ github.event_name == 'push' }}
env:
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
# Guard BEFORE the build, not before the upload: an unconfigured cache must not cost an
# hour of rustc first. No-ops cleanly until the secret exists, exactly as flatpak.yml's
# repo deploy does, so this workflow stays green through setup.
run: |
set -eu
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ]; then
echo "go=true" >> "$GITHUB_OUTPUT"
else
echo "go=false" >> "$GITHUB_OUTPUT"
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST not set — skipping the binary cache publish (see packaging/nix/README.md)."
fi
- name: Build the publishable packages
if: ${{ steps.cachecfg.outputs.go == 'true' }}
# Everything a user can install. punktfunk-gamescope earns its place here more than any
# other: host.gamescopeHdr DEFAULTS TRUE, so without it in the cache every
# `services.punktfunk.host.enable = true` still compiles a compositor from source.
run: |
"$NIX" build --print-build-logs \
.#punktfunk-host .#punktfunk-client .#punktfunk-tray \
.#punktfunk-web .#punktfunk-scripting .#punktfunk-gamescope
# This is now the heaviest job on the fleet — a full workspace build plus gamescope fills
# the store with tens of GB, and this fleet ran a runner out of disk on 2026-08-06. Record
# the headroom AFTER the build too, or a future "no space left on device" is a guess.
df -h / /nix /tmp || true
- name: Sign + publish to nix.unom.io
if: ${{ steps.cachecfg.outputs.go == 'true' }}
env:
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
# `set -eu`, NOT `set -euo pipefail`: act_runner may execute a step's `run:` under dash in
# these containers (see scripts/ci/ensure-sccache.sh), and dash dies on `-o pipefail` with
# "Illegal option". The two places below where a pipeline's LEFT side must be able to fail
# the step are written as redirects instead, so nothing depends on pipefail.
set -eu
PKGS=".#punktfunk-host .#punktfunk-client .#punktfunk-tray .#punktfunk-web .#punktfunk-scripting .#punktfunk-gamescope"
# 1) Pick what to publish. PUBLISH ONLY OUR OWN PATHS — this is the difference between
# ~300 MB and several GB per run, and it is not a corner cut: a runtime closure here
# is our binaries plus stock nixpkgs (ffmpeg, gtk4, glibc, …), and every stock path is
# already on cache.nixos.org, served by a real CDN. Mirroring them onto unom-1 would
# cost disk and home-to-cloud bandwidth to serve a WORSE copy of what users already
# have. Nothing in nixpkgs is named punktfunk, so the name filter is exact.
paths="$("$NIX" path-info -r $PKGS | grep -- '-punktfunk' || true)"
[ -n "$paths" ] || { echo "::error::no punktfunk store paths in the closure — the name filter is broken"; exit 1; }
echo "$paths"
# The filter is a string match, so it would fail SILENTLY if a pname ever changed — and
# the package most likely to drift is gamescope, the most expensive one to lose. Assert
# every built output is actually covered rather than discovering it as a user rebuild.
for out in $("$NIX" build --print-out-paths $PKGS); do
printf '%s\n' "$paths" | grep -qxF "$out" \
|| { echo "::error::$out is not matched by the '-punktfunk' filter — publish would silently omit it"; exit 1; }
done
# 2) Sign into a local binary cache. The secret is the whole `name:base64` line from
# `nix key generate-secret`; the matching public key is what users pin (README).
KEYDIR="$(mktemp -d)"; chmod 700 "$KEYDIR"
printf '%s' "$NIX_CACHE_SIGNING_KEY" > "$KEYDIR/key"; chmod 600 "$KEYDIR/key"
printf '%s\n' "$paths" | xargs "$NIX" copy --to "file://$PWD/nix-cache?secret-key=$KEYDIR/key"
# Publish the PUBLIC half beside the cache and echo it here. Users must pin this key, so
# it needs to be fetchable from the cache itself rather than only from a doc that can
# drift — and on the first run this log line is where the value for README.md comes from.
# Redirect, not `| tee`: without pipefail a failing nix would be masked by tee's success
# and publish an EMPTY public key, which every user would then pin.
"$NIX" key convert-secret-to-public < "$KEYDIR/key" > nix-cache/punktfunk-cache.pub
cat nix-cache/punktfunk-cache.pub
rm -rf "$KEYDIR"
echo "publishing $(find nix-cache -name '*.narinfo' | wc -l) paths, $(du -sh nix-cache | cut -f1)"
# 3) Ship it. Same deploy key and retry discipline as flatpak.yml — this runner's link to
# unom-1 drops TCP dials under load.
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/nar"
# ⚠ ORDER IS LOAD-BEARING: NARs first, narinfos second. A narinfo whose NAR has not landed
# yet is a HARD download failure for whoever fetches it in that window; a NAR nothing
# points at yet is simply invisible. rsync renames each file into place atomically, so a
# cancelled run (this workflow has cancel-in-progress) can only ever under-publish.
# No --delete: superseded paths are aged out by prune.sh below instead, so a client
# mid-download is never pulled out from under.
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" nix-cache/nar/ "$DEST:$DEPLOY_DIR/site/nar/"
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" nix-cache/nix-cache-info nix-cache/punktfunk-cache.pub nix-cache/*.narinfo "$DEST:$DEPLOY_DIR/site/"
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" \
packaging/nix/server/compose.production.yml packaging/nix/server/Caddyfile packaging/nix/server/prune.sh \
"$DEST:$DEPLOY_DIR/"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
# 4) Bound it. The flatpak repo next door reached 3.84 GB publishing this same way with
# no sweep, on a box that has run out of disk before; this one gets the sweep from the
# first publish. Never allowed to fail the job — the cache is already live by now, and
# a growing disk is a slower problem than a red main.
bash scripts/ci/retry.sh 3 $SSH "$DEST" "sh ~/$DEPLOY_DIR/prune.sh ~/$DEPLOY_DIR/site 180" \
|| echo "::warning::cache prune failed — published cache may be growing unbounded"
# 5) Prove the published cache actually answers, rather than assuming the rsync landed.
# A substituter that 200s on nix-cache-info but 403s on a miss is the failure mode that
# breaks users' builds, so check both.
bash scripts/ci/retry.sh 5 curl -fsS "$CACHE_URL/nix-cache-info"
miss="$(curl -sS -o /dev/null -w '%{http_code}' "$CACHE_URL/0000000000000000000000000000000000.narinfo")"
[ "$miss" = 404 ] || { echo "::error::cache returns $miss for an absent path; nix needs 404 or every user build fails"; exit 1; }
echo "published → $CACHE_URL"
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
# Accept BOTH shapes. A checkbox dispatched from the Gitea UI arrives as the STRING
# "true", but an API dispatch (scripts, cross-repo automation) can deliver a real JSON
# boolean, and `== 'true'` silently misses it — the step is skipped, the run goes green,
# and the log looks identical to a run that genuinely had nothing to do. MEASURED
# 2026-08-19: dispatched with build-gamescope while verifying a flake.lock bump, and this
# step skipped while the job reported success — a green that proved nothing about the
# very package being fixed. Still no `inputs.*`: that context is the thing Gitea's parser
# is least reliable about, which is why this file used github.event.inputs to begin with.
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
- name: Build the Rust packages (dispatch opt-in)
if: ${{ github.event.inputs.build-rust == 'true' || github.event.inputs.build-rust == true }}
if: ${{ github.event.inputs.build-rust == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
# The patched compositor. Separate from build-rust because its failure mode is different: it
# tracks nixpkgs' gamescope, not our Rust, so it wants a run after a flake.lock bump rather
# than after a code change. `gamescope.nix` fails loudly (an eval-time `throw` if nixpkgs no
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
# something actually builds it.
- name: Build the patched gamescope (dispatch opt-in)
if: ${{ github.event.inputs.build-gamescope == 'true' || github.event.inputs.build-gamescope == true }}
run: |
"$NIX" build --print-build-logs .#punktfunk-gamescope
-7
View File
@@ -66,13 +66,6 @@ jobs:
test -f node_modules/@punktfunk/host/package.json
test -f node_modules/@punktfunk/host/dist/index.d.ts
# The kit had no biome config and no lint step, while every plugin repo that consumes it does
# — so its source drifted (unused imports, formatting) with nothing to catch it. Now gated
# here, on the same config and pinned biome version the plugins use.
- name: Lint & format
working-directory: plugin-kit
run: bun run check
- name: Typecheck
working-directory: plugin-kit
run: bun run typecheck
+555
View File
@@ -0,0 +1,555 @@
# Production Apple client builds — runs on the macos-arm64 runner (home-mac-mini-1).
#
# Tag v* (or workflow_dispatch):
# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached to a
# Gitea release on tag pushes
# macOS (App Store) -> archive + upload to TestFlight (App Store Connect)
# iOS -> archive + upload straight to TestFlight (App Store Connect)
# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets,
# nightly -Zbuild-std, in build-xcframework.sh)
#
# One App Store listing for all platforms (universal purchase): every target shares the
# bundle ID io.unom.punktfunk.
#
# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements —
# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the
# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid).
# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build,
# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a
# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG
# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though
# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the
# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here.
#
# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the
# step is continue-on-error until they exist):
# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record
# (universal purchase).
# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the
# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/).
# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in
# the runner's login keychain, in addition to "Apple Distribution" — the App Store
# .pkg is installer-signed with it.
#
# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it):
# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID,
# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner
# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the
# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch
# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app
# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships
# a launchable app.
#
# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's
# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing
# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer
# ID Application + Apple Distribution, with the WWDR intermediate present (so they show as
# *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain.
# One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access:
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <login-pw> \
# ~/Library/Keychains/login.keychain-db
#
# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API
# key — notarization, TestFlight upload, automatic-signing profile fetch).
#
# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow
# 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:
# Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's
# own canary channel) — no notarized DMG (that's stable-only; see the per-step gates).
# Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are
# continue-on-error until the App Store Connect record exists, so this no-ops until then.
branches: [main]
paths:
- 'clients/apple/**'
- 'crates/punktfunk-core/**'
- 'scripts/build-xcframework.sh'
- 'Cargo.lock'
- '.gitea/workflows/release.yml'
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store.
tags: ['v*']
workflow_dispatch:
inputs:
testflight:
description: "Upload the iOS build to TestFlight (true/false)"
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
timeout-minutes: 120
env:
TEAM_ID: F4H37KF6WC
PROJECT: clients/apple/Punktfunk.xcodeproj
steps:
- uses: actions/checkout@v4
- name: Select release Xcode
run: |
DEV_DIR=""
for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do
case "$app" in *beta*|*Beta*) continue;; esac
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
done
if [ -z "$DEV_DIR" ]; then
for app in /Applications/Xcode*.app; do
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
done
echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds."
fi
[ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; }
# Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must
# keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the
# running dyld rejects, killing proc-macro loads (see build-xcframework.sh).
echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV"
DEVELOPER_DIR="$DEV_DIR" xcodebuild -version
- name: Version from tag
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
- name: Rust toolchain (mac + iOS + tvOS slices)
run: |
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
# nightly + -Zbuild-std, so ensure nightly + rust-src are present.
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
command -v cmake >/dev/null || "$BREW" install cmake
echo "$BREW_BIN" >> "$GITHUB_PATH"
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
# 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
# same track as iOS/macOS (the nightly toolchain is installed unconditionally above).
run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh
- name: Stage App Store Connect API key
env:
ASC_P8: ${{ secrets.ASC_API_KEY_P8 }}
run: |
printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8"
chmod 600 "$RUNNER_TEMP/asc.p8"
- name: macOS — archive, codesign Developer ID, notarize, DMG
# Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not
# relevant to TestFlight testers (the canary channel). Skipped on canary main pushes.
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
# Archive UNSIGNED, then codesign with the Developer ID Application identity from the
# login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups
# provisioning-profile gate at archive time; we re-assert that authorization below by
# EMBEDDING a Developer ID profile before codesign (see the keychain note further down).
# Bundle is a single static binary.
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-project "$PROJECT" -scheme Punktfunk \
-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
APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app"
# Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build
# uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix.
RESOLVED="$RUNNER_TEMP/macos.entitlements"
sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \
clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED"
# keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the
# network/device keys are self-asserted for Developer ID, but a keychain access group
# must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to
# spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163),
# SIGKILL before main() — even though the bundle is validly signed and notarized. Embed
# a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its
# entitlements authorize the access group, exactly like the App Store build's profile
# does. Located by profile Name among the profiles installed on the runner (see header).
DEVID_PROFILE_NAME="Punktfunk macOS Developer ID"
PROFILE_SRC=""
for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \
"$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do
[ -e "$p" ] || continue
NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true)
[ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break
done
if [ -n "$PROFILE_SRC" ]; then
# Must land BEFORE codesign so it's sealed into the bundle.
cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile"
echo "embedded Developer ID profile: $PROFILE_SRC"
else
# Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the
# managed entitlement and let ClientIdentityStore fall back to the legacy file keychain
# (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable.
echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain."
/usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true
fi
codesign --force --options runtime --timestamp \
--entitlements "$RESOLVED" \
--sign "Developer ID Application" "$APP"
codesign --verify --strict --verbose=2 "$APP"
# Notarized DMG.
STAGE="$RUNNER_TEMP/dmg-stage"
mkdir -p "$STAGE"
cp -R "$APP" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg"
hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \
--key "$RUNNER_TEMP/asc.p8" \
--key-id "${{ secrets.ASC_API_KEY_ID }}" \
--issuer "${{ secrets.ASC_API_ISSUER_ID }}"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG"
echo "DMG=$DMG" >> "$GITHUB_ENV"
- name: Attach DMG 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)
upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg"
- name: macOS App Store — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Best-effort until the App Store Connect record has the macOS platform + the
# "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer
# Installer" cert are on the runner (see the header). The macOS app is sandboxed
# (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store.
continue-on-error: true
run: |
# Separate archive from the Developer ID one above: App Store needs a signed, entitled
# archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign
# DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified
# profile (as this step used to do): the in-app license screens added a SwiftPM resource
# bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot
# carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an
# 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
# (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
pkill -x Xcode 2>/dev/null || true
PROFILE="Punktfunk macOS App Store Distribution"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-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"
cat > "$RUNNER_TEMP/export-macos-appstore.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>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>
<key>provisioningProfiles</key>
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \
-exportPath "$RUNNER_TEMP/export-macos-appstore" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: iOS — archive + upload to TestFlight
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Best-effort until the App Store Connect app record for io.unom.punktfunk exists.
continue-on-error: true
run: |
# Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full
# rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app
# 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.
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"
cat > "$RUNNER_TEMP/export-appstore.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>upload</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 "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \
-exportPath "$RUNNER_TEMP/export-appstore" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-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.
if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true'
# Needs tvOS added to the App Store Connect app record + the tvOS platform installed
# on the runner (xcodebuild -downloadPlatform tvOS).
continue-on-error: true
run: |
# Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM
# 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).
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"
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \
-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"
cat > "$RUNNER_TEMP/export-tvos.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>upload</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></dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \
-exportPath "$RUNNER_TEMP/export-tvos" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
+13 -212
View File
@@ -35,7 +35,6 @@ on:
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- 'scripts/alsa-ucm2/**'
- '.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
@@ -52,14 +51,6 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# The C/C++ half of the cache (aws-lc-sys, the vendored libopus, openh264's C++). Safe at
# workflow level here: unlike ci.yml/deb.yml this workflow has no cross-compiling job whose
# image sets its own CC_x86_64_unknown_linux_gnu. See ci.yml's `rust` job for that trap.
# This matters twice per push — the f43 and f44 legs are the two longest jobs in the fleet.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -89,9 +80,14 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this heals the job while the running :latest predates the bake.
# images; this fetch keeps the job green while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: sh scripts/ci/ensure-sccache.sh
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
@@ -107,11 +103,7 @@ jobs:
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
# libcap = setcap/getcap: the sysext is the ONLY place the image can acquire
# cap_sys_nice=ep on punktfunk-encode-worker (a merged /usr is read-only squashfs and no
# scriptlet ever runs), and it is also what the build's host-must-be-uncapped assertion
# and the capability-matrix CI leg read with. Without it the image ships the lever inert.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted libcap
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 /
@@ -149,63 +141,11 @@ jobs:
echo "GROUP=$GROUP" >> "$GITHUB_ENV"
echo "rpm $V-$R -> group '$GROUP'"
# ── The web console, built once per (web+sdk content, bun) instead of once per leg ─────────
# Two legs run here (f43 + f44) and each built its own identical copy of a bundle that is a
# pure function of web/ and sdk/ — see the fuller note in deb.yml, whose key family this
# shares, so whichever job builds it first warms the rest of the fleet.
#
# ⚠ The build has to happen HERE, in the workspace, rather than being left to the spec. Two
# reasons, and both are load-bearing:
# * build-rpm.sh packages a `git archive` tarball and web/.output is gitignored, so a
# bundle sitting in the workspace is invisible to rpmbuild — it must be handed over by
# absolute path (PF_PREBUILT_WEB_OUTPUT -> the spec's pf_prebuilt_web macro).
# * the reverse direction is worse: the spec builds into rpmbuild's %{_topdir}, which
# build-rpm.sh creates with mktemp and removes on EXIT. A console built in there is gone
# before actions/cache's post step runs, so the cache would never populate and every run
# would be a miss that quietly rebuilt — the cache would look present and do nothing.
- name: Web console cache key
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
- name: Build the web console (cache miss only)
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
bun run build
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
# become a quietly console-less RPM. The spec re-checks the marker on whatever it packages.
- name: The console must exist (cache hit or fresh build)
run: |
if [ ! -f web/.output/server/index.mjs ]; then
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
exit 1
fi
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
echo "web console present: $(du -sh web/.output | cut -f1)"
- 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 \
PF_PREBUILT_WEB_OUTPUT="$GITHUB_WORKSPACE/web/.output" \
bash packaging/rpm/build-rpm.sh
# Visibility only — the two RPM legs are the longest jobs in the fleet, so a cache
# regression here is the most expensive one to leave undetected.
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
# Signs with packages@unom.io (org secret) and self-verifies before publish. On a v* tag a
# missing key FAILS the build rather than publishing unsigned RPMs into a gpgcheck=1 repo.
@@ -215,29 +155,6 @@ jobs:
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
run: bash packaging/rpm/sign-rpms.sh
# Read the file-capability matrix out of the BUILT rpm, before anything is signed or
# published. 0.26.0-1 shipped `%caps(cap_sys_nice=ep)` on the host through this very spec —
# on Fedora and, via rpm-ostree layering, on Bazzite — and every board was green while every
# KDE desktop session died in the field. The lesson recorded then was "verify the PACKAGE,
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
# fail takes the job down instead of waving the release through.
- name: The DualSense UCM drop-in must still bite
# scripts/alsa-ucm2/ hooks into alsa-ucm-conf's own dispatcher, so an upstream rename or
# reorder can neuter it with no error anywhere — and what comes back is the Spider-Man
# EXCEPTION_ACCESS_VIOLATION, not a quieter pad. This is the only leg that runs on a real
# Fedora tree, hence the two packages. Skips itself on any box without them.
run: |
dnf -y install alsa-ucm alsa-ucm-utils
sh scripts/ci/check-dualsense-ucm.sh
- name: Assert the capability matrix (rpm)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
# Only the main host package carries binaries; -debuginfo/-debugsource and the
# client/web/scripting subpackages ship neither and are skipped by the script itself.
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-[0-9]*.rpm
- name: Publish to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -289,93 +206,13 @@ jobs:
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
# NOT best-effort: build-punktfunk-gamescope.sh appends `-static-libstdc++` to LDFLAGS
# (so the binary still starts on SteamOS's older libstdc++ — see its comment), and
# without the static library meson's very FIRST sanity check dies with
# "cannot find -lstdc++ / have you installed the static version", so nothing builds at
# all. That is what happened on the v0.26.0 tag: both Fedora bases warned and skipped,
# the job stayed green, and the release shipped with no gamescope RPM while the notes
# said it had one. A rename here must be LOUD, hence no `|| true`.
dnf -y install libstdc++-static
# The rest of the Arch package's makedepends that Fedora's older packaged gamescope does
# not necessarily pull. Best-effort: unlike the static runtime, meson finds fallbacks or
# does without, and a name that moves between Fedora releases should not fail the job.
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
# beside the compositor, which is the only thing that can give a nested game an HDR10
# swapchain. Caching the tree rather than a file per artifact is what keeps the next
# addition from needing a change here as well as in every packaging script.
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
# would skip the sysext build, the sysext feed, AND the release attach below, so a
# missing gamescope would also withhold the punktfunk RPMs and the .raw images that
# built perfectly well. deb.yml learned that the expensive way on v0.26.0.
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
# The same binary, as an ordinary RPM. The sysext below is the Atomic/Bazzite delivery; this
# is the one a traditional Fedora-family box (Nobara, plain Fedora) can actually install —
# until it existed those users had no packaged route to the patched build at all, and a stock
# gamescope tells every game its display is 60 Hz whatever the client negotiated.
#
# Same best-effort rule as the build above: no binary, no package, and the host stays on its
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
- name: Package punktfunk-gamescope as an RPM
run: |
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/gamescope/build-gamescope-rpm.sh \
--stage gs-cache \
--release "$PF_RELEASE"
else
# Warn only — see the note on the build step. The gate is the last step of this job.
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
fi
# A SECOND signing pass, for this package only. The main "Sign RPMs" step ran back at build
# time, long before this RPM existed — the gamescope build sits behind its own ~10-minute
# cache and deliberately runs after the host RPMs are already published. So every
# punktfunk-gamescope RPM went to the registry UNSIGNED, and the repo file we tell users to
# install carries gpgcheck=1: `dnf install punktfunk-gamescope` failed with "The package is
# not signed" on every Fedora and Nobara box. The package was in the channel the whole time
# and could not be installed from it — which is worse than absent, because the release notes
# and the docs-site both say it is there.
#
# Same fail-closed rule as the first pass: sign-rpms.sh hard-fails on refs/tags/v* if the org
# secret is missing, rather than republishing something a user's dnf will reject.
- name: Sign punktfunk-gamescope
env:
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
run: |
shopt -s nullglob
rpms=(dist/punktfunk-gamescope-*.rpm)
# No RPM here is the best-effort skip above, already warned about — not a signing failure.
if [ "${#rpms[@]}" -eq 0 ]; then
echo "no punktfunk-gamescope RPM to sign (see the packaging step above)"
exit 0
fi
bash packaging/rpm/sign-rpms.sh "${rpms[@]}"
- name: Publish punktfunk-gamescope to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
shopt -s nullglob
for rpm in dist/punktfunk-gamescope-*.rpm; do
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
echo "uploading $rpm"
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
done
# 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.
@@ -386,9 +223,9 @@ jobs:
# whose runtime libs are missing from this container must cost the image its HDR, not the
# image itself.
gs=()
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope-stage gs-cache)
echo "folding in $(gs-cache/usr/bin/punktfunk-gamescope --version 2>&1 | head -1)"
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
@@ -399,19 +236,6 @@ jobs:
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
# Read the capability matrix back OUT of the image that is about to be published — the one
# channel where getting it wrong is unrepairable, because a merged sysext's /usr is read-only
# squashfs and the only fix is a new image plus a feed republish. 0.26.0-1's Bazzite breakage
# was confirmed exactly this way, after the fact, by mounting the published .raw and running
# getcap on it. Doing it here means the .raw never reaches the feed.
#
# The script proves its own reader first (cap a file, squash it, unsquash it, read it back)
# so a runner that cannot see file capabilities FAILS the leg instead of blessing the image.
- name: Assert the capability matrix (sysext image)
run: |
bash scripts/ci/assert-cap-matrix.sh \
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
# 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.
@@ -452,26 +276,3 @@ jobs:
for raw in dist-sysext/*.raw; do
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
done
# A release must not be able to make a claim its own CI silently dropped — v0.26.0's notes
# said the patched gamescope was dnf-installable while both Fedora bases had skipped it on a
# `::warning::` (missing libstdc++-static, which the -static-libstdc++ link needs).
#
# ⚠ LAST step on purpose, matching deb.yml: failing at the build step instead would skip the
# sysext image, the feed publish AND the attach above, withholding the punktfunk RPMs and
# .raw images that built perfectly well. Everything good ships first; the job goes red after.
- name: A stable tag must ship the gamescope RPM
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope-*.rpm)
keep=()
for r in "${built[@]}"; do
case "$r" in *debuginfo*|*debugsource*) continue;; esac
keep+=("$r")
done
if [ ${#keep[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope RPM was built for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope RPM present: ${keep[*]}"
+2 -2
View File
@@ -9,7 +9,7 @@
#
# What goes in: scripts/ci/gen-sbom.sh = syft over the checkout (every lockfile-pinned dep in
# both Rust workspaces + the JS trees + Swift Package.resolved) merged with
# compliance/sbom/manual-components.cdx.json (vendored C/C++, bundled DLLs, gamescope).
# 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
@@ -67,7 +67,7 @@ jobs:
. 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 apple.yml).
# 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
-370
View File
@@ -1,370 +0,0 @@
# Windows CLIENT — build, lint, test and package, on a self-hosted windows-amd64 runner (host mode;
# the generic runner + MSVC/WinUI toolchain come from unom/infra's windows-runner/, punktfunk's own
# extras — WDK, Inno Setup, the ARM64 rustup target — self-provision via the "Ensure Windows
# toolchain" step, a fast no-op once present, so any runner with that label works).
#
# Covers BOTH client binaries: the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the
# punktfunk-session Vulkan client (pf-presenter/pf-client-core/pf-console-ui — every stream runs in
# it, spawned by the shell), plus punktfunk-cli, whose `punktfunk.exe` alias the MSIX manifest
# references.
#
# ⚠ WHY THIS IS ONE FILE. This was `windows.yml` (build+lint+test, DEBUG, x64 + arm64) and
# `windows-msix.yml` (build+package, RELEASE, x64 + arm64) — four full compiles of the same crates
# per client push, on ONE runner, from three copies of the same `paths:` list that had already
# started to drift. windows-host.yml learned the hard way that debug trees on this machine are pure
# liability: a second dep tree tips it into `cabac_decoder.cpp: fatal error C1069` building
# openh264-sys2's vendored C++, which is disk/temp exhaustion, not a source error. So there is now
# ONE release build per arch, and clippy/fmt/test run against it. Do not reintroduce a debug leg.
#
# Renamed from windows-msix.yml deliberately, and safely: `github.run_number` is REPO-WIDE in Gitea
# (consecutive runs of DIFFERENT workflows get consecutive numbers), so the canary MSIX version
# `<minor>.<run>.0` keeps climbing across the rename — on GitHub, where run_number is per-workflow,
# this same rename would have reset it to 1 and made every canary sort below the published ones.
#
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and aarch64-pc-windows-msvc
# by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust target
# with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64 compiler from
# the target triple (SDL3 + libopus build-from-source cross-compile fine). The one thing the aarch64
# build can't do is *run* on the x64 host, so fmt + test run only for x64.
#
# ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the session builds
# --no-default-features there (no Skia console UI; streaming is unaffected) — flip when
# skia-binaries adds the target.
#
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
# `amf-qsv` leg link-imports them.
#
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout / per-arch
# vars are set in a step:
# - 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
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
# root keeps every nested path well under the limit (per-arch so the two
# matrix legs don't share a target dir).
#
# 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.
# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh.
#
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
#
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
# from one assembled layout:
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
# (stable path Steam can launch: overlay + Big Picture work)
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
#
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
# Published to the generic registry + the stable `latest/` alias + attached to the
# unified Gitea Release alongside every other platform's artifact.
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
#
# Signing (clients/windows/packaging/pack-msix.ps1), first match wins:
# 1. Azure Artifact Signing — what this workflow always takes, since the AZURE_CODESIGNING_*
# endpoint/account/profile are literals below and only the AZURE_TENANT_ID / AZURE_CLIENT_ID /
# AZURE_CLIENT_SECRET secrets are needed. Publicly trusted, so NO .cer is emitted or published
# and users import nothing. NOTE the Publisher DN is the Azure profile's verified subject, and
# MSIX identity is name + publisher: moving to it changed the package identity, so installs
# predating it need an uninstall, not an upgrade.
# 2. MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD — the older self-signed .pfx, kept as a fallback.
# 3. an ephemeral self-signed cert. Modes 2 and 3 DO emit a .cer next to the .msix, which users
# would have to import into Trusted People before Windows will install the package.
#
# Modes 2 and 3 are 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.
name: windows-client
# 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]
# ONE list now, not three. The old windows.yml + windows-msix.yml pair carried this same set
# three times (push, pull_request, and the second file), which is exactly how a crate goes
# missing from one copy — windows-host.yml documents the "Cargo.lock luck" gap that produced.
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-client.yml'
tags: ['v*']
pull_request:
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-client.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.
#
# NOTE the C/C++ launcher wiring the Linux workflows carry (CMAKE_*_COMPILER_LAUNCHER, CC_*) is
# deliberately NOT set here. This runner's failure mode under extra compiler processes is the
# C1069 disk/temp exhaustion documented in windows-host.yml, so sccache-for-MSVC is its own
# change, to be made with a measurement rather than folded into a reorganisation.
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:
# 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 steps below and
# windows-host.yml (which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
# never blocks internal PR CI.
client:
runs-on: windows-amd64
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- arch: x64
target: x86_64-pc-windows-msvc
td: C:\t
session_flags: ''
- arch: arm64
target: aarch64-pc-windows-msvc
td: C:\t-a64
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
# rust-skia adds the target.
session_flags: '--no-default-features'
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Configure + version
shell: pwsh
run: |
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
# crates (see this file's header). No FFMPEG_DIR: nothing in this package links libav*
# (M10), and pack-msix.ps1 no longer copies runtime DLLs from one.
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }}
rustc --version
cargo --version
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
} else {
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
}
while ($parts.Count -lt 4) { $parts += '0' }
$v = ($parts[0..3] -join '.')
"MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }} target-dir ${{ matrix.td }}"
# All three client binaries, ONCE, in release. The shell spawns punktfunk-session.exe (a
# package sibling) for every stream, and punktfunk-cli builds the `punktfunk.exe` the manifest
# aliases and pack-msix.ps1 requires (bf981027 added the requirement without the build — the
# same gap 90c84ef4 closed for deb). --no-default-features on ARM64 is a no-op for the shell.
#
# Release, not debug, even for the lint/test legs below: a debug build here would compile the
# whole dep tree into a SECOND target dir and re-run openh264-sys2's vendored C++ through
# cc-rs's cl.exe fan-out, which is what tips this runner into C1069 (see the header).
- name: Build (release)
shell: pwsh
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
- name: Clippy (-D warnings)
shell: pwsh
run: |
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
# dependency but only LINTS the packages it is given, so a decode crate that starts the
# run but is missing from this list would be gated by nothing.
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
$sf = @()
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
cargo clippy --release @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
if ($LASTEXITCODE) { throw "clippy" }
- name: Rustfmt check
if: matrix.arch == 'x64'
shell: pwsh
run: |
cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
if ($LASTEXITCODE) { throw "rustfmt" }
- name: Test
# x64 only: the aarch64 binaries cross-compile here but cannot RUN on this host.
if: matrix.arch == 'x64'
shell: pwsh
run: |
cargo test --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
if ($LASTEXITCODE) { throw "tests" }
- name: sccache stats (visibility only)
if: always()
shell: pwsh
run: sccache --show-stats
# ── Packaging: pushes, tags and dispatch only. A PR gets the build/lint/test signal above and
# stops there — packing would sign with a throwaway cert and publish nothing.
- name: Pack + sign MSIX
if: github.event_name != 'pull_request'
shell: pwsh
env:
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
# when all three are set. Not secret: an account/profile name and a regional endpoint,
# inert without the credentials below. The profile's verified subject is also the MSIX
# manifest Publisher; pack-msix.ps1 reads the signature back and fails on a mismatch.
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
# Legacy self-signed path, kept as the fallback for builds without Azure access.
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
& clients/windows/packaging/pack-msix.ps1 `
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
- name: Pack + sign installer + portable zip
if: github.event_name != 'pull_request'
shell: pwsh
env:
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
& clients/windows/packaging/pack-client-installer.ps1 `
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
- name: Publish to Gitea generic registry
if: github.event_name != 'pull_request'
shell: pwsh
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
$PSNativeCommandUseErrorActionPreference = $false
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
# Under Azure signing there is no .cer, so MSIX_CER_PATH is unset. The quotes below are
# load-bearing: "$($env:UNSET)" interpolates to an empty string (a legal key), whereas a
# BARE $env:UNSET is $null and a null key is a hard error in a hash literal — which is
# exactly how windows-host.yml's publish step broke. Added explicitly rather than relying
# on that accident, so removing the quotes can't silently reintroduce it.
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
# The installer + portable zip (the default download; docs point at these alias URLs).
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" }
function Put($f, $url) {
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
# status code instead of aborting on it.
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
Write-Output "published ($code): $url"
}
foreach ($f in $files) {
$name = Split-Path $f -Leaf
# 1) immutable, versioned path
Put $f "$base/$($env:MSIX_VERSION)/$name"
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
$an = $aliasNames["$f"]
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
Put $f "$base/$alias/$an"
}
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
# don't collide.
- name: Attach client artifacts to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
+7 -8
View File
@@ -9,7 +9,7 @@
# only live NVENC encode does, which defers to the RTX box.
#
# shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the
# first GITHUB_ENV line — see windows-client.yml).
# 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
@@ -39,7 +39,7 @@ on:
jobs:
# SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with
# the release-signing jobs (windows-host.yml / windows-client.yml). See windows-client.yml for the full
# the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full
# rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR
# runners); the `if:` is a fail-open backstop that never blocks internal PR CI.
probe-and-proto:
@@ -111,7 +111,7 @@ jobs:
- name: Build + test pf-driver-proto (MSVC)
run: |
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows-client.yml).
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
$env:CARGO_TARGET_DIR = "C:\t\drv"
cargo build -p pf-driver-proto
cargo test -p pf-driver-proto
@@ -144,7 +144,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
# Shared self-provision step (also used by windows-client.yml/windows-host.yml) so
# Shared self-provision step (also used by windows.yml/windows-msix.yml/windows-host.yml) so
# driver-build is self-sufficient on any windows-amd64 runner and never races a manually
# dispatched provisioning workflow landing on a different one. Path is relative to the job
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
@@ -159,10 +159,9 @@ jobs:
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
# `// 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, but it holds real DDI slot-dispatch unsafe (iddcx_rt.rs), so it
# runs the same gates.
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay -p wdk-probe --all-targets -- -D warnings
# `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
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
+14 -40
View File
@@ -3,7 +3,7 @@
# 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
# (host mode; same MSVC/Windows-SDK/LLVM env as windows-client.yml — generic from unom/infra's
# (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).
#
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
@@ -20,18 +20,12 @@
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
#
# Signing goes through Azure Artifact Signing (account `unomsigning`, profile `unom-io`) — a publicly
# trusted CA, so there is no .cer for users to import and no SmartScreen "unknown publisher" prompt.
# It falls back to the old MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD self-signed cert, and then to an
# ephemeral one, for builds without Azure access. Those fallbacks are for canary/CI ONLY — on a v* tag
# 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.
#
# The bundled DRIVERS are NOT signed by Azure — they keep their own DRIVER_CERT_* cert and are still
# trusted by planting that cert in the machine Root store at install time. Independent by design:
# Windows checks the installer's signature via SmartScreen/UAC and driver catalogs via PnP, and never
# requires a common signer. See packaging/windows/README.md for why that root-plant is still there.
#
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
@@ -149,13 +143,20 @@ jobs:
"CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by
# scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10
# it links no libav* at all (windows-client.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
# it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone
# and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend
# (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
# then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
if (-not $env:FFMPEG_DIR) {
"FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
# VBCABLE_DIR: the pinned official VB-CABLE package (provisioned by
# provision-windows-punktfunk-extras.ps1) -> pack-host-installer.ps1 bundles the
# streaming virtual microphone. Same daemon-env-or-fallback pattern as FFMPEG_DIR
# (the daemon env only refreshes on a runner-task restart).
if (-not $env:VBCABLE_DIR) {
"VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
$env:GITHUB_REF_NAME -replace '^v', ''
@@ -297,17 +298,9 @@ jobs:
# so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
$ver = 'bun-v1.3.14'
$url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
# SHA-256 of this exact asset, pinned. GitHub release assets are MUTABLE at a fixed URL, so
# the tag alone vouches for nothing — this binary is Authenticode-signed into our installer
# and its hash published in the Ed25519 update manifest, i.e. our signature vouches for bytes
# we downloaded. Verify them. On a bun bump, update BOTH $ver and $sha (compute:
# `shasum -a 256 bun-windows-x64.zip`). security-review 2026-08-15 finding 12.
$sha = '0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922'
New-Item -ItemType Directory -Force -Path C:\t | Out-Null
$zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
Invoke-WebRequest -Uri $url -OutFile $zip
$got = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
if ($got -ne $sha) { throw "bun zip sha256 mismatch for ${ver}: got $got, pinned $sha" }
if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
Expand-Archive -Path $zip -DestinationPath $dst -Force
$bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
@@ -413,6 +406,7 @@ jobs:
@{ 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) {
@@ -429,26 +423,12 @@ jobs:
- name: Pack + sign installer
shell: pwsh
env:
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
# when all three of these are set. Not secret: an account/profile name and a regional
# endpoint, all inert without the credentials below, so they live here where a reviewer
# can see which profile a release was signed by.
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
# Legacy self-signed path, kept as the fallback for builds without Azure access.
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.
# NOT moved to Azure: driver catalogs are a separate track, see that README.
DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }}
DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }}
run: |
@@ -480,13 +460,7 @@ jobs:
# Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
# predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
# Build this incrementally, NOT as one literal: under Azure signing there is no .cer, so
# HOST_CER_PATH is unset — and an unset $env: var is $null, which is a HARD ERROR as a hash
# literal key ("A null key is not allowed in a hash literal"), not the empty-string key it
# looks like it should be. The $files guard above filters the missing .cer out just fine;
# this line ran before anything could use it and failed the whole publish step.
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe' }
if ($env:HOST_CER_PATH) { $aliasNames[$env:HOST_CER_PATH] = 'punktfunk-host-windows.cer' }
$aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
foreach ($f in $files) {
$an = $aliasNames[$f]; if (-not $an) { continue }
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
+196
View File
@@ -0,0 +1,196 @@
# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to
# Gitea's generic package registry, so Windows boxes can download + install a real package (Start
# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64
# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest
# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the
# Windows SDK's makeappx/signtool are baked into the runner's daemon env.
#
# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC
# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the
# BOM/MAX_PATH runner gotchas.
#
# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the
# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer
# (windows-host.yml) is unchanged.
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
#
# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so:
# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX).
# Published to the generic registry + the stable `latest/` alias + attached to the
# unified Gitea Release alongside every other platform's artifact.
# main push / dispatch -> <next-minor>.<run_number>.0 (canary; base is one minor ahead of the
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
# 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.
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:
branches: [main]
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows-msix.yml'
tags: ['v*']
workflow_dispatch:
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:
runs-on: windows-amd64
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- arch: x64
target: x86_64-pc-windows-msvc
td: C:\t
session_flags: ''
- arch: arm64
target: aarch64-pc-windows-msvc
td: C:\t-a64
# No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships
# without the Skia console UI on ARM64 (streaming unaffected) — flip when
# rust-skia adds the target.
session_flags: '--no-default-features'
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Configure + version
shell: pwsh
run: |
# CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source
# crates (see windows.yml). No FFMPEG_DIR: nothing in this package links libav* (M10),
# and pack-msix.ps1 no longer copies runtime DLLs from one.
"CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
rustup target add ${{ matrix.target }}
$pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag
$parts = if ($env:GITHUB_REF -like 'refs/tags/v*') {
# MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix.
(($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.')
} else {
# Canary: <major>.<minor>.<run>.0 — major.minor track one minor ahead of stable, run climbs monotonically.
@($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER)
}
while ($parts.Count -lt 4) { $parts += '0' }
$v = ($parts[0..3] -join '.')
"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.
- 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 }}
- name: Pack + sign MSIX
shell: pwsh
env:
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
& clients/windows/packaging/pack-msix.ps1 `
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
- name: Publish to Gitea generic registry
shell: pwsh
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
$PSNativeCommandUseErrorActionPreference = $false
$base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
# stable release -> `latest/` alias; canary main build -> `canary/` alias.
$alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
# version-less, arch-suffixed alias names so each channel keeps one predictable URL.
$aliasNames = @{
"$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix"
"$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer"
}
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" }
function Put($f, $url) {
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
# re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
# alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
# status code instead of aborting on it.
$code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
Write-Output "published ($code): $url"
}
foreach ($f in $files) {
$name = Split-Path $f -Leaf
# 1) immutable, versioned path
Put $f "$base/$($env:MSIX_VERSION)/$name"
# 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file)
$an = $aliasNames["$f"]
curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
Put $f "$base/$alias/$an"
}
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
# the race, and x64/arm64 filenames differ so the assets don't collide.
- name: Attach MSIX to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
+169
View File
@@ -0,0 +1,169 @@
# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner +
# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup,
# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast
# no-op once already present, so any runner with that label works with no manual dispatch step
# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell
# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client
# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the
# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the
# session builds --no-default-features there (no Skia console UI; streaming is unaffected) —
# flip when skia-binaries adds the target.
#
# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with
# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets
# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning
# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's
# `amf-qsv` leg link-imports them.
#
# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and
# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler
# (VC\Tools\MSVC\<ver>\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust
# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64
# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one
# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64.
#
# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME,
# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout
# / per-arch vars are set in a step:
# - 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
# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then
# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short
# root keeps every nested path well under the limit (per-arch so the two
# matrix legs don't share a target dir).
#
# 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.
# 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:
branches: [main]
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
pull_request:
paths:
- 'clients/windows/**'
- 'clients/session/**'
- 'crates/punktfunk-core/**'
- 'crates/pf-client-core/**'
- 'crates/pf-presenter/**'
- 'crates/pf-console-ui/**'
- 'crates/pf-bitstream/**'
- 'crates/pf-vkdecode/**'
- 'crates/pf-dxvadec/**'
- 'Cargo.lock'
- 'Cargo.toml'
- '.gitea/workflows/windows.yml'
workflow_dispatch:
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can
# never collide; every Rust job on every host feeds and reads one warm cache.
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: unom-ci-sccache
SCCACHE_ENDPOINT: https://storage.unom.io
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
jobs:
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
# windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
# never blocks internal PR CI.
build:
runs-on: windows-amd64
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc]
steps:
- uses: actions/checkout@v4
- name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target)
shell: pwsh
run: ./scripts/ci/ensure-windows-toolchain.ps1
- name: Configure + toolchain versions
shell: pwsh
run: |
# Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\).
$td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' }
"CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav*
# since M10 (see this file's header), so nothing here needs import libs or runtime DLLs.
# The HOST still does — windows-host.yml sets them for its amf-qsv leg.
rustup target add ${{ matrix.target }}
rustc --version
cargo --version
Write-Output "target ${{ matrix.target }} target-dir $td"
# 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 }}
- name: Clippy (-D warnings)
shell: pwsh
run: |
# Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a
# dependency but only LINTS the packages it is given, so a decode crate that starts the
# run but is missing from this list would be gated by nothing.
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec')
$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
- name: Rustfmt check
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check
- name: Test
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }}
-22
View File
@@ -1,22 +0,0 @@
# AGENTS.md
Guidance for coding agents working in this repository.
## Agent skills
### Issue tracker
Issues live as Gitea issues in `unom/punktfunk` on `git.unom.io`, driven by the `gitea` MCP server
(`gh`/`glab`/`tea` do not work here), and every write needs the user's go-ahead first.
See `docs/agents/issue-tracker.md`.
### Triage labels
The five canonical roles, each label string equal to its name — `needs-triage`, `needs-info`,
`ready-for-agent`, `ready-for-human`, `wontfix` — none of which exist in the tracker yet.
See `docs/agents/triage-labels.md`.
### Domain docs
Single-context: one `CONTEXT.md` and one `docs/adr/` at the repo root, covering the whole
workspace. See `docs/agents/domain.md`.
-4321
View File
File diff suppressed because it is too large Load Diff
+3 -29
View File
@@ -83,41 +83,15 @@ Two more gates that only apply to some changes:
instead of waiting for the CI job that compiles it.
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
and CI fails if the committed copy drifts. `api/openapi.json` is gated the same way: the `rust` job
regenerates the spec and diffs it against the committed file, and the `docs-drift` job checks that
`docs-site/public/openapi.json` — the snapshot the docs site serves — is a byte-for-byte copy of it.
Touch the management API and CI stays red until you regenerate and re-copy:
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
and copy the snapshot the docs site serves:
```sh
cargo run -p punktfunk-host -- openapi > api/openapi.json
cp api/openapi.json docs-site/public/openapi.json
```
## Where facts live (docs vs READMEs vs website)
Every user-facing fact has exactly one canonical home; everything else links to it. Duplicated
walkthroughs are how the docs drifted before — don't add new ones.
| Surface | Owns | Never contains |
|---|---|---|
| [docs-site](https://docs.punktfunk.unom.io) (`docs-site/content/`) | All user-facing facts: install, config, features, troubleshooting | Design rationale |
| READMEs (root, `packaging/*`, `scripts/*`) | Dev/packager rationale and pointers into the docs | User walkthroughs duplicated from docs-site |
| [punktfunk.unom.io](https://punktfunk.unom.io) (separate repo) | Marketing, downloads, blog | Instructions — it deep-links the docs instead |
| punktfunk-planning (private) | Design rationale, RFCs, plans | Anything user-facing |
Docs pages are written for one of two audiences, not both at once: the **get-started track**
(quickstart, install, pairing — short, one task per page, happy path only) assumes no Linux
expertise; the **reference track** (configuration, CLI, API, per-compositor pages) is allowed to be
dense. When a change touches a user-facing fact, update the docs-site page that owns it in the same
PR.
CI enforces the cheap half of this (`scripts/ci/check-docs-drift.sh` and `check-docs-links.sh`):
the OpenAPI snapshot must match `api/openapi.json`, the docs-site copy of `data/platforms.json` must
match the canonical one, `scripts/install.sh` must carry the file's install lines verbatim, every `PUNKTFUNK_*` variable the docs mention
must still exist in the tree, the counts of undocumented `PUNKTFUNK_*` variables and undocumented
`punktfunk-host` subcommands may never grow (document the new knob, or consciously raise the
baseline in the script), and internal docs links must resolve.
Match the surrounding code's comment density and naming. Commit messages end with the
`Co-Authored-By` trailer (see `git log`).
Generated
+1156 -880
View File
File diff suppressed because it is too large Load Diff
+3 -22
View File
@@ -4,9 +4,6 @@ members = [
"crates/punktfunk-core",
"crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim",
# The capability-carrying PyroWave encode worker. A SEPARATE binary by design — never a
# hardlink of, or a subcommand of, punktfunk-host (design/gpu-priority-capability-worker.md).
"crates/punktfunk-encode-worker",
"crates/punktfunk-tray",
"crates/pf-bitstream",
"crates/pf-bitstream/vendor/cros-codecs",
@@ -49,11 +46,6 @@ members = [
exclude = [
"packaging/linux/steam-deck-gadget/usbip-poc",
"clients/android/native/vendor/ndk",
# Bring-your-own-hardware measurement tools. `hid-descriptor-dump` pulls `hidapi`, a C library
# wanting libudev on Linux; `win-input-matrix` is Windows-only and asks the live input stacks
# what they can see. Neither belongs in `cargo build --workspace` or on a CI leg with no pad.
"tools/hid-descriptor-dump",
"tools/win-input-matrix",
]
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
@@ -65,9 +57,9 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.31.0"
edition = "2024"
rust-version = "1.85"
version = "0.24.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
authors = ["unom"]
repository = "https://git.unom.io/unom/punktfunk"
@@ -101,17 +93,6 @@ repository = "https://git.unom.io/unom/punktfunk"
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
# The companion lint: every `unsafe {}` / `unsafe impl` carries a `// SAFETY:` proof. Hoisted here
# from ~85 per-file `#![deny(...)]` attributes so a NEW crate (or a new module in an old one) is
# covered on creation rather than on remembering — the per-file form left pf-vkhdr-layer,
# wdk-probe, and half of pf-clipboard uncovered for months. NOTE: this table reaches only crates
# with `[lints] workspace = true`; `packaging/windows/drivers` and `packaging/windows/pf-vkhdr-layer`
# are SEPARATE workspaces and restate it (any "workspace-wide" claim must be made three times or it
# is false). Of the members, only the two vendored snapshots (pf-bitstream/vendor/cros-codecs,
# punktfunk-host/vendor/usbip-sim) stay out, deliberately — upstream code stays pristine.
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
[profile.release]
opt-level = 3
lto = "thin"
+30 -5
View File
@@ -100,7 +100,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
| Platform | Install | Guide |
|--------|---------|-------|
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
@@ -109,11 +109,36 @@ installer (all-vendor: NVIDIA, AMD, Intel).
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
The per-platform guide walks you through the rest — first run, the web console, pairing, and the
desktop-specific wiring ([KDE](https://docs.punktfunk.unom.io/docs/kde) ·
[GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
installed first:
```sh
mkdir -p ~/.config/punktfunk
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
# (on Bazzite take host.env.bazzite instead)
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
systemctl --user enable --now punktfunk-host # the streaming host
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
```
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
line before the replacement — the install guide has the snippet). Then open
`https://<host-ip>:47992` and pair.
How the virtual display and input are wired up depends on your desktop — see
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
[Sway](https://docs.punktfunk.unom.io/docs/sway)).
[Sway](https://docs.punktfunk.unom.io/docs/sway).
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
nothing to run by hand — open the web console and pair. Use
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
`setup.exe` over the old install; uninstall from Add/Remove Programs.
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
+1 -5
View File
@@ -5,17 +5,13 @@ machine, so we take security reports seriously and appreciate responsible disclo
## Supported versions
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag) and
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
we don't backport to older minor versions, so the supported versions are the latest stable release
and the current canary build. If you're on an older build, please check that the issue still
reproduces on the latest stable before reporting it. See
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
Security fixes are **free of charge**, ship **without undue delay**, and are **separated from
feature updates where feasible**: on the stable track they arrive as patch releases (`vX.Y.Z+1`)
that carry the fix rather than waiting on the next feature release.
## Reporting a vulnerability
**Please report security issues privately by email to security@punktfunk.com.**
+1309 -1840
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -40,6 +40,7 @@ accepted = [
"CC0-1.0",
"Unlicense",
"WTFPL",
"OpenSSL",
]
# cbindgen is MPL-2.0 but it is a BUILD-ONLY codegen tool that never links into a shipped artifact
@@ -56,8 +57,7 @@ ignore-dev-dependencies = true
# 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.)
#
# There is deliberately NO per-crate entry here any more. `ring` used to need one (its licence is an
# AND that includes the OpenSSL licence, which was accepted for that crate alone), but the crypto
# backend moved to aws-lc-rs and the ureq 2 → 3 upgrade removed ring from every target we build.
# aws-lc-sys 0.44's SPDX is an AND of ISC / Apache-2.0 / MIT / BSD-3-Clause / MIT-0 — all globally
# accepted above — and carries no OpenSSL clause, so `OpenSSL` left the global list with ring.
# 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"]
+27 -1175
View File
File diff suppressed because it is too large Load Diff
@@ -1,17 +0,0 @@
Font Awesome Free — brand icons (steam, xbox in assets/launcher-icons/) are from
Font Awesome Free.
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
The icons are redistributed here as monochrome SVG path data with no
modifications beyond color normalization (fill="currentColor").
Per the Font Awesome Free license (https://fontawesome.com/license/free):
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Attribution is required by MIT, SIL OFL, and CC BY licenses."
Brand icons are trademarks of their respective owners and are used for
identification purposes only; their use does not imply endorsement.
@@ -1,29 +0,0 @@
Playnite — the `playnite` mark in assets/launcher-icons/ is the Playnite logo from the
Playnite source repository (media/playnite-logo-black.svg).
Copyright (c) 2020 Josef Nemec (https://github.com/JosefNemec/Playnite)
Licensed under the MIT License:
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Modifications: the colour was normalized to fill="currentColor"; the original viewBox
(0 0 1024 1024) and path geometry are unchanged.
Brand icons are trademarks of their respective owners and are used for identification
purposes only; their use does not imply endorsement.
@@ -1,13 +0,0 @@
Simple Icons — brand icons (lutris, heroic, epic, gog in assets/launcher-icons/) are
from Simple Icons
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
Upstream slugs: lutris, heroicgameslauncher, epicgames, gogdotcom.
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.
-62
View File
@@ -1,62 +0,0 @@
# Launcher icon masters
The brand marks a **launcher tile** draws — the entries a library plugin publishes with
`role: "launcher"` (design D4), which open Steam Big Picture or Heroic or Playnite rather
than a game. One file per **icon token**, the value a plugin puts in an entry's `icon`
field and every client resolves against the set it ships.
| token | mark | emitted by | source |
|---|---|---|---|
| `steam` | Steam | punktfunk-plugin-steam (Big Picture + desktop) | Font Awesome Free brands (CC BY 4.0) |
| `lutris` | Lutris | punktfunk-plugin-lutris | Simple Icons (CC0 1.0) |
| `heroic` | Heroic Games Launcher | punktfunk-plugin-heroic | Simple Icons (CC0 1.0, slug `heroicgameslauncher`) |
| `playnite` | Playnite | punktfunk-plugin-playnite | JosefNemec/Playnite (MIT) |
| `epic` | Epic Games | punktfunk-plugin-epic — **dormant** | Simple Icons (CC0 1.0, slug `epicgames`) |
| `gog` | GOG.com | punktfunk-plugin-gog — **dormant** | Simple Icons (CC0 1.0, slug `gogdotcom`) |
| `xbox` | Xbox | punktfunk-plugin-xbox — **dormant** | Font Awesome Free brands (CC BY 4.0) |
The last three are **dormant on purpose**: those plugins carry a `launcher` config switch that
is off by default and whose `launcherEntries` returns nothing, because the host has no verified
`launcher_ui` activation for them yet — a tile would be a card that does nothing. Their marks
ship anyway so that turning one on stays the one-line plugin change those plugins promise,
instead of also needing a release of all six clients.
`steam` is the same mark as `assets/os-icons/steam.svg`, generated from that file rather than
re-sourced, so the SteamOS host badge and the Steam launcher tile can never drift apart.
## Why a token and not the icon itself
A plugin sends the **name** of a mark, never its bytes, and never a URL.
The obvious alternative — a plugin ships its own `icon.svg` and the host's art proxy serves it —
is closed by construction, and deliberately: `local_art_bytes` serves what the bytes *are*
(`sniff_image_type`, `crates/punktfunk-host/src/library/art.rs`), and SVG is not on that list
because it is script-capable XML and the web console renders library art in a browser. Widening
that sniff to admit SVG would trade a rendering nicety for a stored-XSS surface.
Sending a token instead keeps that refusal intact and buys three things a proxied image could
not have given us anyway: the glyph stays vector at every tile size a client picks, it takes the
tile's own ink instead of arriving pre-coloured, and it costs no fetch, no cache and no bytes on
a reconcile that is already body-limited.
The cost is that a **third-party** plugin cannot ship a mark no client bundles. Its tile falls
back to the launcher's name on an accent face — exactly what every launcher tile looked like
before this existed — and the fix is a pull request adding the master here.
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Those
viewBoxes are not all square (`0 0 24 24`, `0 0 496 512`, `0 0 1024 1024`), so **a client must
letterbox rather than stretch** — a mark drawn to a square box is a squashed mark.
## Regenerating the per-client derivatives
`bash scripts/gen-launcher-icons.sh [token ...]` turns a master into the three baked forms (GTK
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
that inline it (web console, Android, the in-session console UI). Adding a **new** token also
means adding it to each client's shipped-token list — the script prints that checklist too.
## Licensing
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
are used here nominatively — to *identify* the launcher a tile opens, the standard practice in
this ecosystem — and imply no affiliation or endorsement.
-2
View File
@@ -1,2 +0,0 @@
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

-2
View File
@@ -1,2 +0,0 @@
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-2
View File
@@ -1,2 +0,0 @@
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>

Before

Width:  |  Height:  |  Size: 957 B

-2
View File
@@ -1,2 +0,0 @@
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

-2
View File
@@ -1,2 +0,0 @@
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

-2
View File
@@ -1,2 +0,0 @@
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. 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: 956 B

-2
View File
@@ -1,2 +0,0 @@
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+1 -38
View File
@@ -9,26 +9,7 @@
# from the last image rebuild instead of a fresh -Syu per run. That is the same staleness
# the gamescope cache already embraces ("a stale binary against newer system libs is the
# same risk the distro's own package carries between rebuilds"), and any ci/ edit — or
# bumping the date in this line (refreshed: 2026-08-08) — re-keys and re-snapshots it.
#
# ⚠ That staleness has a sharp edge, and 2026-08-08 is why the date above moved: this snapshot is
# what decides which FFmpeg the HOST links, and arch.yml deliberately runs no -Syu, so the builder
# stayed frozen on ffmpeg 8 (libavcodec 62) even after Arch shipped 2:9.0-5 (libavcodec 63) to
# every user. A canary built from the old snapshot therefore CANNOT satisfy the soname dep that
# packaging/arch/PKGBUILD now derives from the link (libavcodec.so=62-64 against a box that has
# 63-64), so it would simply refuse to install rather than start. Re-keying this image is the step
# that makes the ffmpeg-9 bump actually reach the package — a Cargo.toml bump alone does nothing
# here. Whenever Arch moves to an FFmpeg major, bump the date in the same commit.
#
# ⚠ AND KNOW WHY THAT WAS NOT ENOUGH: bumping this date only helps once docker.yml has actually
# republished the image, and nothing sequences the two workflows. v0.25.0 was tagged four minutes
# after the ffmpeg-9 merge, so the release build still pulled the FFmpeg-8 `:latest` and published
# a punktfunk-host that no up-to-date Arch box could install — which blocks the user's ENTIRE
# `pacman -Syu`, not just our package. arch.yml therefore no longer trusts this image on that one
# axis: it compares the builder's libav sonames against the repos before building (and `-Syu`s
# itself if they differ), and refuses to publish anything a pristine-db `pacman -U --print` says
# is unsatisfiable. This file staying current is still the CHEAP path — those guards are the
# backstop, not the plan.
# 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
@@ -48,11 +29,6 @@ RUN pacman -Syu --noconfirm --needed \
hwdata luajit seatd sdl2-compat vulkan-icd-loader \
xcb-util-errors xcb-util-wm xorg-xwayland \
meson glm wayland-protocols benchmark libxcursor \
# mold: link-phase accelerator (sccache cannot cache linking). makepkg links the release
# host, client, worker and tray on every arch.yml run. Wired via cargo-config-mold.toml
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
# and its `-static-libstdc++` link is untouched.
mold \
&& pacman -Scc --noconfirm
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
@@ -69,16 +45,3 @@ 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
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
# level (and so `cargo` finds the config below when the image is used by hand). The workflow still
# passes CARGO_HOME explicitly across the `sudo -u builder env …` boundary, which strips ambient
# env — that is why the C/C++ sccache wiring has to be re-exported there by name while THIS file,
# being a file, crosses the boundary for free.
ENV CARGO_HOME=/usr/local/cargo
RUN mkdir -p /usr/local/cargo && chmod -R a+w /usr/local/cargo
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
RUN mold --version && test -r /usr/local/cargo/config.toml
-41
View File
@@ -1,41 +0,0 @@
# Installed as $CARGO_HOME/config.toml in every Linux CI builder image (ci/*.Dockerfile).
#
# WHAT: link the x86_64 Linux targets with mold instead of GNU ld. Linking is the one phase of a
# Rust build that sccache CANNOT cache — every job relinks punktfunk-host, punktfunk-client-linux,
# punktfunk-client-session, punktfunk-cli, pf-update and punktfunk-encode-worker from scratch on
# every run, and the packaging legs (deb/rpm/arch) do it for release binaries with full debug info.
# mold is the only lever that touches that phase.
#
# ⚠ THE TRAP THIS FILE HAS TO STAY CLEAR OF — read before editing, and before adding rustflags
# anywhere else in this repo:
#
# 1. A `RUSTFLAGS` ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY. It does not merge
# and it does not append. Any job that sets RUSTFLAGS silently loses mold here — it still
# builds, just with the default linker. Never "simplify" this file into a RUSTFLAGS export.
# (This trap used to be far worse: the workspace .cargo/config.toml carried aarch64
# `--cfg aes_armv8` / `--cfg polyval_armv8`, worth ~10x on the decrypt path, and an override
# dropped those too. The RustCrypto aes 0.9 / polyval 0.7 bump retired both cfgs — see the
# tombstone in .cargo/config.toml — so today only mold is at stake here.)
#
# 2. CONFIG FILES MERGE PER KEY, HIGHEST-PRECEDENCE FILE WINS — they do not concatenate. The
# workspace's .cargo/config.toml outranks this one ($CARGO_HOME is the LOWEST precedence).
# Today that is harmless because the workspace file defines NO rustflags at all and this one
# defines only `target.x86_64-unknown-linux-gnu.rustflags`. But the moment someone adds an
# x86_64 rustflags entry to the workspace .cargo/config.toml, IT WINS and mold silently stops
# being used here. If that ever happens, move the link-arg into that file instead of
# duplicating it.
#
# 3. aarch64 IS DELIBERATELY NOT WIRED. The cross image links with aarch64-linux-gnu-gcc against a
# multiarch sysroot (ci/rust-ci-arm64cross.Dockerfile); pointing that driver at mold is a
# separate thing to prove, and those legs are already the fast ones (~1.5 min of clippy, ~5 min
# for the arm64 .deb). Add it only with a measurement, and in a commit of its own.
#
# Requires GCC >= 12.1 (or clang) for `-fuse-ld=mold`; every base here ships far newer. mold itself
# is installed in the same Dockerfile layer that copies this file, so an image can never carry the
# flag without the linker — see the `mold --version` assertion there.
#
# NOTE this affects the HOST-targeted compiles of build scripts and proc macros too (they are
# x86_64-unknown-linux-gnu), which is exactly what we want: those link constantly and are pure
# overhead.
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
-11
View File
@@ -22,12 +22,6 @@ RUN dnf -y install \
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
# build toolchain + bindgen
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
# mold: link-phase accelerator (sccache cannot cache linking). This image links the release
# host, client, worker and tray on every rpm.yml run, TWICE per push (f43 + f44). Wired via
# cargo-config-mold.toml below. Note the linker DRIVER is unchanged — still gcc, so Fedora's
# default `-Wl,--build-id` still reaches the link and rpmbuild's debuginfo extraction (which
# hard-requires a build-id) behaves exactly as before; mold implements --build-id natively.
mold \
# ffmpeg (NVENC), capture/audio/display link deps
ffmpeg-devel pipewire-devel wayland-devel libxkbcommon-devel opus-devel \
mesa-libGL-devel mesa-libgbm-devel \
@@ -82,8 +76,3 @@ 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
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
RUN mold --version && test -r /usr/local/cargo/config.toml
-92
View File
@@ -1,92 +0,0 @@
# Flatpak CI builder: everything flatpak.yml used to install per run, plus the Flathub
# runtime set the manifest declares. Content-keyed and rebuilt only when the ci/ tree
# changes (docker.yml `builders`).
#
# docker build -f ci/flatpak-ci.Dockerfile -t punktfunk-flatpak-ci ci
#
# MEASURED on run 18855 (2026-08-17), a green 21m23s build, this image removes:
# 63 s `dnf -y install nodejs` (act_runner execs JS actions with the CONTAINER's node)
# 240 s `Tooling` — 330 packages
# 168 s restoring a 1.5 GB actions/cache of ~/.local/share/flatpak
# i.e. ~7.8 min of an 8 min preamble in front of a 6 min compile. The runtimes are the
# bulk of the image and the reason it exists; a package-only image would leave the
# biggest single step in place.
#
# WHY FEDORA and not the flathub org.flatpak.Builder image: flatpak.yml's job container
# must run bubblewrap under --privileged in the act_runner Docker executor, and Fedora
# ships a flatpak/flatpak-builder pair recent enough for the manifest with the kernel
# userns support already enabled. See flatpak.yml's header for the --privileged and
# --network host constraints, both of which still apply to this image.
FROM docker.io/library/fedora:43
# nss-resolve trap, baked. fedora:43's nsswitch.conf is
# `hosts: files myhostname resolve [!UNAVAIL=return] dns`: `resolve` is nss-resolve
# (systemd-resolved), which does not run in a CI container. glibc consumers (git, curl,
# dnf) fall through to `dns`; flatpak/ostree's resolver does NOT — the absent-daemon
# socket connect trips `[!UNAVAIL=return]` and it reports "[6] Could not resolve
# hostname". flatpak.yml carries the same sed as a runtime step and had to repeat it
# after its dnf transaction, because a systemd upgrade's authselect trigger regenerates
# the file. Doing it here, after the last dnf in the image, ends that whack-a-mole for
# every consumer — the workflow's copy stays only as a guard for a lagging :latest.
RUN dnf -y install \
# the build itself
flatpak flatpak-builder \
# ostree CLI: the seed step pulls the published channels' tip commits with it
# (`dnf install flatpak` brings the LIBRARY, not the binary)
ostree \
# actions/checkout is a JS action and act_runner does not inject a node
nodejs git git-lfs \
# flatpak-cargo-generator.py needs aiohttp + tomlkit (NOT the old `toml`)
python3 python3-aiohttp python3-tomlkit \
# sign the OSTree repo + rsync it to unom-1; jq/curl for the registry publish
gnupg2 rsync openssh-clients curl jq \
&& dnf clean all \
&& sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf \
&& ! grep -q 'resolve \[!UNAVAIL=return\]' /etc/nsswitch.conf
# The Flathub runtime/SDK set, baked into the image's USER installation (/root/.local,
# HOME=/root in the job container) — the same path flatpak.yml used to restore from
# actions/cache, so `flatpak-builder --user` finds these with no further wiring.
#
# ⚠ KEEP IN SYNC with packaging/flatpak/io.unom.Punktfunk.yml: GNOME_VERSION is the
# manifest's `runtime-version`, FREEDESKTOP_VERSION is what flatpak-builder resolves the
# two `sdk-extensions` to (it prints them as "Dependency Extension: … 25.08"). A drift
# here is not fatal — flatpak.yml's prefetch step downloads whatever is missing from
# Flathub, retried — but it silently costs ~1.5 GB and several minutes per run, which is
# precisely what this image exists to avoid. Bump both in the same commit as the manifest.
#
# `flatpak install` HERE, at docker-build time, needs no privileges — VERIFIED 2026-08-17
# in a plain `docker run` container where bwrap was proven broken first
# ("bwrap: No permissions to creating new namespace"): the install still exited 0 and
# `flatpak info --user` resolved the ref. flatpak's post-deploy triggers are the only
# part that wants bwrap and they are best-effort, so this layer does NOT need the
# --privileged that the CONSUMING job needs for flatpak-builder's actual sandbox.
#
# Related refs (org.freedesktop.Platform.GL.default{,-extra}, codecs-extra,
# org.gnome.Platform.Locale) come along by default and are deliberately kept: they are
# what `flatpak-builder --install-deps-only` would otherwise pull on every run, so a
# --no-related image would look smaller and cost more. org.gnome.Platform//50 alone
# unpacks to 2.4 GB; the whole set is the same content the 1.5 GB (compressed) runtime
# actions/cache already carried — this moves it from the cache server to the registry,
# where Docker keeps it on the runner's disk instead of re-extracting it every run.
ARG GNOME_VERSION=50
ARG FREEDESKTOP_VERSION=25.08
RUN flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo \
&& flatpak install --user -y --noninteractive flathub \
"org.gnome.Platform//${GNOME_VERSION}" \
"org.gnome.Sdk//${GNOME_VERSION}" \
"org.freedesktop.Sdk.Extension.rust-stable//${FREEDESKTOP_VERSION}" \
"org.freedesktop.Sdk.Extension.llvm20//${FREEDESKTOP_VERSION}" \
# Assert rather than trust. `flatpak install` treats its post-deploy triggers as
# best-effort and this RUN's exit status would not notice a ref that failed to
# deploy; an image that merely LOOKS warm would push the 1.5 GB back onto every
# single run, where it reads as "flatpak is slow again" instead of a broken image.
# Fail the image build loudly instead — docker.yml goes red and :latest never moves.
&& for ref in \
"org.gnome.Platform//${GNOME_VERSION}" \
"org.gnome.Sdk//${GNOME_VERSION}" \
"org.freedesktop.Sdk.Extension.rust-stable//${FREEDESKTOP_VERSION}" \
"org.freedesktop.Sdk.Extension.llvm20//${FREEDESKTOP_VERSION}" \
; do flatpak info --user "$ref" >/dev/null || exit 1; done \
&& flatpak list --user --columns=ref
-83
View File
@@ -1,83 +0,0 @@
# Builder for the `punktfunk-gamescope` .deb — Debian 13 (trixie).
#
# WHY THIS EXISTS, AND WHY IT IS NOT THE NOBLE IMAGE:
# The gamescope .deb was built in the host job's Ubuntu 24.04 (noble) image, and it has NEVER once
# succeeded there — v0.26.0 and v0.27.0 both shipped with no gamescope package while the release
# notes and docs-site said it was apt-installable. The failure is structural, not a flaky dep:
#
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
# subprojects/wlroots/meson.build:96:17: ERROR: Dependency 'wayland-server' is required but not found
#
# Our gamescope pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1. Noble ships
# 1.22.0 and will never ship more — so no amount of `apt-get install` in that image can fix it.
# Noble also has no `libxcb-errors-dev` at all and only libdisplay-info 0.1.1 (the tree wants 0.2).
#
# Debian 13 ships wayland 1.23.1 exactly, libxcb-errors 1.0.1 and libdisplay-info 0.2.0 — the
# oldest apt distro the tree actually builds on. Building HERE rather than on Ubuntu 26.04
# (wayland 1.24, libdisplay-info 0.3) is deliberate twice over: it keeps the glibc floor low, and
# it stays on the libdisplay-info 0.2 line the pin was developed against.
#
# WHAT THE RESULTING BINARY RUNS ON — verified by building it and reading the ELF:
# * glibc floor GLIBC_2.38 (the C++ runtime is linked statically by
# build-punktfunk-gamescope.sh, so libstdc++ never enters the NEEDED list)
# * NEEDED libwayland-server.so.0 / libwayland-client.so.0 — wlroots 0.19 calls symbols
# added in 1.23.1, so THAT, not glibc, is the real floor.
# ⇒ Debian 13 (1.23.1) and Ubuntu 26.04 (1.24.0) YES; Ubuntu 24.04 (1.22.0) NO — and 24.04
# could not run this binary however it was built, so nothing is lost by moving off noble.
#
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-gamescope-trixie); consumed by
# the `build-publish-gamescope` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci-noble, the
# first deb.yml run after this image is added needs the image to already exist — seed it once by
# hand (docker build -f ci/gamescope-trixie.Dockerfile -t <registry>/punktfunk-gamescope-trixie:latest ci
# && docker push …) before that job can run.
FROM debian:trixie
ENV DEBIAN_FRONTEND=noninteractive
# nodejs is not optional: the Gitea runner executes the JS actions (checkout/cache) INSIDE this
# container, so an image without it fails before the first `run:` step ever starts.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config cmake meson ninja-build git curl ca-certificates nodejs \
# .deb assembly (dpkg-shlibdeps computes the runtime Depends from the built binary)
dpkg-dev \
# shader compilers gamescope's meson looks for
glslc glslang-tools \
# wayland + protocols. libwayland-dev 1.23.1 is the whole reason this image is Debian.
libwayland-dev wayland-protocols \
# gamescope's own dependency set. `apt-get build-dep gamescope` is useless here — Debian has
# no gamescope package to derive it from — so the tree's needs are named outright, exactly as
# the noble job had to. Kept as ONE transaction on purpose: in an image build a missing name
# SHOULD fail loudly at build time, unlike the workflow's per-package best-effort loop where a
# rename would have silently dropped a dep into a warning nobody reads.
libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
# x11-xcb is needed by the VULKAN WSI LAYER (layer/meson.build), not by the compositor — so it
# was not missed until v0.28.1 started building the layer beside the binary. Debian is the only
# channel that needs it named: Arch's libx11 and Fedora's libX11-devel both carry x11-xcb.pc
# themselves, while Debian splits it into its own -dev package.
libx11-xcb-dev \
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
libxcb-ewmh-dev libxcb-icccm4-dev libxcb-errors-dev libxcb-shape0-dev \
libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev xwayland \
&& rm -rf /var/lib/apt/lists/*
# Assert the ONE version that decides whether this image can do its job, so a future Debian base
# bump that regressed it fails HERE (loudly, at image build) instead of in a deb.yml run whose
# gamescope failure has historically been a `::warning::` nobody saw.
RUN set -eux; \
have="$(pkg-config --modversion wayland-server)"; \
pkg-config --atleast-version=1.23.1 wayland-server \
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
echo "wayland-server $have — OK"
# The layer's own floor, asserted for the same reason: a missing x11-xcb does not fail the
# COMPOSITOR build, it fails `layer/meson.build` — and the layer is the only route to an HDR10
# swapchain for a nested game, so losing it silently ships a package that looks healthy and denies
# every game HDR. This is exactly how v0.28.1's deb leg broke, one release after the layer was
# added; assert it here so the next dep the layer grows fails at image build, not mid-release.
RUN set -eux; \
pkg-config --exists x11-xcb \
|| { echo "x11-xcb absent — the Vulkan WSI layer will not configure (need libx11-xcb-dev)" >&2; exit 1; }; \
echo "x11-xcb $(pkg-config --modversion x11-xcb) — OK"
-18
View File
@@ -26,9 +26,6 @@ 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 \
# mold: link-phase accelerator (sccache cannot cache linking). This image links the release
# host + encode worker on every deb.yml run. Wired via cargo-config-mold.toml below.
mold \
# .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
@@ -48,14 +45,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
#
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
# — and this .deb is the one package with NO exposure to the soname break that motivated the bump: it
# BUNDLES these libs into /usr/lib/punktfunk-host behind an rpath and strips the libav* sonames from
# its Depends, so nothing the user's apt does can move them underneath it. Bumping this tag would
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
ARG FFMPEG_TAG=n8.0
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
@@ -102,10 +91,3 @@ 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
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY.
# ⚠ This does NOT touch the from-source FFmpeg built above: that is a plain ./configure && make in
# an earlier layer, linked by GNU ld exactly as before. Only cargo's links move to mold.
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
RUN mold --version && test -r /usr/local/cargo/config.toml
+1 -15
View File
@@ -13,12 +13,7 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
# mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this
# image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below.
mold \
# ffmpeg-next 9, built against whatever libav* 26.04 ships (FFmpeg 8 / libavcodec 62 today).
# The crate major is a CEILING — ffmpeg-sys-next 9 spans libavcodec 56..63 — so this image does
# not need to move in lockstep with Arch's FFmpeg 9; it just links what the distro has.
# ffmpeg-next 8 (system FFmpeg 8 / libavcodec 62 on 26.04)
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev \
libavdevice-dev \
# capture / audio / display stacks (+xkbcommon for the wlr input backend)
@@ -64,12 +59,3 @@ 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
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
#
# The assertion is the point: an image carrying the flag but NOT the linker would fail every cargo
# invocation in every consuming job, which is a catastrophic way to find out that a base image
# renamed the package. `mold --version` fails the docker build instead, so nothing is pushed and
# `:latest` keeps pointing at the previous working image — consumers never see it.
COPY cargo-config-mold.toml /usr/local/cargo/config.toml
RUN mold --version && test -r /usr/local/cargo/config.toml
-2
View File
@@ -1,8 +1,6 @@
# Gradle / Android build artifacts
.gradle/
build/
# Kotlin compiler daemon session files, written by any Gradle invocation.
.kotlin/
local.properties
*.iml
.idea/
+1 -2
View File
@@ -22,8 +22,7 @@ Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "n
## Get it
Published to **Google Play (Open Testing)** — join via the
[public opt-in link](https://play.google.com/apps/testing/io.unom.punktfunk) or the
Published to **Google Play (Internal Testing)** — join the beta via the
[Discord](https://discord.gg/kaPNvzMuGU). Per-device setup and pairing:
**[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**.
-8
View File
@@ -142,16 +142,8 @@ dependencies {
// job runs `:app:testDebugUnitTest -PskipRustBuild` (see kit/build.gradle.kts). ---
testImplementation(composeBom)
testImplementation("androidx.compose.ui:ui-test-junit4")
// Deterministic cover art for the library scene: FakeImageLoaderEngine answers the coverflow's
// AsyncImage synchronously with generated posters — no network, no async race under the frozen
// animation clock.
testImplementation("io.coil-kt:coil-test:2.7.0")
debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host
testImplementation("junit:junit:4.13.2")
// Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub
// set whose every method throws "Stub!", so a plain JVM unit test cannot parse with it. Same
// dependency, same reason, as the kit module's deeplink-vectors test.
testImplementation("org.json:json:20250107")
testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0")
@@ -28,14 +28,9 @@
<!-- 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) from API 31 — MainActivity asks
for it when a BLE-paired SC2 is actually around, and the Controllers screen offers the
grant outright. USB capture (wired / Puck dongle) needs no Bluetooth at all.
Below API 31 the same two operations (the bonded list + connectGatt) are covered by the
install-time legacy permission instead, which BLUETOOTH_CONNECT does NOT imply — without
it every BLE capture on Android 11 and older throws SecurityException. -->
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" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
devices without a gamepad. -->
File diff suppressed because it is too large Load Diff
@@ -1,317 +0,0 @@
package io.unom.punktfunk
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import io.unom.punktfunk.models.PendingTrust
// The touch UI's prompts, each described once — a title, a list of [DialogAction]s (primary
// first) and a body — and drawn as a Material `AlertDialog`.
//
// History: these used to be drawn a second way, as the Compose console's glass card, and the two
// renderers drifted by hand until the descriptions were shared here. The Compose console is gone
// (the console is the Skia shell now — design/android-skia-console-port.md — and it draws its own
// pairing/trust screens), so only the touch renderer remains; the shared-description shape stays
// because it is the right shape regardless.
/** One button of a prompt. [primary] lifts it into the confirm slot; the rest lay out beside. */
class DialogAction(
val label: String,
val primary: Boolean = false,
val enabled: Boolean = true,
val onClick: () -> Unit,
)
/**
* One prompt. [actions] is ordered PRIMARY FIRST — the first (or the one flagged primary) becomes
* `confirmButton`, the rest lay out beside it.
*/
@Composable
fun PunktfunkDialog(
title: String,
onDismiss: () -> Unit,
actions: List<DialogAction>,
/**
* False pins the prompt open against a stray tap outside it — for a dialog sitting over work
* in flight, where a mis-tap would abandon it. Console-side there is no outside to tap, so
* this only reaches the touch renderer.
*/
dismissOnOutsideTap: Boolean = true,
body: @Composable ColumnScope.() -> Unit,
) {
val primary = actions.firstOrNull { it.primary } ?: actions.firstOrNull()
val rest = actions.filter { it !== primary }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(dismissOnClickOutside = dismissOnOutsideTap),
title = { Text(title) },
text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { body() } },
confirmButton = {
primary?.let { a ->
TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) }
}
},
dismissButton = {
if (rest.isNotEmpty()) {
Row {
rest.forEach { a ->
TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) }
}
}
}
},
)
}
/** A prompt's body paragraph, dimmed to sit under the title. */
@Composable
private fun PromptText(text: String) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
@Composable
fun TrustNewHostPrompt(
pt: PendingTrust,
onTrust: () -> Unit,
onPairInstead: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
title = "Trust this host?",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
DialogAction("Pair with PIN…", onClick = onPairInstead),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}") }
PromptText(
"This host allows trust-on-first-use, but that can't tell an impostor from the real " +
"host. Pairing with a PIN is stronger — it proves both sides.",
)
}
}
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
@Composable
fun FingerprintChangedPrompt(
pt: PendingTrust,
onRepair: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
title = "Host identity changed",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Re-pair", primary = true, onClick = onRepair),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText(
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. " +
"This can mean a host reinstall — or an impostor. Re-pair with the host's PIN to " +
"continue.",
)
}
}
/**
* A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is
* the no-PIN path — connect and wait for the operator to click Approve in the host's console;
* "Use a PIN…" switches to the SPAKE2 ceremony.
*/
@Composable
fun RequestAccessPrompt(
pt: PendingTrust,
onRequestAccess: () -> Unit,
onUsePin: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
title = "Pairing required",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Request access", primary = true, onClick = onRequestAccess),
DialogAction("Use a PIN…", onClick = onUsePin),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("${pt.host}:${pt.port} requires pairing before it will stream.")
PromptText(
"Request access and approve this device in the host's console (or web UI) — no PIN " +
"needed. Or pair with the 4-digit PIN the host displays.",
)
}
}
/**
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
*
* Outside taps are ignored: a connect is parked on the host, and a stray tap beside the card is not
* a decision to abandon it.
*/
@Composable
fun AwaitingApprovalPrompt(hostLabel: String, onCancel: () -> Unit) {
PunktfunkDialog(
title = "Waiting for approval",
onDismiss = onCancel,
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
dismissOnOutsideTap = false,
) {
// MUST be the name the connect actually knocked with (`HostConnect`), or this sends the
// user looking for a row the console does not show.
val label = deviceName(LocalContext.current)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Approve this device on $hostLabel.",
color = MaterialTheme.colorScheme.onSurface,
)
}
PromptText(
"Open the host's console (or web UI) and approve “$label”. It connects " +
"automatically once you approve — no PIN needed.",
)
}
}
/**
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
* every connect are dead — offer the system prompt again and a settings deep link (a permanently-
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
*/
@Composable
fun LocalNetworkPrompt(
onAllow: () -> Unit,
onSettings: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
title = "Allow local network access",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Allow", primary = true, onClick = onAllow),
DialogAction("Open settings", onClick = onSettings),
DialogAction("Not now", onClick = onDismiss),
),
) {
PromptText(
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
)
PromptText(
"If no prompt appears after you allow it, enable “Nearby devices” for Punktfunk in " +
"system settings.",
)
}
}
/**
* The link measurement and what to do with the result. A TV box on a powerline adapter is exactly
* the machine whose link is worth measuring, so this belongs on the couch surface too — and so
* does [speedTestTargetNote], which the console used to omit, leaving a console user to guess
* which layer Apply would write to.
*/
@Composable
fun SpeedTestPrompt(
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val done = phase as? SpeedTestPhase.Done
PunktfunkDialog(
title = "Network speed test",
onDismiss = onDismiss,
// Measuring bursts traffic for two seconds; a tap outside must not abandon it midway.
dismissOnOutsideTap = phase !is SpeedTestPhase.Measuring,
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))
},
) {
PromptText(hostName)
when (phase) {
SpeedTestPhase.Connecting -> PromptText("Connecting…")
SpeedTestPhase.Measuring ->
PromptText(
"Measuring — the host is bursting test traffic for two seconds.",
)
is SpeedTestPhase.Failed -> Text(
phase.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
is SpeedTestPhase.Done -> {
Text(
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
PromptText(
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
)
PromptText(speedTestTargetNote(target))
}
}
}
}
/** 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,6 +1,9 @@
package io.unom.punktfunk
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -11,9 +14,6 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
@@ -21,17 +21,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
@@ -40,32 +37,21 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
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 androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import android.widget.Toast
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.console.SkiaConsole
import io.unom.punktfunk.console.SkiaConsoleShell
import io.unom.punktfunk.models.LibraryReturn
import io.unom.punktfunk.models.Tab
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@Composable
fun App(forceGamepadUi: Boolean = false) {
@@ -81,48 +67,13 @@ fun App(forceGamepadUi: Boolean = false) {
// whose library the console shell should come back to. Held HERE because the shell's own
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
// later manual Back out of the library is not undone by a stale value.
var reopenLibrary by remember { mutableStateOf<LibraryReturn?>(null) }
// Which host's game library the TOUCH shell is showing, and which of its pinned cards opened it
// (null = the host's own card). Held here beside `tab` rather than inside the tab content: it is
// a PUSH over the whole shell, and a `remember` down in ConnectScreen would not survive the
// stream that a launch off the shelf starts — which is exactly what `reopenLibrary` restores.
var touchLibrary by remember { mutableStateOf<Pair<KnownHost, String?>?>(null) }
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
// connect/disconnect — unless the mode is Always, where it simply stays.
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
val tv = remember { isTvDevice(context) }
val controllerConnected by rememberControllerConnected()
// …AND the native console host is in this build (every shipping ABI today; the sysprop
// `debug.punktfunk.console_backend=none` forces the touch UI for on-glass triage). Without a
// console to draw, a controller drives the touch UI through Compose's own focus.
val skiaConsole = remember { SkiaConsole.wanted() }
// …AND it actually came up: a console whose native create failed or whose render thread died
// ([SkiaConsole.healthy], observable) would front a SurfaceView nothing ever paints — a gray
// screen with a working pad probe, which is worse than the touch UI it replaced.
val gamepadUi = skiaConsole && SkiaConsole.healthy && gamepadUiActive(
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
)
// System bars have ONE owner: this effect. The stream and the console shell both want the
// whole panel (bars hidden, a swipe shows them transiently); the touch shell wants them back.
// It cannot live inside the screens themselves: `AnimatedContent` below keeps the outgoing
// screen composed until its fade ends, so a per-screen `onDispose { show(...) }` fired AFTER
// the incoming screen's hide — console → stream left the status and gesture bars parked over
// the video. Keyed on the resolved intent, not the screens.
val immersive = session != null || gamepadUi
DisposableEffect(immersive) {
val window = activity?.window ?: return@DisposableEffect onDispose {}
val controller = WindowCompat.getInsetsController(window, window.decorView)
if (immersive) {
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
} else {
controller.show(WindowInsetsCompat.Type.systemBars())
}
onDispose {}
}
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
@@ -154,21 +105,6 @@ fun App(forceGamepadUi: Boolean = false) {
}
}
// The touch shell's half of "come back to the library this game was launched from" — the console
// shell consumes the same intent on its way in (see GamepadShell). Gated on which shell is up so
// exactly one of the two ever claims it, and cleared here so a later manual Back stays backed
// out. A host forgotten while the game ran simply leaves us on the host grid.
LaunchedEffect(reopenLibrary, gamepadUi) {
if (gamepadUi) return@LaunchedEffect
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
KnownHostStore(context).all().firstOrNull { it.id == id }?.let { kh ->
// A pin unpinned while the game was running is no longer a shelf: fall back to the
// host's own, rather than a card that no longer exists.
touchLibrary = kh to pinId?.takeIf { it in kh.pinnedProfileIds }
}
reopenLibrary = null
}
// The console backdrop's colour family, published once from the live settings rather than
// threaded through every screen that draws a backdrop. Because it is read from the SAME
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
@@ -193,53 +129,23 @@ fun App(forceGamepadUi: Boolean = false) {
// than all the way out to host selection. The console shell's own screen state does
// not survive the stream (StreamScreen replaces it in the composition, discarding
// its `remember`s), so the intent is hoisted here and handed back on the way in.
reopenLibrary =
reopenLibraryHostId =
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
active.hostId?.let { LibraryReturn(it, active.libraryProfileId) }
active.hostId
} else {
null
}
// The console keeps its stack across the stream and wants to know how the session
// ended — a clean end is no toast, an abnormal one says why (the desktop shell's
// exact contract).
if (skiaConsole) {
SkiaConsole.sessionEnded(
when (reason) {
SessionEndReason.NONE, SessionEndReason.LOCAL,
SessionEndReason.GAME_EXITED, SessionEndReason.HOST_ENDED -> null
SessionEndReason.HOST_ERROR -> "the host reported an error"
SessionEndReason.LOST -> "the connection was lost"
},
)
}
session = null
}
} else if (gamepadUi) {
// The console: the same Skia shell the Linux/Windows session binary shows, drawn by
// native onto a SurfaceView (design/android-skia-console-port.md) — the Compose
// console it replaced is gone. `gamepadUi` already folds in whether the native host
// is present on this build (see `SkiaConsole.wanted` in the `gamepadUi` resolution).
SkiaConsoleShell(
GamepadShell(
settings = settings,
onSettingsChange = { settings = it; settingsStore.save(it) },
onConnected = { session = it },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
reopenLibrary = reopenLibrary,
onReopenLibraryHandled = { reopenLibrary = null },
)
} else if (touchLibrary != null) {
// The touch shell's library is a PUSHED screen, not a tab: it belongs to one host, and a
// third permanent tab for something you reach from a card would be a nav item that is
// meaningless until you pick one. So it takes the whole window (bar included) and Back — the arrow or the system gesture — returns to the grid.
// Read once: `touchLibrary` is a `var`, so it does not smart-cast through the branch.
val (host, pinId) = touchLibrary!!
LibraryScreen(
host = host,
settings = settings,
onLaunched = { session = it },
onBack = { touchLibrary = null },
pinnedProfileId = pinId,
reopenLibraryHostId = reopenLibraryHostId,
onReopenLibraryHandled = { reopenLibraryHostId = null },
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -276,9 +182,6 @@ fun App(forceGamepadUi: Boolean = false) {
onSettingsChange = { settings = it; settingsStore.save(it) },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
// "Browse library…" in a card's overflow — the touch route to the shelf
// the console shell reaches with Y.
onOpenLibrary = { kh, pinId -> touchLibrary = kh to pinId },
)
Tab.Settings -> SettingsScreen(
initial = settings,
@@ -305,9 +208,8 @@ fun App(forceGamepadUi: Boolean = false) {
Spacer(Modifier.weight(1f))
}
// The rail handles its own insets; the content pane insets itself (the screens
// don't, since they used to rely on the Scaffold's padding). Cutout included:
// a tablet in landscape puts its punch on exactly this pane's leading edge.
Box(Modifier.weight(1f).fillMaxHeight().consoleSafeArea()) { tabContent(true) }
// don't, since they used to rely on the Scaffold's padding).
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
}
} else {
Scaffold(
@@ -340,120 +242,95 @@ fun App(forceGamepadUi: Boolean = false) {
*/
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
/**
* Re-inks a screen written against the TOUCH theme so it can be shown on the console's field.
*
* `ControllersScreen` alone pulls `MaterialTheme.colorScheme` at 27 explicit sites, plus implicitly
* through every `OutlinedCard`, `Switch`, `OutlinedButton` and `LinearProgressIndicator` it draws.
* Dropped into the shell those keep the touch palette — light-grey body text with no background of
* its own, which over the six PALE console palettes (`GamepadPalette`, `light = true`) is grey on
* pastel: technically painted, in practice unreadable. That is the same class of bug as the console
* dialogs that spent a release rendering dark ink on a dark card.
*
* The fix is deliberately ONE derived colour scheme rather than 27 call-site branches:
* * a call-site branch cannot reach the IMPLICIT pulls at all — a `Switch`'s track and an
* `OutlinedCard`'s border are resolved inside Material, not here;
* * two colours per site is exactly the shape that drifts, and it would leave the touch screen
* carrying console vocabulary it has no use for.
*
* The alternative — give the console presentation an opaque backdrop and let the touch theme read on
* its own ground — was rejected because it splits the screen's material in two: an opaque touch-grey
* slab under a palette-inked header and legend, with a visible seam between them, on a field whose
* whole point is that one look runs through it.
*
* The base scheme follows the field's lightness, so anything not overridden here (a container role
* some Material component reaches for) still lands on the right side of the contrast line.
*/
@Composable
internal fun ConsoleInkedTheme(content: @Composable () -> Unit) {
val ink = LocalGamepadInk.current
val scheme = remember(ink) {
val base = if (ink.isLight) lightColorScheme() else darkColorScheme()
base.copy(
primary = ink.accent,
onPrimary = ink.onAccent,
// A card becomes a PANE over the aurora rather than a slab on top of it: the console's
// own glass fill, so an OutlinedCard here is cut from the material the settings rows are.
surface = ink.glass,
onSurface = ink.fg,
surfaceVariant = ink.fg(0.12f),
onSurfaceVariant = ink.fg(0.68f),
outline = ink.fg(0.30f),
outlineVariant = ink.fg(0.16f),
// Nothing here paints a background — the aurora is the ground — but a component that
// resolves `background` (or the content colour for it) must still land on the palette.
background = Color.Transparent,
onBackground = ink.fg,
)
}
// The typography and shapes are the app's, not Material's defaults: this swaps the INK, not the
// brand typeface. And `LocalContentColor` has to be provided by hand — outside a Surface or a
// Scaffold it defaults to BLACK, which is how an unstyled `Text` would vanish into a dark field.
MaterialTheme(
colorScheme = scheme,
typography = MaterialTheme.typography,
shapes = MaterialTheme.shapes,
) {
CompositionLocalProvider(LocalContentColor provides ink.fg, content = content)
}
}
/** Which console screen the gamepad shell is showing. */
private enum class GamepadScreen { Home, Settings, Library }
/**
* The console's scroll route for a screen that is a WALL of content rather than a list of focusable
* rows.
*
* Compose only scrolls a container to keep a FOCUSED child visible, so a screen whose body holds no
* focusable nodes (the licenses notices are one enormous `Text`) simply cannot be scrolled by a
* controller: the D-pad has nothing to move to. These screens therefore drive the scroll state
* directly — up/down steps, the shoulders page.
*
* Returned as a plain function so a screen's nav callbacks read `scroll(-1, page = false)` rather
* than each screen minting its own coroutine + viewport arithmetic (which is how the two would end
* up scrolling at different speeds).
* The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch:
* a full-screen host carousel with X → Settings and Y → a saved host's library, all sharing
* [ConnectScreen]'s connect logic. No bottom bar; navigation is button-driven.
*/
@Composable
internal fun rememberConsoleScroller(scroll: ScrollState): (dir: Int, page: Boolean) -> Unit {
val scope = rememberCoroutineScope()
val animated = animationsEnabled()
return remember(scroll, animated) {
{ dir, page ->
val delta = consoleScrollDelta(scroll.viewportSize.toFloat(), page, dir)
if (delta != 0f) {
scope.launch {
// Auto-repeat fires every 150 ms while a direction is held, so each animation is
// short enough to have landed (or nearly) before the next one cancels it —
// otherwise a held D-pad crawls, each step restarting from where the last was
// interrupted.
if (animated) {
scroll.animateScrollBy(
delta,
ConsoleMotion.ease(
if (page) ConsoleMotion.TRANSITION_MS else ConsoleMotion.FOCUS_MS,
),
)
} else {
scroll.scrollBy(delta)
}
}
}
fun GamepadShell(
settings: Settings,
onSettingsChange: (Settings) -> Unit,
onConnected: (ActiveSession) -> Unit,
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
/**
* Open this saved host's library instead of Home on the way in — set when a game launched from
* it has just exited. Null (the default) starts on Home exactly as before.
*/
reopenLibraryHostId: String? = null,
onReopenLibraryHandled: () -> Unit = {},
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
// A host that has since been forgotten simply leaves us on Home rather than failing.
LaunchedEffect(reopenLibraryHostId) {
val id = reopenLibraryHostId ?: return@LaunchedEffect
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
// either order happens to work — but this one cannot be broken by a later edit that adds a
// suspending call. A host that has since been forgotten just leaves us on Home.
KnownHostStore(context).all()
.firstOrNull { it.id == id }
?.let { libraryHost = it; screen = GamepadScreen.Library }
onReopenLibraryHandled()
}
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
// tablet, keep their real density unchanged. This is the "based on pixel density" scale the layout
// wanted — one uniform factor across text, cards, spacing, and insets.
val isTv = remember { isTvDevice(context) }
val baseDensity = LocalDensity.current
val screenWidthPx = LocalConfiguration.current.screenWidthDp * baseDensity.density
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
// Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated
// on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives
// the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while
// the content behind it fades.
Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s ->
when (s) {
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 },
navGate = s == screen,
)
GamepadScreen.Settings -> GamepadSettingsScreen(
initial = settings,
onChange = onSettingsChange,
onBack = { screen = GamepadScreen.Home },
navActive = s == screen,
)
GamepadScreen.Library -> libraryHost?.let { host ->
LibraryScreen(
host = host,
settings = settings,
onLaunched = onConnected,
onBack = { screen = GamepadScreen.Home; libraryHost = null },
navActive = s == screen,
)
} ?: run { screen = GamepadScreen.Home }
}
}
}
}
/**
* How far one console scroll press travels: [dir] is -1 (up/left) or +1 (down/right), [page] picks
* the shoulders' full page over a D-pad step. Zero while the viewport is unmeasured — a first press
* that arrived before layout must do nothing rather than fling the content by zero-times-nothing.
*/
internal fun consoleScrollDelta(viewportPx: Float, page: Boolean, dir: Int): Float =
if (viewportPx <= 0f) 0f else viewportPx * (if (page) CONSOLE_PAGE else CONSOLE_STEP) * dir
/**
* A page keeps a band of what you were reading on screen rather than jumping a clean screenful — the
* overlap every reader has used since the printed page, and the difference between "I moved down"
* and "where was I".
*/
private const val CONSOLE_PAGE = 0.88f
/** A D-pad step is about a quarter screen, so holding the direction walks the wall rather than flicking it. */
private const val CONSOLE_STEP = 0.28f
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
@@ -1,5 +1,6 @@
package io.unom.punktfunk
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -30,7 +31,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.NativeBridge
@@ -123,6 +123,130 @@ internal fun AddHostSheet(
}
}
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
@Composable
internal fun TrustNewHostDialog(
pt: PendingTrust,
onTrust: () -> Unit,
onPairInstead: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Trust this host?") },
text = {
Column {
Text("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}") }
Text(
"This host allows trust-on-first-use, but that can't tell an impostor " +
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
)
}
},
confirmButton = {
TextButton(onClick = onTrust) { Text("Trust (TOFU)") }
},
dismissButton = {
Row {
TextButton(onClick = onPairInstead) { Text("Pair with PIN…") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}
/**
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
* every connect are dead offer the system prompt again and a settings deep link (a permanently-
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
*/
@Composable
internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Allow local network access") },
text = {
Text(
"Android blocks Punktfunk from talking to devices on your network, so it can't " +
"find or reach any host until you allow it. If no prompt appears when you tap " +
"Allow, enable “Nearby devices” for Punktfunk in system settings.",
)
},
confirmButton = {
TextButton(onClick = onAllow) { Text("Allow") }
},
dismissButton = {
Row {
TextButton(onClick = onSettings) { Text("Open settings") }
TextButton(onClick = onDismiss) { Text("Not now") }
}
},
)
}
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
@Composable
internal fun FingerprintChangedDialog(
pt: PendingTrust,
onRepair: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Host identity changed") },
text = {
Text(
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
"with the host's PIN to continue.",
)
},
confirmButton = {
TextButton(onClick = onRepair) { Text("Re-pair") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
/**
* A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is
* the no-PIN path connect and wait for the operator to click Approve in the host's console;
* "Use a PIN…" switches to the SPAKE2 ceremony.
*/
@Composable
internal fun RequestAccessDialog(
pt: PendingTrust,
onRequestAccess: () -> Unit,
onUsePin: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Pairing required") },
text = {
Column {
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
Text(
"Request access and approve this device in the host's console (or web " +
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
)
}
},
confirmButton = {
TextButton(onClick = onRequestAccess) { Text("Request access") }
},
dismissButton = {
Row {
TextButton(onClick = onUsePin) { Text("Use a PIN…") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}
/**
* The SPAKE2 PIN ceremony dialog. Runs [NativeBridge.nativePair] off the UI thread itself (the
* pin/name/error state is dialog-local); on success hands the host's verified fingerprint to
@@ -137,8 +261,7 @@ internal fun PairPinDialog(
) {
val scope = rememberCoroutineScope()
var pin by remember(pt) { mutableStateOf("") }
val context = LocalContext.current
var name by remember(pt) { mutableStateOf(deviceName(context)) }
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
AlertDialog(
@@ -195,6 +318,41 @@ internal fun PairPinDialog(
)
}
/**
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
* this device. Cancel returns the UI immediately the caller trips the per-attempt flag so a late
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
*/
@Composable
internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Waiting for approval") },
text = {
val deviceName = Build.MODEL ?: "this device"
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text("Approve this device on $hostLabel.")
}
Text(
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
"automatically once you approve — no PIN needed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onCancel) { Text("Cancel") }
},
)
}
/**
* 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).
@@ -309,3 +467,103 @@ 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."
}
@@ -53,11 +53,6 @@ object ConnectErrors {
"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."
"access-expired" ->
"Your access to this host has expired — ask the host's owner to grant it again."
"launch-not-permitted" ->
"This device's access doesn't include launching games — connect to the desktop, " +
"or ask the host's owner."
else -> null
}
@@ -1,354 +0,0 @@
package io.unom.punktfunk
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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.discovery.DiscoveredHost
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.HostStatus
/**
* The touch home: the saved/discovered host grid with the Add-host FAB over it everything
* `ConnectScreen` draws when the console UI is off, and the counterpart of [buildHomeTiles] +
* `GamepadHome` when it is on.
*
* Pure display: every action arrives as a callback, because they all end in state the screen owns
* (a dial in flight, the trust prompt, the host store). What this file DOES own is the arrangement
* which sections exist, in what order, and which actions a given card offers and the two rules
* that are easy to get wrong from the outside: a pinned card is a shortcut and so withholds the
* host's destructive actions, and every card in a section reserves the profile chip's space as soon
* as one of them needs it.
*/
@Composable
internal fun ConnectGrid(
savedHosts: List<KnownHost>,
/** Every live advert — the OS mark prefers it over the stored one, and "searching…" reads it. */
discovered: List<DiscoveredHost>,
/** Adverts with no saved record behind them, de-duped by the caller (it needs them too). */
discoveredUnsaved: List<DiscoveredHost>,
/** Saved hosts answering the QUIC probe, "address:port" — the routed half of "online". */
reachable: Set<String>,
profiles: List<StreamProfile>,
pinsFor: (KnownHost) -> List<StreamProfile>,
connecting: Boolean,
/** A confirmation ("75 Mbit/s set in …"); [status] is the failure line. Never the same thing. */
notice: String?,
status: String?,
lnpGranted: Boolean,
/** Raise the local-network-permission prompt — the banner's "Allow…" and the wake guard. */
onAskLocalNetwork: () -> Unit,
/**
* Dial a saved host. The second argument is `connect`'s one-off profile reference: null follows
* the host's binding (a plain tap), a profile id forces that profile, and the empty string
* forces the global defaults a real, different action on a bound host, which is why it has to
* survive as a value rather than collapsing into "unset".
*/
onConnect: (KnownHost, String?) -> Unit,
onConnectDiscovered: (DiscoveredHost) -> Unit,
onForget: (KnownHost) -> Unit,
onEdit: (KnownHost) -> Unit,
onWake: (KnownHost) -> Unit,
onSpeedTest: (KnownHost) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
onTogglePin: (KnownHost, StreamProfile) -> Unit,
/** The experimental game-library toggle — off hides "Browse library…" everywhere. */
libraryEnabled: Boolean,
/**
* Open this card's game library. The second argument is the shelf's pinned profile id, exactly
* as [onConnect] takes the card's one-off: browsing IS this card's connect with a title picked
* first, so a pinned card's shelf launches with that card's profile.
*/
onBrowseLibrary: (KnownHost, StreamProfile?) -> Unit,
onRescan: () -> Unit,
onAddHost: () -> Unit,
) {
// 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 {
// Browsing IS a connect-shaped action — this card's connect with a title picked first — so
// a PINNED card offers it too, and its shelf launches with that card's profile. Without it
// the touch home had no route to the library at all: the console shell reaches it with Y
// from a tile, and a finger has no Y.
if (libraryEnabled) {
add(HostMenuItem("Browse library…") { onBrowseLibrary(kh, pin) })
}
if (pin == null) {
add(HostMenuItem("Network speed test") { onSpeedTest(kh) })
}
add(HostMenuItem("Copy link") { onCopyLink(kh, pin) })
if (profiles.isEmpty()) return@buildList
if (pin != null) {
add(HostMenuItem("Unpin card", startsSection = true) { onTogglePin(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.
onConnect(kh, "")
},
)
profiles.forEach { p ->
add(HostMenuItem("Connect with: ${p.name}") { onConnect(kh, 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,
) { onTogglePin(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)) + 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 }
Box(Modifier.fillMaxSize()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(8.dp))
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
Text(
"stream a remote desktop",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
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
// container reads as a real failure banner, not just red text lost in the layout.
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(16.dp))
}
}
}
if (!lnpGranted) {
// Local network access denied: discovery can't ever find anything and every connect
// would time out — say so at the top, with the fix one tap away, instead of letting
// the screen look idle/broken.
item(span = { GridItemSpan(maxLineSpan) }) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Local network access is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
)
TextButton(onClick = onAskLocalNetwork) { Text("Allow…") }
}
}
Spacer(Modifier.height(12.dp))
}
}
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState()
}
}
if (savedHosts.isNotEmpty()) {
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 } }
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 = { onConnect(kh, pin?.id) },
// 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 ({ onForget(kh) }),
onEdit = if (pin != null) null else ({ onEdit(kh) }),
// Explicit wake-only: offered when the host is offline and we have a MAC. The
// screen runs it 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(kh) })
} else {
null
},
profileLabel = pin?.name ?: bound?.name,
profileProminent = pin != null,
accent = accentColor(pin?.accent ?: bound?.accent),
menuItems = hostMenu(kh, pin),
reserveProfileSlot = anyProfileChip,
)
}
}
if (discoveredUnsaved.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network")
}
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
HostCard(
name = dh.name,
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 = { onConnectDiscovered(dh) },
onForget = null,
)
}
}
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
// rather than looking idle/empty. Suppressed while local network access is denied —
// a spinner would be a lie there (the browse can't receive anything); the banner above
// owns that state.
// Scan again is offered whether or not anything turned up: the case that sends people
// here is ONE expected host missing, not an empty list, and a browse that quietly went
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
// exactly like a network without that host on it.
if (lnpGranted && !connecting) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (discovered.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
TextButton(onClick = onRescan) { Text("Scan again") }
}
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(96.dp))
}
}
ExtendedFloatingActionButton(
onClick = onAddHost,
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Add host") },
expanded = !connecting,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp),
)
}
}
@@ -1,6 +1,5 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@@ -30,8 +29,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -108,6 +107,7 @@ private fun connectCopy(phase: ConnectPhase): ConnectCopy = when (phase) {
fun ConnectOverlay(
connectingHostName: String?,
waker: WakeController,
gamepadUi: Boolean,
onCancelConnect: () -> Unit,
) {
val waking = waker.waking
@@ -123,8 +123,19 @@ fun ConnectOverlay(
// System Back / pad B (remapped) cancels whatever's in flight — a plain dial or the wake wait.
val cancel = { if (waking != null) waker.cancel() else onCancelConnect() }
// The AlertDialog owns its own scrim + system-Back handling (routed to cancel).
ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
if (gamepadUi) {
BackHandler { cancel() }
// A retries once a wake has timed out; B falls through to the BackHandler above.
GamepadNavEffect2D(
active = true,
onDirection = {},
onActivate = { if (phase is ConnectPhase.WakeTimedOut) waker.retry() },
)
ConnectTakeover(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
} else {
// The AlertDialog owns its own scrim + system-Back handling (routed to cancel).
ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
}
}
/**
@@ -167,3 +178,102 @@ internal fun ConnectModal(
},
)
}
/**
* The console / gamepad presentation: an opaque aurora backdrop with a centred spinner/title/subtitle
* for [phase], plus a bottom hint bar spelling out the pad actions (B cancels, A retries once timed
* out) glyph-driven like every other console screen. onClick keeps the hints tappable too, so a
* user without a working pad can still get out.
*/
@Composable
internal fun ConnectTakeover(
phase: ConnectPhase,
onCancel: () -> Unit,
onRetry: () -> Unit,
) {
val ink = LocalGamepadInk.current
val copy = connectCopy(phase)
val timedOut = phase is ConnectPhase.WakeTimedOut
Box(
Modifier
.fillMaxSize()
// Swallow taps so the screen behind can't be touched through the takeover.
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
contentAlignment = Alignment.Center,
) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(
Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
if (timedOut) {
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
Icon(
Icons.Filled.Bedtime,
contentDescription = null,
tint = ink.fg(0.9f),
modifier = Modifier.size(46.dp),
)
}
} else {
PulsingSpinner()
}
Text(
copy.title,
color = ink.fg,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
textAlign = TextAlign.Center,
)
Text(
copy.subtitle,
color = ink.fg(0.65f),
fontSize = 14.sp,
textAlign = TextAlign.Center,
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
)
}
val hints = buildList {
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
}
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
}
}
/**
* The connecting/waking indicator: a white progress ring inside two brand-violet halo rings that
* expand and fade on a staggered loop a small sign of life so the takeover reads as working, not
* stalled.
*/
@Composable
private fun PulsingSpinner() {
val ink = LocalGamepadInk.current
val transition = rememberInfiniteTransition(label = "connectPulse")
val pulse by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Restart),
label = "pulse",
)
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) {
val maxR = size.minDimension / 2f
for (i in 0..1) {
val p = (pulse + i * 0.5f) % 1f
drawCircle(
color = ink.accent.copy(alpha = (1f - p) * 0.35f),
radius = maxR * (0.42f + p * 0.58f),
style = Stroke(width = 2.dp.toPx()),
)
}
}
CircularProgressIndicator(
color = ink.fg,
strokeWidth = 3.dp,
modifier = Modifier.size(54.dp),
)
}
}
@@ -1,130 +0,0 @@
package io.unom.punktfunk
import androidx.compose.runtime.Composable
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingTrust
/**
* Everything `ConnectScreen` puts ON TOP of whichever home it drew the trust and pairing
* ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the
* edit form, the local-network rationale, and finally the connect takeover.
*
* They live together because their ORDER is the contract: this is a stack of siblings in one tree,
* so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose a dial can
* start from any of the prompts above it, and its takeover has to cover the prompt that started it.
*
* Only the state each prompt reads comes in; every action goes back out as a callback, because they
* all end in the connect/pair engine or in the host store, which the screen owns. Nothing in here
* decides anything it decides only what is visible.
*/
@Composable
internal fun ConnectPrompts(
/** The client identity — the PIN ceremony needs it to run SPAKE2; null while it is still minting. */
identity: ClientIdentity?,
profiles: List<StreamProfile>,
isOnline: (KnownHost) -> Boolean,
// ---- trust / pairing --------------------------------------------------------------------
pendingTrust: PendingTrust?,
/** Dismiss (null) or re-aim the SAME decision at another kind — "Pair with PIN…" does that. */
onPendingTrustChange: (PendingTrust?) -> Unit,
/** Trust-on-first-use accepted: dial with no pin. Offered only for a `pair=optional` host. */
onTrustNew: (PendingTrust) -> Unit,
/** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */
onPaired: (PendingTrust, String) -> Unit,
onRequestAccess: (PendingTrust) -> Unit,
// ---- the parked no-PIN request ----------------------------------------------------------
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
awaitingHostName: String?,
onCancelApproval: () -> Unit,
// ---- speed test --------------------------------------------------------------------------
speedTest: HostCardEntry?,
/** Which layer Apply writes to. Resolved by the caller (it holds the store); set with [speedTest]. */
speedTestTarget: SpeedTestTarget?,
speedTestPhase: SpeedTestPhase,
/** true = write the measured bitrate to the profile, false = to the global default. */
onApplySpeedTest: (Boolean) -> Unit,
onDismissSpeedTest: () -> Unit,
// ---- edit host ---------------------------------------------------------------------------
editTarget: KnownHost?,
/** A MAC from the live advert, for a host whose own is not learned yet. */
editSuggestedMacs: List<String>,
onSaveHost: (KnownHost) -> Unit,
onDismissEdit: () -> Unit,
// ---- local network permission ------------------------------------------------------------
lnpPrompt: Boolean,
onAllowLocalNetwork: () -> Unit,
onOpenSystemSettings: () -> Unit,
onDismissLnpPrompt: () -> Unit,
// ---- the connect takeover ----------------------------------------------------------------
connectingHostName: String?,
waker: WakeController,
onCancelConnect: () -> Unit,
) {
pendingTrust?.let { pt ->
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
val onPair = { onPendingTrustChange(pt.copy(kind = PendingTrust.Kind.PAIR)) }
// Three of the four say the same thing in both interfaces, so they are ONE prompt that
// knows which one is running. Only the PIN ceremony genuinely differs — a keyboard field
// against four D-pad digit slots is a different input model, not a different skin.
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW -> TrustNewHostPrompt(
pt,
onTrust = { onTrustNew(pt) },
onPairInstead = onPair,
onDismiss = { onPendingTrustChange(null) },
)
PendingTrust.Kind.FP_CHANGED ->
FingerprintChangedPrompt(pt, onPair) { onPendingTrustChange(null) }
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessPrompt(
pt,
onRequestAccess = { onRequestAccess(pt) },
onUsePin = onPair,
onDismiss = { onPendingTrustChange(null) },
)
PendingTrust.Kind.PAIR -> {
val onSavePaired = { fp: String -> onPaired(pt, fp) }
PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
}
}
}
awaitingHostName?.let { hostLabel ->
AwaitingApprovalPrompt(hostLabel = hostLabel, onCancel = onCancelApproval)
}
if (speedTest != null && speedTestTarget != null) {
SpeedTestPrompt(
speedTest.host.name, speedTestTarget, speedTestPhase,
onApplySpeedTest, onDismissSpeedTest,
)
}
editTarget?.let { kh ->
EditHostDialog(
target = kh,
suggestedMacs = editSuggestedMacs,
profiles = profiles,
onSave = onSaveHost,
onDismiss = onDismissEdit,
)
}
if (lnpPrompt) {
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
// returns instantly without a system prompt — hence the settings deep link alongside).
LocalNetworkPrompt(
onAllow = onAllowLocalNetwork,
onSettings = onOpenSystemSettings,
onDismiss = onDismissLnpPrompt,
)
}
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
// seamlessly into the "Waking…" wait if the host turns out to be asleep.
ConnectOverlay(
connectingHostName = connectingHostName,
waker = waker,
onCancelConnect = onCancelConnect,
)
}
@@ -1,6 +1,8 @@
package io.unom.punktfunk
import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@@ -9,6 +11,31 @@ import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -18,11 +45,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
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
@@ -38,6 +73,7 @@ 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
import kotlinx.coroutines.Dispatchers
@@ -72,20 +108,6 @@ private class ConnectAttempt(val hostName: String) {
val cancelled = AtomicBoolean(false)
}
/**
* The connect screen discovery, trust and the dial itself, under either interface.
*
* What is left in this file is the STATE and the engine: the mDNS browse and the permission that
* gates it, the identity, the host and profile stores, the trust decision, the dial and its wake
* fallback, and the `punktfunk://` router. What was drawn from that state now lives beside it —
* `buildHomeTiles` (the console carousel's contents), `ConnectGrid` (the touch home) and
* `ConnectPrompts` (everything modal, plus the connect takeover). They hold no state of their own,
* which is why they could leave: each one takes what it displays and hands back what was pressed.
*
* The engine did NOT leave, and shouldn't until it has somewhere to live: it closes over ~20 locals
* that a dozen callbacks read and write, and hoisting it means inventing a state holder a second
* refactor, and a second thing to get wrong.
*/
@Composable
fun ConnectScreen(
settings: Settings,
@@ -93,10 +115,13 @@ fun ConnectScreen(
// 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 = {},
// (host, pinned profile id) — a pinned host+profile card opens ITS shelf, and the id is the
// one-off every launch off that shelf runs with (design §5.2a). Null = the host's own tile.
// Raised by "Browse library…" in a card's overflow.
onOpenLibrary: (KnownHost, String?) -> 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).
gamepadUi: Boolean = false,
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.
@@ -236,15 +261,6 @@ fun ConnectScreen(
knownHostStore.learnOs(dh.host, dh.port, dh.os)
any = true
}
// And the mgmt port, so a host that moved off 47990 keeps its library once this
// device can no longer see the advert (VPN, routed subnet, multicast-dead Wi-Fi).
val mgmt = dh.mgmtPort
if (mgmt != null &&
knownHostStore.get(dh.host, dh.port)?.let { it.mgmtPort != mgmt } == true
) {
knownHostStore.learnMgmtPort(dh.host, dh.port, mgmt)
any = true
}
}
any
}
@@ -288,6 +304,9 @@ fun ConnectScreen(
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
// A saved host being edited (name / address / port / MAC).
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) }
// 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
@@ -312,24 +331,13 @@ fun ConnectScreen(
// 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 {
// The session's own Welcome carries where this host serves its library. Save it now: this
// is the only source that does not need an mDNS advert, so it is what makes a host that
// moved off 47990 browsable over a VPN or when it was added by address. 0 = not
// advertised, and learnMgmtPort ignores it.
if (record != null) {
NativeBridge.nativeHostMgmtPort(handle).takeIf { it > 0 }?.let {
knownHostStore.learnMgmtPort(record.address, record.port, it)
}
}
return ActiveSession(
handle,
settings.effectiveFor(profile),
clipboardSync = record?.clipboardSync ?: true,
profileName = profile?.name,
hostId = record?.id,
)
}
fun session(handle: Long, record: KnownHost?, profile: StreamProfile?) = ActiveSession(
handle,
settings.effectiveFor(profile),
clipboardSync = record?.clipboardSync ?: true,
profileName = profile?.name,
hostId = record?.id,
)
// 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
@@ -625,13 +633,70 @@ fun ConnectScreen(
// host's binding, exactly like a tap on it does.
fun copyLink(kh: KnownHost, pin: StreamProfile?) {
val url = DeepLinks.forHost(kh, profile = pin?.id).toUrl()
val copied = putLinkOnClipboard(context, url)
val message = linkCopyMessage(copied) ?: return
// A success dressed as an error banner is a small lie: the notice line for a copy, the
// status line for a failure.
if (copied) notice = message else status = message
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val copied = clipboard != null && runCatching {
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
}.isSuccess
// Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is
// the platform's own documented anti-pattern. Below it nothing visible happens at all unless
// we say so — a silent menu item reads as a broken one.
if (copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
val message = if (copied) "Link copied." else "Couldn't copy the link to the clipboard."
// The console home renders neither the notice nor the status banner, so there it has to be a
// toast; the touch grid has both, and a success dressed as an error banner is a small lie.
when {
gamepadUi -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
copied -> notice = message
else -> status = message
}
}
// The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a
// user who never wants this feature sees no new clutter anywhere but the settings scope chips.
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
// lives in the Edit sheet instead.
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
if (pin == null) {
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
}
add(HostMenuItem("Copy link") { copyLink(kh, pin) })
if (profiles.isEmpty()) return@buildList
if (pin != null) {
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
}
add(
HostMenuItem("Connect with: Default settings", startsSection = true) {
// The empty reference is "force the defaults", not "unset" — on a bound host that
// is a real, different action from a plain tap.
connect(kh.address, kh.port, oneOffProfile = "")
},
)
profiles.forEach { p ->
add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) })
}
if (pin == null) {
profiles.forEachIndexed { i, p ->
val pinned = p.id in kh.pinnedProfileIds
add(
HostMenuItem(
if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}",
startsSection = i == 0,
) { togglePin(kh, p) },
)
}
}
}
// The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a
// pinned combination is a plain one-click connect instead of a trip through a menu.
val savedCards = savedHosts.flatMap { kh ->
listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) }
}
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
// profiles ever sees the gap.
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
//
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
@@ -717,147 +782,489 @@ fun ConnectScreen(
var showManualSheet by remember { mutableStateOf(false) }
// Wake a saved host on demand — the touch card's Wake item and the console options dialog run
// the same action. Through the WakeController, so it shows the "Waking…" overlay and waits for
// the host to come back rather than firing one silent packet at it.
fun wakeHost(kh: KnownHost) {
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
lnpPrompt = true
return
if (gamepadUi) {
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
// 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}",
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}",
filled = true,
online = kh.isOnline(discovered, reachable),
paired = kh.paired,
knownHost = kh,
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(
HomeTile(
id = "disc-${dh.host}:${dh.port}",
title = dh.name,
subtitle = "${dh.host}:${dh.port}",
online = true,
activate = { connect(dh.host, dh.port, dh) },
),
)
}
add(
HomeTile(
id = "add",
title = "Add Host",
subtitle = "Register a host by address",
isAdd = true,
activate = { showManualSheet = true },
),
)
}
waker.start(
hostName = kh.name,
connectsAfter = false,
macs = kh.mac,
lastIp = kh.address,
// "Back up" is mDNS presence ONLY — narrower than the [isOnline] that decides whether to
// OFFER Wake, which also counts a QUIC probe answer. Matched through `matches`, so a
// cold boot onto a new DHCP address still ends the wait.
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
GamepadHome(
tiles = tiles,
libraryEnabled = settings.libraryEnabled,
controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name,
// Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen,
// while a connect is in flight (else a second A launches a concurrent connect that leaks a
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
// console home is cross-fading out.
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
awaiting == null && editTarget == null && optionsTarget == null &&
waker.waking == null && !lnpPrompt,
onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenSettings = onOpenSettings,
onOptions = { tile ->
tile.knownHost?.let { kh ->
optionsTarget = HostCardEntry(kh, tile.pinnedProfileId?.let(profileStore::byId))
}
},
)
} else {
Box(Modifier.fillMaxSize()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(8.dp))
Text("Punktfunk", style = MaterialTheme.typography.headlineLarge)
Text(
"stream a remote desktop",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
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
// container reads as a real failure banner, not just red text lost in the layout.
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(16.dp))
}
}
}
if (!lnpGranted) {
// Local network access denied: discovery can't ever find anything and every connect
// would time out — say so at the top, with the fix one tap away, instead of letting
// the screen look idle/broken.
item(span = { GridItemSpan(maxLineSpan) }) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Local network access is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"Android blocks Punktfunk from finding or reaching hosts until you allow it.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
)
TextButton(onClick = { lnpPrompt = true }) { Text("Allow…") }
}
}
Spacer(Modifier.height(12.dp))
}
}
if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyHostsState()
}
}
if (savedHosts.isNotEmpty()) {
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 } }
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)
}
},
// 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 }),
// 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)) {
{
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
lnpPrompt = true
} else {
waker.start(
hostName = kh.name,
connectsAfter = false,
macs = kh.mac,
lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
}
}
} else {
null
},
profileLabel = pin?.name ?: bound?.name,
profileProminent = pin != null,
accent = accentColor(pin?.accent ?: bound?.accent),
menuItems = hostMenu(kh, pin),
reserveProfileSlot = anyProfileChip,
)
}
}
if (discoveredUnsaved.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp))
SectionLabel("Discovered on the network")
}
items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh ->
HostCard(
name = dh.name,
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,
)
}
}
// Active-discovery hint: discovery runs whenever this screen is up, so while it's
// scanning but nothing's turned up yet (and we're not mid-connect), show it's working
// rather than looking idle/empty. Suppressed while local network access is denied —
// a spinner would be a lie there (the browse can't receive anything); the banner above
// owns that state.
// Scan again is offered whether or not anything turned up: the case that sends people
// here is ONE expected host missing, not an empty list, and a browse that quietly went
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
// exactly like a network without that host on it.
if (lnpGranted && !connecting) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (discovered.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text(
"Searching the local network…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
TextButton(onClick = { discovery.restart() }) { Text("Scan again") }
}
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(96.dp))
}
}
ExtendedFloatingActionButton(
onClick = { showManualSheet = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Add host") },
expanded = !connecting,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp),
)
}
}
fun forgetHost(kh: KnownHost) {
knownHostStore.remove(kh)
// A forgotten host leaves no list of what somebody plays, and no record of what they were
// playing, behind on the device. Its record id is the key both are filed under, so this is
// the last moment either can be found.
io.unom.punktfunk.kit.library.LibraryCache.standard(context.cacheDir).forget(kh.id)
LibraryPosition.forget(context, kh.id)
savedHosts = knownHostStore.all()
}
ConnectGrid(
savedHosts = savedHosts,
discovered = discovered,
discoveredUnsaved = discoveredUnsaved,
reachable = reachable,
profiles = profiles,
pinsFor = profileStore::pinsFor,
connecting = connecting,
notice = notice,
status = status,
lnpGranted = lnpGranted,
onAskLocalNetwork = { lnpPrompt = true },
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
onForget = { kh -> forgetHost(kh) },
onEdit = { kh -> editTarget = kh },
onWake = { kh -> wakeHost(kh) },
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
onCopyLink = { kh, pin -> copyLink(kh, pin) },
onTogglePin = { kh, p -> togglePin(kh, p) },
libraryEnabled = settings.libraryEnabled,
onBrowseLibrary = { kh, pin -> onOpenLibrary(kh, pin?.id) },
onRescan = { discovery.restart() },
onAddHost = { showManualSheet = true },
)
// Add Host stayed behind while the other modals moved into ConnectPrompts: its form fields are
// remembered HERE, on purpose, so a half-typed address survives the sheet being dismissed and
// reopened. Moving the block without moving that state would quietly change what a dismiss
// costs; moving both is a separate decision from this one.
if (showManualSheet) {
AddHostSheet(
hostName = hostName,
onHostNameChange = { hostName = it },
host = host,
onHostChange = { host = it },
port = port,
onPortChange = { port = it },
connecting = connecting,
modeLabel = "$w×$h@$hz",
onDismiss = { showManualSheet = false },
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
)
if (gamepadUi) {
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
// saves the host on TOFU/pair), exactly like the touch sheet's Connect.
GamepadAddHostScreen(
onAdd = { n, addr, p ->
showManualSheet = false
connect(addr, p, manualName = n)
},
onDismiss = { showManualSheet = false },
)
} else {
AddHostSheet(
hostName = hostName,
onHostNameChange = { hostName = it },
host = host,
onHostChange = { host = it },
port = port,
onPortChange = { port = it },
connecting = connecting,
modeLabel = "$w×$h@$hz",
onDismiss = { showManualSheet = false },
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
)
}
}
// Which layer a measurement would land in. Resolved here, not in the prompt: it is a question
// for the profile store, and the Apply button and the caption above it must agree on the answer.
val speedTestTarget = speedTest?.let { SpeedTestTarget.resolve(it.host, it.pin?.id, profileStore) }
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
val editSuggestedMacs =
editTarget?.let { kh -> discovered.firstOrNull { kh.matches(it) }?.mac } ?: emptyList()
// Everything that floats above whichever home was drawn, in one place and in one order — see
// ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above.
ConnectPrompts(
identity = identity,
profiles = profiles,
isOnline = { it.isOnline(discovered, reachable) },
pendingTrust = pendingTrust,
onPendingTrustChange = { pendingTrust = it },
onTrustNew = { pt ->
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch)
},
onPaired = { pt, fp ->
pendingTrust?.let { pt ->
// 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)
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
},
onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) },
awaitingHostName = awaiting?.target?.name,
onCancelApproval = {
awaiting?.cancelled?.set(true)
}
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 })
PendingTrust.Kind.FP_CHANGED ->
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
PendingTrust.Kind.REQUEST_ACCESS ->
if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
PendingTrust.Kind.PAIR ->
if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
}
}
awaiting?.let { req ->
val onCancel = {
req.cancelled.set(true)
awaiting = null
connecting = false
discovery.start() // the request may still be pending on the host; keep scanning
},
speedTest = speedTest,
speedTestTarget = speedTestTarget,
speedTestPhase = speedTestPhase,
onApplySpeedTest = { toProfile ->
}
if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel)
else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel)
}
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
optionsTarget?.let { entry ->
val kh = entry.host
val pin = entry.pin
val offline = !kh.isOnline(discovered, reachable)
GamepadHostOptionsDialog(
hostName = kh.name,
canWake = kh.mac.isNotEmpty() && offline,
onWake = {
optionsTarget = null
// The magic packet is UDP broadcast — LNP-blocked like everything else.
if (!lnpGranted) {
lnpPrompt = true
} else {
waker.start(
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
}
},
// 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) {
{ optionsTarget = null; onOpenLibrary(kh) }
} else {
null
},
onSpeedTest = if (pin == null) {
{ optionsTarget = null; startSpeedTest(HostCardEntry(kh, null)) }
} else {
null
},
onCopyLink = { optionsTarget = null; copyLink(kh, pin) },
onEdit = { optionsTarget = null; editTarget = kh },
onForget = {
knownHostStore.remove(kh)
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 && speedTestTarget != null) {
if (done != null) {
val where = applySpeedTestResult(
done.recommendedKbps, speedTestTarget, toProfile, profileStore, settings,
onSettingsChange,
done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange,
)
profiles = profileStore.all()
notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where)
}
speedTest = null
},
onDismissSpeedTest = { speedTest = null },
editTarget = editTarget,
editSuggestedMacs = editSuggestedMacs,
onSaveHost = { updated ->
}
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)
savedHosts = knownHostStore.all()
editTarget = null
},
onDismissEdit = { editTarget = null },
lnpPrompt = lnpPrompt,
onAllowLocalNetwork = {
}
if (gamepadUi) {
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
// host with an extra MAC row; the action SAVES instead of connecting.
GamepadAddHostScreen(
onAdd = { _, _, _ -> },
onDismiss = { editTarget = null },
editHost = kh,
suggestedMacs = suggested,
onSave = onSaveHost,
)
} else {
EditHostDialog(
target = kh,
suggestedMacs = suggested,
profiles = profiles,
onSave = onSaveHost,
onDismiss = { editTarget = null },
)
}
}
if (lnpPrompt) {
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
// returns instantly without a system prompt — hence the settings deep link alongside).
val onAllow = {
lnpPrompt = false
localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
},
onOpenSystemSettings = {
}
val onSettings = {
lnpPrompt = false
context.startActivity(
Intent(
@@ -865,10 +1272,21 @@ fun ConnectScreen(
Uri.fromParts("package", context.packageName, null),
),
)
},
onDismissLnpPrompt = { lnpPrompt = false },
}
if (gamepadUi) {
GamepadLocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
} else {
LocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
}
}
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
// grid and the console home.
ConnectOverlay(
connectingHostName = attempt?.hostName,
waker = waker,
gamepadUi = gamepadUi,
onCancelConnect = { cancelConnect() },
)
}
@@ -878,7 +1296,7 @@ fun ConnectScreen(
* 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).
*/
internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
}
@@ -887,7 +1305,7 @@ internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?)
* as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via
* the native core + MulticastLock) does not depend on it.
*/
internal fun hasNearbyPermission(context: Context): Boolean =
fun hasNearbyPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) ==
PackageManager.PERMISSION_GRANTED
@@ -899,7 +1317,7 @@ internal fun hasNearbyPermission(context: Context): Boolean =
* QUIC dial surfaces as a silent handshake timeout and the mDNS browse receives nothing. Unlike
* [hasNearbyPermission] this is load-bearing nothing on the connect screen works without it.
*/
internal fun hasLocalNetworkPermission(context: Context): Boolean =
fun hasLocalNetworkPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) ==
PackageManager.PERMISSION_GRANTED
@@ -909,7 +1327,7 @@ internal fun hasLocalNetworkPermission(context: Context): Boolean =
* fingerprint when both carry it (so it survives a DHCP address change), else by address:port.
* Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts".
*/
internal fun KnownHost.matches(dh: DiscoveredHost): Boolean {
private fun KnownHost.matches(dh: DiscoveredHost): Boolean {
val advFp = dh.fingerprint?.lowercase()
if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true
return address == dh.host && port == dh.port
@@ -919,9 +1337,6 @@ internal fun KnownHost.matches(dh: DiscoveredHost): Boolean {
* True when a saved host is reachable RIGHT NOW: advertising on mDNS OR answering the QUIC probe
* (a host reached over a routed network Tailscale/VPN never advertises but is reachable). The
* display-side companion to dial-first: presence no longer means "on this LAN".
*
* `internal`, not private: the touch grid draws the same pip in its own file now, and the console's
* tile builder is handed this as a lambda so it never has to know what "reachable" is made of.
*/
internal fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
private fun KnownHost.isOnline(discovered: List<DiscoveredHost>, reachable: Set<String>): Boolean =
discovered.any { matches(it) } || reachable.contains("$address:$port")
@@ -11,16 +11,12 @@ import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -43,7 +39,6 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -51,47 +46,25 @@ 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.Sc2BleLink
import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
/**
* Connected-controllers debug view (Settings -> Controller -> Connected controllers): everything
* the app can see about attached input devices, plus a live input test. This exists for exactly
* the support case where a pad "doesn't work" - adapters and BT-to-USB dongles often enumerate
* with a different identity than the physical pad, or not as a gamepad at all, and punktfunk only
* forwards devices Android classifies as gamepad/joystick. This screen makes that visible on the
* device itself.
*
* The TOUCH presentation, and since 2026-08 the only one: the console reaches the same answer
* through its own Skia screen (`crates/pf-console-ui/src/screens/controllers.rs`), which keeps the
* console's input on the page instead of suspending it behind a Compose takeover. What this screen
* still owns alone is the live input test - the console receives only the aggregated navigation
* sample, which is nowhere near a per-device axis/trigger readout. Everything the console DOES
* need from here it asks for as a `ConsoleCmd::PadAction` (see [SkiaConsoleShell]), which is why
* [padInfoOf] and [testRumble] are internal rather than private.
* Connected-controllers debug view (Settings Host Connected controllers): everything the app
* can see about attached input devices, plus a live input test. This exists for exactly the support
* case where a pad "doesn't work" adapters and BT-to-USB dongles often enumerate with a different
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
*/
@Composable
internal fun ControllersScreen(
gamepadSetting: Int,
onBack: () -> Unit,
padsOverride: List<PadInfo>? = null,
) {
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
BackHandler(onBack = onBack)
val scroll = rememberScrollState()
// Events are OBSERVED (not consumed) while the test is off, which is what keeps the
// "Last input" line live while browsing. Nothing else here wants the pad.
var testing by remember { mutableStateOf(false) }
val onTestingChange: (Boolean) -> Unit = { testing = it }
val contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp)
val context = LocalContext.current
val activity = context as? MainActivity
// Device list, re-read on every hot-plug event. [padsOverride] replaces it wholesale: the
// screenshot harness runs where no InputDevice can exist, and the connected-pad card is the
// point of that shot.
// Device list, re-read on every hot-plug event.
var generation by remember { mutableIntStateOf(0) }
val pads = padsOverride ?: remember(generation) { Gamepad.pads() }.map(::padInfoOf)
val pads = remember(generation) { Gamepad.pads() }
val others = remember(generation) {
InputDevice.getDeviceIds()
.toList()
@@ -111,31 +84,17 @@ internal fun ControllersScreen(
// Live input test. While `testing`, the MainActivity probes consume pad events (so they show up
// here instead of driving focus navigation); holding B releases, since the pad can no longer
// reach the Switch.
// reach the Switch. Events are observed (not consumed) even when the test is off, so the
// "last input" line works while browsing.
var testing by remember { mutableStateOf(false) }
val held = remember { mutableStateMapOf<Int, Boolean>() }
val axes = remember { mutableStateMapOf<String, Float>() }
var lastInput by remember { mutableStateOf<String?>(null) }
var bHeld by remember { mutableStateOf(false) }
// The hold has lasted long enough; the test ends when B is let go (see the probe).
var holdSatisfied by remember { mutableStateOf(false) }
// The probes below are built ONCE and then read these for the life of the screen, so
// capturing `testing` plainly would freeze the value it had when the probe was made — the
// test would consume nothing.
val consuming by rememberUpdatedState(testing)
// The console's refusal thud, on whatever actuator the driving pad or this device has.
val haptics by rememberUpdatedState(rememberConsoleHaptics())
DisposableEffect(Unit) {
// One entry on the MainActivity probe stack, removed by identity on the way out — the rule
// GamepadNavEffect2D follows. During the console shell's push/pop BOTH screens are briefly
// composed, and only the identity removal keeps this screen's teardown from taking the
// arriving screen's claim with it. The same teardown also runs when this screen hands the
// pad to its own input test and back.
val keyProbe: (KeyEvent) -> Boolean = probe@{ event ->
activity?.padKeyProbe = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
// Read ONCE, up front: the test can end inside this very event, and the release that
// ended it still has to be swallowed here — see the B branch below.
val consume = consuming
when (event.action) {
KeyEvent.ACTION_DOWN -> {
held[event.keyCode] = true
@@ -143,34 +102,13 @@ internal fun ControllersScreen(
}
KeyEvent.ACTION_UP -> {
held[event.keyCode] = false
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
bHeld = false
if (consume) {
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
// The hold ends the test HERE, on the release, and NOT the moment
// the 1.2 s elapsed: end it a moment earlier and this release falls
// through unconsumed to the activity's B→BACK remap, which takes the
// whole screen with it. Finishing the test and leaving the screen on
// one press is not what "hold B to finish" says.
onTestingChange(false)
held.clear()
} else {
// A short B is not swallowed either. While the test owns the pad, B
// is a BUTTON UNDER TEST — it lights its chip like every other — so
// a tap can't also mean "leave", and in the console B is otherwise
// the universal back. The press gets the boundary thud instead, the
// same answer a refused step gets on the settings screen: heard, and
// it means something else here.
haptics.boundary()
}
}
}
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = false
}
}
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
consume
testing
}
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
activity?.padMotionProbe = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
@@ -186,33 +124,28 @@ internal fun ControllersScreen(
)
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
consuming
testing
}
onDispose {
activity?.padKeyProbe = null
activity?.padMotionProbe = null
}
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
activity?.pushPadProbes(probes)
onDispose { activity?.removePadProbes(probes) }
}
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
// test instead (touch still works). This half only ANSWERS the hold once it is long enough; the
// release is what ends the test (see the probe). Letting go early cancels the effect before the
// delay fires, so nothing is announced.
LaunchedEffect(bHeld, testing) {
// test instead (touch still works). A short tap cancels the effect before the delay fires.
LaunchedEffect(bHeld) {
if (bHeld && testing) {
delay(HOLD_TO_FINISH_MS)
holdSatisfied = true
// A hold with no answer at the moment it lands is a hold you keep holding. Say it in
// both channels a couch user has: a pulse in the hands, a changed line on the screen.
haptics.confirm()
} else {
holdSatisfied = false
delay(1_200)
testing = false
held.clear()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(contentPadding),
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
@@ -240,16 +173,12 @@ internal fun ControllersScreen(
}
val sc2Probe = remember { Sc2Capture(context) }
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
// Answers null without the Bluetooth grant (and logs why) — see Sc2BleLink.
val sc2Ble = remember(usbGeneration) { sc2Probe.pairedBleAddress() }
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
// A BLE-paired SC2 cannot be seen at all until Bluetooth is granted, so "no controller
// detected" would be the wrong thing to print at someone who has one paired. This is the
// screen a user opens when a pad is missing, so the grant belongs here — see
// [sc2BluetoothGrantOffered] for when it is worth offering, and the lizard-mode
// InputDevice probe (no permission of its own) for how we word it.
val btPermitted = remember(usbGeneration) { Sc2BleLink.permissionGranted(context) }
val sc2OnBluetooth = remember(usbGeneration) { Gamepad.sc2InputDevicePresent() }
val dsUsb = remember(usbGeneration) {
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
.deviceList.values.firstOrNull {
@@ -270,24 +199,11 @@ internal fun ControllersScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// After that paragraph on purpose: when nothing was detected, this is the actionable
// half of the same answer — the one pad we are blind to rather than one Android has
// simply classified oddly.
if (
sc2BluetoothGrantOffered(
permissionGranted = btPermitted,
usbSc2 = sc2Usb != null,
sc2Attached = sc2OnBluetooth,
anyPadDetected = pads.isNotEmpty(),
)
) {
Sc2BluetoothRow(attached = sc2OnBluetooth, activity = activity) { usbGeneration++ }
}
// Every real controller is forwarded now (Automatic forwards them all, each on its own
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
pads.forEach { info ->
PadRow(info, gamepadSetting = gamepadSetting)
pads.forEach { dev ->
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
}
}
@@ -296,19 +212,13 @@ internal fun ControllersScreen(
Column(Modifier.weight(1f)) {
Text("Test inputs", style = MaterialTheme.typography.bodyLarge)
Text(
when {
holdSatisfied -> "Release B to finish"
testing -> "Controller input stays on this screen — hold B to finish"
else -> "Show button presses and stick motion live"
},
if (testing) "Controller input stays on this screen — hold B to finish"
else "Show button presses and stick motion live",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = testing,
onCheckedChange = { on -> onTestingChange(on); if (!on) held.clear() },
)
Switch(checked = testing, onCheckedChange = { testing = it; if (!it) held.clear() })
}
if (testing) {
ButtonGrid(held)
@@ -345,90 +255,6 @@ internal fun ControllersScreen(
}
}
/**
* Whether to offer the Bluetooth grant for a directly-paired Steam Controller 2.
*
* Only when it could change the answer ([permissionGranted] false), and only when there is reason
* to think it would: an SC2 is visibly attached in lizard mode ([sc2Attached] the permission-free
* probe), or nothing was detected at all ([anyPadDetected] false) and a Bluetooth SC2 is precisely
* the pad this client cannot see without the grant. A [usbSc2] is already captured over USB and
* needs no Bluetooth, and someone with working controllers and no sign of an SC2 is shown nothing.
*/
fun sc2BluetoothGrantOffered(
permissionGranted: Boolean,
usbSc2: Boolean,
sc2Attached: Boolean,
anyPadDetected: Boolean,
): Boolean = !permissionGranted && !usbSc2 && (sc2Attached || !anyPadDetected)
/**
* The Bluetooth grant for a directly-paired Steam Controller 2 the card that exists because a
* BLE SC2 is invisible without it.
*
* A wired or Puck SC2 is enumerated over USB with no permission at all, so it shows up in this
* screen either way; the bonded list a BLE one lives in is behind `BLUETOOTH_CONNECT` from API 31
* and answers "nothing is paired" rather than "ask me first" when the permission is missing. Until
* this existed, nothing in the client ever requested it, so a Bluetooth SC2 was silently absent
* everywhere no capture, no controller layout, no forwarding while the same pad over USB
* worked (field report, 2026-08-15).
*
* [attached] distinguishes "we can see one sitting in lizard mode" from "you may have one paired",
* which is the difference between a statement and a guess. [onGranted] re-probes the caller's
* device state; the menu capture is engaged from here too, so the pad starts driving the UI on the
* grant rather than at the next resume.
*/
@Composable
private fun Sc2BluetoothRow(
attached: Boolean,
activity: MainActivity?,
onGranted: () -> Unit,
) {
val context = LocalContext.current
val settingOn = remember { SettingsStore(context).load().sc2Capture }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
activity?.startSc2MenuNav()
onGranted()
}
}
val permission = Sc2BleLink.CONNECT_PERMISSION ?: return
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
if (attached) "Steam Controller 2" else "Steam Controller 2 over Bluetooth",
style = MaterialTheme.typography.bodyLarge,
)
Text(
when {
!settingOn ->
"Passthrough is disabled in Settings — enable \"Steam Controller 2 " +
"passthrough\" to capture it."
attached ->
"Paired over Bluetooth. Punktfunk needs Bluetooth access to capture it — " +
"until then it stays in its built-in keyboard/mouse mode and no game " +
"sees a controller."
else ->
"A Steam Controller 2 paired over Bluetooth can't be detected without " +
"Bluetooth access. Wired and Puck-dongle controllers need no " +
"permission and are already listed above."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (settingOn) {
OutlinedButton(onClick = { launcher.launch(permission) }) {
Text("Grant Bluetooth access")
}
}
}
}
}
/**
* 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
@@ -653,19 +479,19 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
/** One detected gamepad: identity, what it streams as, and a rumble test. */
@Composable
private fun PadRow(info: PadInfo, gamepadSetting: Int) {
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(info.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
if (info.forwarded) {
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
if (forwarded) {
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
// user can tell which physical pad is which. The stream's wire pad index is
// assigned separately (lowest-free per device) once streaming starts.
val number = info.controllerNumber
val number = dev.controllerNumber
Text(
if (number > 0) "forwarded · player $number" else "forwarded to host",
style = MaterialTheme.typography.labelSmall,
@@ -674,11 +500,11 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
}
}
Text(
info.detail,
deviceDetail(dev),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val resolved = info.resolvedPref
val resolved = Gamepad.prefFor(dev)
Text(
if (gamepadSetting == Gamepad.PREF_AUTO) {
"Streams as: ${prefLabel(resolved)} (automatic)"
@@ -689,8 +515,9 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (info.canRumble) {
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
val canRumble = deviceHasVibrator(dev)
if (canRumble) {
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
} else {
Text(
"No rumble motors reported — host rumble will be silent",
@@ -771,32 +598,6 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
private fun isForwarded(dev: InputDevice): Boolean =
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
/**
* Everything [PadRow] renders, decoupled from [InputDevice] so the screenshot harness can compose
* the connected-pad card at all Robolectric enumerates no input devices, and a marketing shot of
* "no controller detected" sells nothing. Production always maps a real device via [padInfoOf];
* [dev] powers the rumble test and is absent only in the harness (the button then no-ops).
*/
internal data class PadInfo(
val name: String,
val detail: String,
val forwarded: Boolean,
val controllerNumber: Int,
val resolvedPref: Int,
val canRumble: Boolean,
val dev: InputDevice? = null,
)
internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
name = dev.name,
detail = deviceDetail(dev),
forwarded = isForwarded(dev),
controllerNumber = dev.controllerNumber,
resolvedPref = Gamepad.prefFor(dev),
canRumble = deviceHasVibrator(dev),
dev = dev,
)
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
private fun deviceHasVibrator(dev: InputDevice): Boolean =
if (Build.VERSION.SDK_INT >= 31) {
@@ -806,8 +607,7 @@ private fun deviceHasVibrator(dev: InputDevice): Boolean =
dev.vibrator.hasVibrator()
}
/** A short pulse on the pad's own motor. Also the console's `PadAction::Rumble`. */
internal fun testRumble(dev: InputDevice) {
private fun testRumble(dev: InputDevice) {
runCatching {
if (Build.VERSION.SDK_INT >= 31) {
val vm = dev.vibratorManager
@@ -880,11 +680,3 @@ private val TEST_BUTTONS = listOf(
/** Axis bars shown in the test view, in display order. */
private val AXIS_LABELS = listOf("LX", "LY", "RX", "RY", "LT", "RT", "HX", "HY")
/**
* How long B must be held to end the input test and, below that, how long a press still counts as
* a tap that gets answered rather than ignored. One constant, because a hold that ends at 1.2 s
* while the "you tapped" answer stops at some other number leaves a window where a press does
* nothing at all.
*/
private const val HOLD_TO_FINISH_MS = 1_200L
@@ -1,25 +0,0 @@
package io.unom.punktfunk
import android.content.Context
import android.os.Build
import android.provider.Settings
/**
* The name the user knows this device by what a host shows in its pending-approval list (the web
* console's outstanding-pairings view and the dialog that approves a knock) and files the device
* under once approved.
*
* `Settings.Global.DEVICE_NAME` is the name the user typed in Settings ("Enrico's Pixel", "TV im
* Wohnzimmer"); it is what every other protocol on the network already calls this device. Only when
* it is unset does this fall back to [Build.MODEL], which names the *product* and so reads
* identically on every unit of it two of the same tablet pending approval are indistinguishable.
* Available unconditionally here: `DEVICE_NAME` landed in API 25 and this app's floor is 28.
*/
internal fun deviceName(context: Context): String {
val userNamed = runCatching {
Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME)
}.getOrNull()
return userNamed?.trim()?.takeIf { it.isNotEmpty() }
?: Build.MODEL?.trim()?.takeIf { it.isNotEmpty() }
?: "Android"
}
@@ -0,0 +1,487 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
// The gamepad-driven "Add Host" screen — the Android mirror of the Apple client's GamepadAddHostView
// + GamepadKeyboard: three field rows (name / address / port) plus an Add action, navigated with the
// vertical focus list; A on a field opens the on-screen keyboard so a host can be registered end to
// end from the couch. One GamepadNavEffect2D owns BOTH modes (list vs keyboard) so they never fight
// over the shared input probes. B peels one layer: close the keyboard, then cancel the screen.
// Keyboard grid: digits, qwerty letters, hostname/address punctuation, then space / delete / done.
private val KB_CHAR_ROWS = listOf("1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm._:")
private const val KB_ACTIONS_ROW = 4 // index of the [space, delete, done] row
private const val KB_ROWS = 5
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
@Composable
fun GamepadAddHostScreen(
onAdd: (name: String, address: String, port: Int) -> Unit,
onDismiss: () -> Unit,
// Non-null → EDIT mode: fields seed from this host, a MAC row is added, and the action SAVES the
// edited record via [onSave] instead of connecting. [suggestedMacs] prefills a not-yet-learned MAC.
editHost: KnownHost? = null,
suggestedMacs: List<String> = emptyList(),
onSave: ((KnownHost) -> Unit)? = null,
) {
val ink = LocalGamepadInk.current
val context = LocalContext.current
val isTv = remember { isTvDevice(context) }
val isEdit = editHost != null
val title = if (isEdit) "Edit Host" else "Add Host"
val actionLabel = if (isEdit) "Save" else "Add Host"
var name by remember { mutableStateOf(editHost?.name ?: "") }
var address by remember { mutableStateOf(editHost?.address ?: "") }
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
fun commit() {
if (isEdit && editHost != null && onSave != null) {
onSave(
editHost.copy(
name = name.trim().ifEmpty { editHost.address },
address = address.trim(),
port = port.toIntOrNull() ?: editHost.port,
mac = KnownHostStore.parseMacs(mac),
),
)
} else {
onAdd(name.trim(), address.trim(), port.toIntOrNull() ?: 9777)
}
}
// On a TV the OS provides a leanback on-screen keyboard for text fields, so use real (focusable)
// text fields + the system IME there. Our controller keyboard is for a phone-with-controller,
// where the phone's own soft keyboard needs a touch a pad can't provide.
if (isTv) {
TvAddHostForm(
title = title, actionLabel = actionLabel,
name = name, onName = { name = it },
address = address, onAddress = { address = it },
port = port, onPort = { port = it.filter(Char::isDigit).take(5) },
mac = if (isEdit) mac else null, onMac = { mac = it },
canAdd = canAdd,
onAdd = { commit() },
onDismiss = onDismiss,
)
return
}
var focus by remember { mutableIntStateOf(1) } // start on Address
var editing by remember { mutableStateOf<String?>(null) } // field id being typed, or null
var kbRow by remember { mutableIntStateOf(1) }
var kbCol by remember { mutableIntStateOf(0) }
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val hazeState = remember { HazeState() }
val fields = buildList {
add(Field("name", "Name", name, "Optional — e.g. Living Room"))
add(Field("address", "Address", address, "IP or hostname"))
add(Field("port", "Port", port, "9777"))
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
}
val actionIndex = fields.size // the Save/Add action sits just after the last field
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
fun closeKeyboard() { editing = null }
fun editField(id: String, transform: (String) -> String) {
when (id) {
"name" -> name = transform(name)
"address" -> address = transform(address)
"port" -> port = transform(port).take(5)
"mac" -> mac = transform(mac)
}
}
fun allowed(id: String, c: Char): Boolean = when (id) {
"port" -> c.isDigit()
"address" -> c != ' '
else -> true
}
fun activateField() {
if (focus == actionIndex) {
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
} else {
openKeyboard(fields[focus].id)
}
}
fun pressKey() {
val id = editing ?: return
if (kbRow < KB_ACTIONS_ROW) {
val c = KB_CHAR_ROWS[kbRow][kbCol.coerceIn(0, KB_CHAR_ROWS[kbRow].lastIndex)]
if (allowed(id, c)) editField(id) { it + c }
} else when (kbCol) {
0 -> if (allowed(id, ' ')) editField(id) { "$it " }
1 -> editField(id) { it.dropLast(1) }
else -> closeKeyboard()
}
}
BackHandler { if (editing != null) closeKeyboard() else onDismiss() }
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
if (editing == null) {
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < actionIndex) focus++
else -> {}
}
} else {
when (dir) {
NavDir.UP -> if (kbRow > 0) { kbRow--; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
NavDir.DOWN -> if (kbRow < KB_ROWS - 1) { kbRow++; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
NavDir.LEFT -> if (kbCol > 0) kbCol--
NavDir.RIGHT -> if (kbCol < rowCols(kbRow) - 1) kbCol++
}
}
},
onActivate = { if (editing == null) activateField() else pressKey() },
onTertiary = { if (editing != null) editField(editing!!) { it.dropLast(1) } },
onSecondary = { if (editing != null) closeKeyboard() },
)
val onFieldClick: (Int) -> Unit = { i -> if (focus == i) activateField() else focus = i }
val onAddClick: () -> Unit = { if (focus == actionIndex) activateField() else focus = actionIndex }
// Tappable (touch escape hatch): the legend doubles as buttons when there's no working controller.
val typeHints = listOf(
PadGlyph.hint('A', "Type") { pressKey() },
PadGlyph.hint('X', "Delete") { editing?.let { id -> editField(id) { it.dropLast(1) } } },
PadGlyph.hint('B', "Done") { closeKeyboard() },
)
val sideBySide = landscape && editing != null
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadFormBackground(Modifier.fillMaxSize())
if (sideBySide) {
// Landscape + typing: fields and keyboard SIDE BY SIDE so the field being edited stays
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
Row(
Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ConsoleHeader(title, horizontalInset = false)
fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } }
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
}
Column(
Modifier.weight(1.15f).fillMaxHeight().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
KeyboardGrid(kbRow, kbCol, compact = true) { r, c -> kbRow = r; kbCol = c; pressKey() }
}
}
} else {
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
Column(
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ConsoleHeader(title, horizontalInset = false)
if (editing == null && !landscape) {
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
)
}
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
}
if (editing != null) {
Spacer(Modifier.height(8.dp))
// The keyboard fills to the bottom; its bottom frame is padded so the fixed
// legend sits OVER that frame (bottom-left corner) rather than in a gap below.
KeyboardGrid(kbRow, kbCol, compact = false, bottomInset = 52.dp) { r, c -> kbRow = r; kbCol = c; pressKey() }
}
}
}
}
// Floating legend — ALWAYS at the same fixed bottom-start spot (portrait or landscape, keyboard
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
Box(
Modifier.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
if (editing != null) {
typeHints
} else {
listOf(
PadGlyph.hint('A', "Select") { activateField() },
PadGlyph.hint('B', "Cancel", onClick = onDismiss),
)
},
hazeState = hazeState,
)
}
}
}
/**
* Add-Host on a TV: real focusable text fields + the system (leanback) IME, driven by the OS. No
* custom keyboard or input probes the native focus engine moves between fields and the Add button,
* and focusing a field pops the OS keyboard. B backs out.
*/
@Composable
private fun TvAddHostForm(
title: String,
actionLabel: String,
name: String,
onName: (String) -> Unit,
address: String,
onAddress: (String) -> Unit,
port: String,
onPort: (String) -> Unit,
mac: String?, // non-null only in edit mode
onMac: (String) -> Unit,
canAdd: Boolean,
onAdd: () -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onDismiss)
val firstFocus = remember { FocusRequester() }
Box(Modifier.fillMaxSize()) {
GamepadFormBackground(Modifier.fillMaxSize())
Column(
Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(horizontal = 56.dp, vertical = 36.dp)
.widthIn(max = 720.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg)
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
)
OutlinedTextField(
value = name, onValueChange = onName, singleLine = true,
label = { Text("Name (optional)") },
modifier = Modifier.fillMaxWidth().focusRequester(firstFocus),
)
OutlinedTextField(
value = address, onValueChange = onAddress, singleLine = true,
label = { Text("Address") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = port, onValueChange = onPort, singleLine = true,
label = { Text("Port") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
if (mac != null) {
OutlinedTextField(
value = mac, onValueChange = onMac, singleLine = true,
label = { Text("Wake-on-LAN MAC") },
placeholder = { Text("auto-filled when the host is seen") },
modifier = Modifier.fillMaxWidth(),
)
}
Button(onClick = onAdd, enabled = canAdd, modifier = Modifier.fillMaxWidth()) {
Text(actionLabel)
}
}
}
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
}
private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row].length else 3
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg)
Spacer(Modifier.weight(1f))
Text(
f.value.ifEmpty { f.placeholder },
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (editing) Text(" |", color = ink.accent)
}
}
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
val labelColor by animateColorAsState(
if (enabled) ink.accent else ink.fg(0.35f),
tween(160),
label = "addLabel",
)
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = labelColor,
)
}
}
@Composable
private fun KeyboardGrid(
cursorRow: Int,
cursorCol: Int,
compact: Boolean,
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
onKey: (Int, Int) -> Unit,
) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(20.dp)
val gap = if (compact) 5.dp else 7.dp
Column(
Modifier
.fillMaxWidth()
.widthIn(max = 640.dp)
.clip(shape)
.background(Color(0x1FFFFFFF))
.border(1.dp, ink.fg(0.12f), shape)
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
verticalArrangement = Arrangement.spacedBy(gap),
) {
KB_CHAR_ROWS.forEachIndexed { r, chars ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
chars.forEachIndexed { c, ch ->
Keycap(ch.toString(), focused = cursorRow == r && cursorCol == c, compact = compact, modifier = Modifier.weight(1f)) { onKey(r, c) }
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
Keycap("space", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 0, compact = compact, modifier = Modifier.weight(2f)) { onKey(KB_ACTIONS_ROW, 0) }
Keycap("", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 1, compact = compact, modifier = Modifier.weight(1f)) { onKey(KB_ACTIONS_ROW, 1) }
Keycap("Done", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 2, compact = compact, modifier = Modifier.weight(1.5f)) { onKey(KB_ACTIONS_ROW, 2) }
}
}
}
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
val bg by animateColorAsState(
if (focused) ink.accent else ink.glass,
tween(90),
label = "keyBg",
)
val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
.clip(RoundedCornerShape(9.dp))
.background(bg)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = fg,
textAlign = TextAlign.Center,
)
}
}
@@ -1,348 +0,0 @@
package io.unom.punktfunk
import android.graphics.RuntimeShader
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.withInfiniteAnimationFrameMillis
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import java.util.Locale
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sin
// The living console backdrop, in two renderings of ONE design.
//
// On API 33+ this is the desktop console's actual field: `pf-console-ui`'s `mesh_sksl`
// (library.rs) ported to AGSL — a 4×4 bicubic colour mesh warped by four drifting interior points,
// swayed ±8° in hue, vignetted and scrimmed. AGSL is the SkSL subset Android 13 ships, so the
// shader body is very nearly the same source, and `GamepadPalette.meshColors` is literally the same
// 16-cell table the Rust samples. Below 33 (`RuntimeShader` is 33+) the field falls back to four
// drifting radial blobs sampled from the same palette ramp — an approximation of the same look, and
// the honest one: emulating a mesh gradient with bitmaps would cost more than it bought.
//
// Either way it is AMBIENCE, never content: it runs full-bleed under the cutout and the system bars,
// and every console screen's chrome floats over it.
/**
* The console backdrop. [calm] is what the FORM screens (settings, add-host) wear: the pools dim
* onto the ground so the glass rows keep real colour and luminance without the launcher's contrast.
* Motion is identical either way on purpose only the contrast differs, so moving between screens
* can't make the field jump.
*
* Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the
* same courtesy the Apple client pays Reduce Motion which doubles as the deterministic mode the
* screenshot harness captures in, since the phase is just a uniform.
*/
@Composable
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
val palette = LocalGamepadPalette.current
val animated = animationsEnabled()
// Compiled once per palette and cached process-wide: stepping the Background row recolours the
// field under the very row being stepped, and a shader compile per D-pad press would be felt on
// a TV box. A compile failure resolves null and takes the blob path — a vendor Skia that
// rejects the source must not take the console UI down with it.
val shader = if (Build.VERSION.SDK_INT >= 33) {
remember(palette.id) { meshShaderFor(palette) }
} else {
null
}
if (shader != null) {
MeshAurora(modifier, shader, calm, animated)
} else {
BlobAurora(modifier, palette, calm, animated)
}
}
/**
* The backdrop for the console FORM screens (settings, add-host) the launcher's own living field
* at `calm`, so no screen in the console UI is backed by a still image and the palette setting
* reaches every one of them. Mirrors the Apple client's GamepadFormBackground and the desktop's
* single `calm` uniform.
*/
@Composable
fun GamepadFormBackground(modifier: Modifier = Modifier) {
GamepadAuroraBackground(modifier, calm = true)
}
// --- The mesh field (API 33+) ---------------------------------------------------------------
/** The phase a frozen (reduce-motion / screenshot) field is drawn at — the desktop's t = 0. */
private const val FROZEN_PHASE = 0f
@RequiresApi(33)
@Composable
private fun MeshAurora(
modifier: Modifier,
shader: RuntimeShader,
calm: Boolean,
animated: Boolean,
) {
val ink = LocalGamepadInk.current
val palette = LocalGamepadPalette.current
val brush = remember(shader) { ShaderBrush(shader) }
// Real monotonic seconds, not a wrapping sweep: the four warp points and the hue sway run at
// mutually irrational rates (periods ~90130 s), so no loop length exists that would rejoin
// them seamlessly — which is exactly why the desktop feeds its shader elapsed time too. Frozen
// under reduce-motion, where it also makes the field deterministic for a screenshot.
val time by produceState(FROZEN_PHASE, animated) {
if (!animated) return@produceState
while (true) {
withInfiniteAnimationFrameMillis { value = it / 1000f }
}
}
val (gr, gg, gb) = palette.ground
Canvas(modifier) {
// Uniforms are set per draw, not per recomposition: `time` is read HERE, inside the draw
// scope, so a new frame invalidates the draw only — the composition never re-runs.
shader.setFloatUniform("u_res", size.width, size.height)
shader.setFloatUniform("u_tc", time, if (calm) 1f else 0f)
// The calm lift: the palette's ground scaled to 0.4, what the field flattens toward.
shader.setFloatUniform(
"u_lift",
(gr * 0.4).toFloat(), (gg * 0.4).toFloat(), (gb * 0.4).toFloat(), 0f,
)
// Where the vignette and scrims tend, and how hard — black at full strength on a dark
// field, white at well under half on a pale one (mixing a pastel toward white at the dark
// field's strength bleaches the chroma straight out of the gradient).
shader.setFloatUniform(
"u_scrim",
ink.shade.red, ink.shade.green, ink.shade.blue, ink.shadeScale,
)
drawRect(brush)
}
}
/**
* Compiled mesh shaders by palette id at most the 13 shipped palettes, so it is bounded by the
* table rather than by use. Touched only from the composition (main) thread.
*/
private val meshShaders = HashMap<String, RuntimeShader?>()
@RequiresApi(33)
private fun meshShaderFor(palette: GamepadPalette): RuntimeShader? =
meshShaders.getOrPut(palette.id) {
runCatching { RuntimeShader(meshAgsl(palette.meshColors)) }.getOrNull()
}
/**
* Format a shader constant. `Locale.ROOT` is not optional: `String.format` on a German-locale
* device emits `0,075`, which is a syntax error in the shader source and would take the whole
* backdrop out on exactly the devices it was authored on. `%f` also keeps a very small ramp value
* out of exponent notation, which SkSL would still parse but nobody would enjoy reading.
*/
private fun n(v: Double): String = String.format(Locale.ROOT, "%.6f", v)
/**
* The mesh gradient as AGSL, the palette baked into the source and resolution/time/calm/scrim left
* as uniforms the direct port of `pf-console-ui`'s `mesh_sksl`, kept structurally line-for-line
* with it so the two can be diffed. A smooth bicubic blend of the 16 colours (a separable
* cubic-Bézier basis in x then y, the fragment-shader analogue of SwiftUI's
* `MeshGradient(smoothsColors: true)`), four interior points driving a bounded domain warp, then
* the ±8° hue sway, an elliptical vignette and the vertical legibility scrim.
*/
private fun meshAgsl(colors: List<Triple<Double, Double, Double>>): String {
fun c(i: Int): String {
val (r, g, b) = colors[i]
return "float3(${n(r)}, ${n(g)}, ${n(b)})"
}
// The four interior-point domain-warp accumulators. SIG (0.30) sets how far each point's pull
// reaches; the warp is the weight-normalised average displacement, so |warp| ≤ max|amp|.
val warp = buildString {
for (p in GamepadPalette.MESH_INTERIOR) {
append(" q = uv - float2(${n(p.x)}, ${n(p.y)});\n")
append(" ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n")
append(" d = float2(${n(p.amp)} * sin(tt * ${n(p.sx)} + ${n(p.phase)}),\n")
append(" ${n(p.amp)} * cos(tt * ${n(p.sy)} + ${n(p.phase)} * 1.3));\n")
append(" wsum += d * ww; wtot += ww;\n")
}
}
return """
uniform float2 u_res;
// x = seconds since this field started, y = the calm mix (0 launcher, 1 form).
uniform float2 u_tc;
// rgb = the palette's corner colour scaled for the calm lift; a is unused.
uniform float4 u_lift;
// rgb = what the vignette and scrims tend toward, a = how hard.
uniform float4 u_scrim;
// Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.
float bz(float t, float a, float b, float c, float d) {
float u = 1.0 - t;
return u*u*u*a + 3.0*u*u*t*b + 3.0*u*t*t*c + t*t*t*d;
}
float3 bz3(float t, float3 a, float3 b, float3 c, float3 d) {
return float3(bz(t, a.r, b.r, c.r, d.r), bz(t, a.g, b.g, c.g, d.g), bz(t, a.b, b.b, c.b, d.b));
}
// Hue rotation about the grey axis (Rodrigues) — the ±8° warm/cool sway. The desktop's `cross(k,
// col)` is written out here: with k = (c, c, c) it collapses to c·(b-g, r-b, g-r), which needs no
// builtin at all — AGSL's function set is a subset of SkSL's and not worth betting the field on.
float3 hue(float3 col, float a) {
float c = 0.5773503;
float cs = cos(a); float sn = sin(a);
float3 kx = c * float3(col.b - col.g, col.r - col.b, col.g - col.r);
return col*cs + kx*sn + float3(c) * dot(float3(c), col) * (1.0 - cs);
}
half4 main(float2 xy) {
float tt = u_tc.x; float calm = u_tc.y;
float2 uv = xy / u_res;
// Interior control points wander → bounded domain warp (pools follow them).
float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;
$warp
uv = clamp(uv - wsum / (wtot + 0.0001), 0.0, 1.0);
// Bicubic blend of the 16 mesh colours: cubic-Bézier in x per row, then in y.
float3 r0 = bz3(uv.x, ${c(0)}, ${c(1)}, ${c(2)}, ${c(3)});
float3 r1 = bz3(uv.x, ${c(4)}, ${c(5)}, ${c(6)}, ${c(7)});
float3 r2 = bz3(uv.x, ${c(8)}, ${c(9)}, ${c(10)}, ${c(11)});
float3 r3 = bz3(uv.x, ${c(12)}, ${c(13)}, ${c(14)}, ${c(15)});
float3 col = bz3(uv.y, r0, r1, r2, r3);
col = hue(col, sin(tt * 0.021) * 0.1396263);
// Calm: flatten the field toward its own corner colour — the pools dim and the corners lift,
// so a form screen keeps real colour under its glass rows while losing the launcher's
// contrast. Motion is untouched.
col = mix(col, col * 0.60 + u_lift.rgb, calm);
// Elliptical vignette: clear at r=0.25 → scrim·0.42 at r=1.15. Halved under calm — a
// launcher's cards sit in the pooled centre, but a form screen's rows run out toward the
// edges, where crushing them just eats the list.
float2 e = (xy / u_res - 0.5) * 2.0;
float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm) * u_scrim.a;
col = mix(col, u_scrim.rgb, vig);
// Vertical legibility scrim for the pinned heading + the floating legend.
float v = xy.y / u_res.y;
float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32)
: v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36)
: mix(0.08, 0.40, (v - 0.68) / 0.32);
col = mix(col, u_scrim.rgb, s * u_scrim.a);
return half4(half3(col), 1.0);
}
"""
}
// --- The blob field (API 2832 fallback) -----------------------------------------------------
/**
* One drifting blob of the fallback field: where it sits, how far it wanders, and how fast. Integer
* [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at
* draw time, so the field always shows several of that palette's tones at once.
*/
private class AuroraBlob(
val baseX: Float,
val baseY: Float,
val driftX: Float,
val driftY: Float,
val sx: Int,
val sy: Int,
val phase: Float,
val radiusFrac: Float,
val alpha: Float,
)
private val auroraBlobs = listOf(
AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f),
AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f),
AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f),
AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f),
)
/**
* Soft blobs from the palette's ramp drifting over its ground on slow, seamless loops, finished
* with a centre-pooling vignette and top/bottom legibility scrims. What API 2832 sees in place of
* the mesh: the same colour families, the same "ambience, never content" role, and the same
* [GamepadPalette] setting recolours it.
*/
@Composable
private fun BlobAurora(
modifier: Modifier,
palette: GamepadPalette,
calm: Boolean,
animated: Boolean,
) {
val ink = LocalGamepadInk.current
val transition = rememberInfiniteTransition(label = "aurora")
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the
// wrap so the field never visibly jumps when the animation restarts.
val swept by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
label = "angle",
)
val angle = if (animated) swept else 0f
val tones = palette.blobColors
val ground = palette.groundColor
// Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's
// strength bleaches the chroma straight out of the gradient, so a pale palette gets under
// half — the same scrim strength the desktop console's shader carries.
val scrim = if (palette.light) ink.fg else Color.Black
val strength = if (palette.light) 0.45f else 1f
Canvas(modifier) {
drawRect(ground)
val span = max(size.width, size.height)
for ((i, b) in auroraBlobs.withIndex()) {
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
val r = span * b.radiusFrac
// Calm scales each blob's contribution rather than dimming the whole canvas: the
// ground stays put and only the pools come down to meet it, which is the same "lower
// the contrast, keep the colour" the desktop console's `calm` uniform does.
val alpha = if (calm) b.alpha * 0.62f else b.alpha
drawCircle(
brush = Brush.radialGradient(
colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent),
center = Offset(cx, cy),
radius = r,
),
center = Offset(cx, cy),
radius = r,
// Additive only works over a DARK ground; over a pale one every blob
// saturates to white and the field turns grey. Pale palettes tint instead.
blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus,
)
}
// Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under
// calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out
// toward the edges, where crushing them just eats the list.
drawRect(
Brush.radialGradient(
colors = listOf(
Color.Transparent,
scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength),
),
center = Offset(size.width / 2, size.height / 2),
radius = span * 0.92f,
),
)
// Top/bottom legibility scrim for the pinned title + hint bar.
drawRect(
Brush.verticalGradient(
0.0f to scrim.copy(alpha = 0.40f * strength),
0.30f to scrim.copy(alpha = 0.05f * strength),
0.70f to scrim.copy(alpha = 0.06f * strength),
1.0f to scrim.copy(alpha = 0.42f * strength),
),
)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,643 @@
package io.unom.punktfunk
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// Console-styled trust/pairing dialogs — the controller-navigable counterparts of the touch
// AlertDialogs in ConnectDialogs.kt, shown while the gamepad UI is active. A dark glass card over a
// scrim with focusable action buttons: D-pad left/right moves the focus, A activates it, B dismisses.
/** One dialog action button. */
class DialogAction(
val label: String,
val primary: Boolean = false,
val enabled: Boolean = true,
val onClick: () -> Unit,
)
/**
* The shared console-dialog scaffold: scrim + glass card with a title, [body], and a row of focusable
* [actions]. Owns its own controller nav (the presenting carousel drops its probes while a dialog is
* up, via ConnectScreen's `navActive`). B [onDismiss].
*/
@Composable
fun GamepadDialog(
title: String,
onDismiss: () -> Unit,
actions: List<DialogAction>,
body: @Composable ColumnScope.() -> Unit,
) {
val ink = LocalGamepadInk.current
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
BackHandler(onBack = onDismiss)
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < actions.lastIndex) focus++
else -> {}
}
},
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
)
// Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
// landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
// a pinned actions column can only compress/clip its last button. Only the title stays pinned;
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
// the current action even when the stack scrolls.
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
body()
Spacer(Modifier.size(4.dp))
actions.forEachIndexed { i, a ->
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val scale by animateFloatAsState(
if (focused) 1.02f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "btnScale",
)
// The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
// that's scrolled out of a short window, pull it into view (no-op when already visible).
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
focused -> ink.accent
primary -> ink.accent(0.20f)
else -> ink.glass
},
tween(160),
label = "btnBg",
)
val fg by animateColorAsState(
when {
!enabled -> ink.fg(0.35f)
focused -> ink.fg
primary -> ink.accent
else -> ink.fg(0.85f)
},
tween(160),
label = "btnFg",
)
val borderColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
tween(160),
label = "btnBorder",
)
Box(
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(bg)
.border(1.dp, borderColor, shape)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 20.dp, vertical = 13.dp),
contentAlignment = Alignment.Center,
) {
Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1)
}
}
/** Body text helper — a dimmed paragraph. */
@Composable
private fun DialogText(text: String) {
val ink = LocalGamepadInk.current
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f))
}
/**
* Console host options for a saved tile Wake (offered only when offline + a MAC is known), Copy
* link, Edit, Forget. Reached by pressing Up on a focused saved host in the carousel; the console
* counterpart of the touch host card's overflow menu.
*/
@Composable
fun GamepadHostOptionsDialog(
hostName: String,
canWake: Boolean,
onWake: () -> Unit,
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
onEdit: () -> Unit,
onForget: () -> Unit,
/**
* Copy this tile's `punktfunk://` link. Offered on a pinned tile too — unlike the host's other
* actions it says nothing about the host, it hands out the shortcut this very tile already is
* (profile included), which is exactly what a pin is for.
*/
onCopyLink: () -> Unit,
onDismiss: () -> Unit,
onSpeedTest: (() -> Unit)? = null,
/**
* Non-null when this is a PINNED host+profile tile, whose only action is to unpin. A pin is a
* shortcut, not a second host offering the host's destructive actions on it would blur
* exactly that, and the touch grid withholds them for the same reason.
*/
onUnpin: (() -> Unit)? = null,
profileName: String? = null,
) {
GamepadDialog(
title = if (profileName != null) "$hostName · $profileName" else hostName,
onDismiss = onDismiss,
actions = buildList {
if (onUnpin != null) {
add(DialogAction("Unpin card", primary = true, onClick = onUnpin))
add(DialogAction("Copy link", onClick = onCopyLink))
add(DialogAction("Cancel", onClick = onDismiss))
return@buildList
}
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
if (canWake) add(DialogAction("Wake host", onClick = onWake))
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
add(DialogAction("Copy link", onClick = onCopyLink))
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
add(DialogAction("Forget", onClick = onForget))
add(DialogAction("Cancel", onClick = onDismiss))
},
) {
DialogText(
if (onUnpin != null) {
"This card is a shortcut to this host with one profile. Unpinning it changes " +
"nothing about the host or the profile."
} else {
"Manage this saved host."
},
)
}
}
/**
* The pin-to-hosts picker the settings screen's Profiles section opens the Android mirror of the
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
* carousel's unpin uses, never the profile itself and never the host's default binding.
*
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
* always what the store holds the row can't disagree with the carousel it feeds.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GamepadPinHostsDialog(
profileName: String,
hosts: List<KnownHost>,
pinned: (KnownHost) -> Boolean,
onToggle: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
// Done, so it starts focused).
var focus by remember { mutableIntStateOf(0) }
BackHandler(onBack = onDismiss)
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < hosts.size) focus++
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
// direction can't oscillate; asking for the state it's already in is a no-op.
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
}
},
onActivate = {
val kh = hosts.getOrNull(focus)
if (kh != null) onToggle(kh) else onDismiss()
},
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Pin “$profileName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (hosts.isEmpty()) {
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
} else {
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
hosts.forEachIndexed { i, kh ->
PinHostRow(
label = kh.name,
on = pinned(kh),
focused = i == focus,
onClick = { onToggle(kh) },
)
}
}
Spacer(Modifier.size(4.dp))
DialogButton(
"Done",
focused = focus == hosts.size,
primary = true,
enabled = true,
onClick = onDismiss,
)
}
}
}
}
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
// landscape window pulls itself into view.
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
Row(
Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.weight(1f))
ConsoleSwitch(on = on, focused = focused)
}
}
/**
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule a TV box on a
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
* couch surface too, even though profile EDITING doesn't.
*/
@Composable
fun GamepadSpeedTestDialog(
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val done = phase as? SpeedTestPhase.Done
GamepadDialog(
title = "Network speed test",
onDismiss = onDismiss,
actions = buildList {
if (done != null) {
add(
DialogAction(
when (target) {
SpeedTestTarget.Global -> "Apply"
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}"
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}"
},
primary = true,
) { onApply(true) },
)
if (target is SpeedTestTarget.Ask) {
add(DialogAction("Set as default") { onApply(false) })
}
}
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
},
) {
DialogText(hostName)
when (phase) {
SpeedTestPhase.Connecting -> DialogText("Connecting…")
SpeedTestPhase.Measuring ->
DialogText("Measuring — the host is bursting test traffic for two seconds.")
is SpeedTestPhase.Failed -> DialogText(phase.message)
is SpeedTestPhase.Done -> {
DialogText(
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
)
DialogText("Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps))
}
}
}
}
/** Console counterpart of [LocalNetworkDialog] — the Android 17+ ACCESS_LOCAL_NETWORK rationale. */
@Composable
fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Allow local network access",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Allow", primary = true, onClick = onAllow),
DialogAction("Open settings", onClick = onSettings),
DialogAction("Not now", onClick = onDismiss),
),
) {
DialogText(
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
)
DialogText(
"If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " +
"system settings.",
)
}
}
@Composable
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Trust this host?",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Pair with PIN", onClick = onPairInstead),
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
),
) {
DialogText("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}") }
DialogText(
"This host allows trust-on-first-use, but that can't tell an impostor from the real host. " +
"Pairing with a PIN is stronger — it proves both sides.",
)
}
}
@Composable
fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Host identity changed",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Re-pair", primary = true, onClick = onRepair),
),
) {
DialogText(
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. This can " +
"mean a host reinstall — or an impostor. Re-pair with the host's PIN to continue.",
)
}
}
@Composable
fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Pairing required",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Use a PIN", onClick = onUsePin),
DialogAction("Request access", primary = true, onClick = onRequestAccess),
),
) {
DialogText("${pt.host}:${pt.port} requires pairing before it will stream.")
DialogText(
"Request access and approve this device in the host's console (or web UI) — no PIN needed. " +
"Or pair with the 4-digit PIN the host displays.",
)
}
}
@Composable
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
val ink = LocalGamepadInk.current
GamepadDialog(
title = "Waiting for approval",
onDismiss = onCancel,
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
) {
val deviceName = Build.MODEL ?: "this device"
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg)
Text("Approve this device on $hostLabel.", color = ink.fg)
}
DialogText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
"once you approve — no PIN needed.",
)
}
}
/**
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
* 09), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
* fingerprint to [onPaired]. No text keyboard needed a PIN is four digits.
*/
@Composable
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
val ink = LocalGamepadInk.current
val scope = rememberCoroutineScope()
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
val name = remember { Build.MODEL ?: "Android" }
fun pair() {
val id = identity ?: return
pairing = true
err = null
val pin = digits.joinToString("")
scope.launch {
val fp = withContext(Dispatchers.IO) {
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())
}
}
}
BackHandler(onBack = { if (!pairing) onDismiss() })
GamepadNavEffect2D(
active = !pairing,
onDirection = { dir ->
when (dir) {
NavDir.LEFT -> if (slot > 0) slot--
NavDir.RIGHT -> if (slot < 4) slot++
NavDir.UP -> if (slot < 4) digits[slot] = (digits[slot] + 1) % 10
NavDir.DOWN -> if (slot < 4) digits[slot] = (digits[slot] + 9) % 10
}
},
onActivate = { if (slot == 4 && identity != null) pair() },
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) {
Column(
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp))
.verticalScroll(rememberScrollState())
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Text(
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
}
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
DialogButton(
label = if (pairing) "Pairing…" else "Pair",
focused = slot == 4 && !pairing,
primary = true,
enabled = !pairing && identity != null,
onClick = { if (identity != null) pair() },
)
}
}
}
@Composable
private fun PinSlot(value: Int, focused: Boolean) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(12.dp)
Box(
Modifier.size(54.dp, 66.dp).clip(shape)
.background(if (focused) ink.accent(0.20f) else ink.glass)
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
contentAlignment = Alignment.Center,
) {
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
}
}
@@ -0,0 +1,336 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.rememberPagerState
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.Lock
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
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.util.lerp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.security.KnownHost
import kotlin.math.absoluteValue
import kotlinx.coroutines.launch
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
// "10-foot" console-style host launcher shown INSTEAD of the touch grid while the console UI is
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
class HomeTile(
val id: String,
val title: String,
val subtitle: String,
val filled: Boolean = false, // saved (solid monogram) vs discovered / action (tinted outline)
val online: Boolean = false, // advertising on the LAN right now
val paired: Boolean = false, // pinned identity (shows a lock)
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
}
/**
* The console home. [tiles] is rebuilt by the caller from the live host stores; [onActivate] runs a
* tile's action, [onOpenLibrary]/[onOpenSettings] are the Y/X actions. Fully driven by D-pad / stick
* / face buttons (MainActivity already maps a pad's Acenter, Bback, sticksD-pad) and by touch.
*/
@Composable
fun GamepadHome(
tiles: List<HomeTile>,
libraryEnabled: Boolean,
controllerName: String?,
// False while a sheet/dialog is on top → the carousel stops consuming the pad so the overlay
// can be driven instead.
navActive: Boolean,
onActivate: (HomeTile) -> Unit,
onOpenLibrary: (HomeTile) -> Unit,
onOpenSettings: () -> Unit,
// Up on a saved host opens its options (Wake / Edit / Forget). Only saved tiles carry a knownHost.
onOptions: (HomeTile) -> Unit = {},
) {
// Equal inset for the pinned title + hint bar, measured from the safe-area edges (so the legend
// sits the same distance from the left and the bottom).
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val pagerState = rememberPagerState(pageCount = { tiles.size })
val scope = rememberCoroutineScope()
// navTarget is the navigation authority — a controller move steps THIS, and the pager is pointed
// at it, so a fast repeat coalesces to the latest target instead of reading a lagging currentPage
// mid-animation (which is what let a flick overshoot by two).
var navTarget by remember { mutableStateOf(0) }
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
val current = tiles.getOrNull(navTarget)
GamepadNavEffect(
active = navActive && tiles.isNotEmpty(),
onMove = { dir ->
val target = (navTarget + dir).coerceIn(0, tiles.lastIndex)
if (target != navTarget) {
navTarget = target
scope.launch { pagerState.animateScrollToPage(target) }
}
},
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
onSecondary = { // Y (gamepad) → Library
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
},
onTertiary = onOpenSettings, // X (gamepad) → Settings
// A TV remote has no A/B/X/Y: Up → Settings, Down → a saved host's Options (Wake / Library /
// Edit / Forget). A gamepad instead opens Options on its Select/View button.
onUp = onOpenSettings,
onDown = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
onOptions = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
)
// The legend follows the LAST-USED input: a real gamepad shows its A/X/Y face buttons + the
// Select/View button for Options; a TV D-pad remote (no face buttons) shows a select ring + Up
// (Settings) / Down (Options) arrows, with Library folded into Options. Input is universal either
// way. Each hint is also TAPPABLE (touch hatch).
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
val optionsAction: () -> Unit = { current?.let(onOptions) }
val arrowTint = Color(0xFF9A93C7)
val hints = buildList {
if (padIsGamepad) {
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
if (libraryEnabled && current?.hasLibrary == true) add(PadGlyph.hint('Y', "Library") {
tiles.getOrNull(navTarget)?.takeIf { it.hasLibrary }?.let(onOpenLibrary)
})
add(PadGlyph.hint('X', "Settings", onClick = onOpenSettings))
// The pad's Select/View button (drawn as its capsule glyph) opens host options.
if (current?.knownHost != null) add(GamepadHint(' ', arrowTint, "Options", onClick = optionsAction, viewButton = true))
} else {
add(GamepadHint(' ', PadGlyph.A, connectLabel, onClick = connectAction, select = true))
add(GamepadHint('↑', arrowTint, "Settings", onClick = { onOpenSettings() }))
if (current?.knownHost != null) add(GamepadHint('↓', arrowTint, "Options", onClick = optionsAction))
}
}
val hazeState = remember { HazeState() }
Box(Modifier.fillMaxSize()) {
// The whole backdrop (aurora + carousel) is the haze source, so the floating legend can blur
// whatever scrolls under it.
BoxWithConstraints(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
// Carousel centred on the FULL screen — the title + legend FLOAT over it (below), so they
// no longer push the cards below the true centre.
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
Box(Modifier.fillMaxSize().systemBarsPadding()) {
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(cardWidth),
contentPadding = PaddingValues(horizontal = sidePad),
pageSpacing = 22.dp,
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
) { page ->
val tile = tiles[page]
// Real distance-from-centered (page + fractional drag), so the pop tracks the
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
.absoluteValue.coerceIn(0f, 1f)
GamepadHostTile(
tile = tile,
modifier = Modifier
.graphicsLayer {
val s = lerp(1f, 0.86f, offset)
scaleX = s
scaleY = s
alpha = lerp(1f, 0.5f, offset)
}
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
// (the cut-off edge). No-op below API 31; a soft blur above.
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
.height(cardHeight)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
if (page == navTarget) {
onActivate(tile)
} else {
navTarget = page
scope.launch { pagerState.animateScrollToPage(page) }
}
},
)
}
}
}
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
// the shared ConsoleHeader so it lines up with every other screen's heading.
Row(
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
.padding(end = ConsoleEdgeInset),
verticalAlignment = Alignment.CenterVertically,
) {
ConsoleHeader("Select a Host", modifier = Modifier.weight(1f))
if (controllerName != null) ControllerStatusChip(controllerName)
}
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
Box(
Modifier
.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
GamepadHintBar(hints, hazeState = hazeState)
}
}
}
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
@Composable
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(26.dp)
val wash = if (tile.filled) {
Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A)))
} else {
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
}
Column(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(wash)
.border(1.dp, ink.fg(0.16f), shape)
.padding(22.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
MonogramBadge(tile)
Spacer(Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
if (tile.paired) {
Icon(
Icons.Filled.Lock,
contentDescription = "Paired",
tint = ink.fg(0.7f),
modifier = Modifier.padding(end = 6.dp).size(15.dp),
)
}
if (tile.online) {
Box(
Modifier.size(10.dp).clip(androidx.compose.foundation.shape.CircleShape)
.background(Color(0xFF3CD070)),
)
}
}
}
Spacer(Modifier.weight(1f))
Text(
tile.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
tile.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun MonogramBadge(tile: HomeTile) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(15.dp)
val fill = if (tile.filled) {
Brush.verticalGradient(listOf(ink.accent, ink.accent))
} else {
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
}
Box(
modifier = Modifier.size(52.dp).clip(shape).background(fill),
contentAlignment = Alignment.Center,
) {
when {
tile.connecting -> CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = ink.fg,
)
tile.isAdd -> Icon(
Icons.Filled.Add,
contentDescription = null,
tint = if (tile.filled) ink.fg else ink.accent,
)
else -> Text(
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = if (tile.filled) ink.fg else ink.accent,
)
}
}
}
@@ -32,31 +32,6 @@ class GamepadInk(
val shadeScale: Float,
/** True when the field is pale, for the few places that branch rather than blend. */
val isLight: Boolean,
/**
* The near-opaque ground a MODAL card sits on. A dialog can't be glass: it has to occlude the
* screen it covers, and it carries [fg] text which is why this must follow the palette. It
* was a hardcoded near-black indigo, so on a pale palette the card's dark ink landed on a dark
* card and the dialogs were unreadable.
*/
val card: Color,
/**
* What dims the screen BEHIND a modal. Always dark, whatever the field: a scrim's job is to
* push the backdrop down, and a pale field lit with more white doesn't recede it glares. A
* pale one needs less of it, because it has further to fall.
*/
val modalScrim: Color,
/**
* The light a glass surface catches along its top edge. White either way a highlight is a
* specular, not a tint but a pale field's frost is already bright, so it takes MORE to read
* as an edge against the pastel showing through it.
*/
val highlight: Color,
/**
* What a failure says itself in the pairing error, and anything else the console has to
* refuse in words. Follows the palette because it lands on [card], not on the field: the salmon
* that reads on a dark modal is washed out on a near-white one.
*/
val danger: Color,
) {
/** The foreground at [alpha]. */
fun fg(alpha: Float): Color = fg.copy(alpha = alpha)
@@ -75,7 +50,6 @@ class GamepadInk(
val accentLuma =
0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third
val onAccent = if (accentLuma > 0.55) Color.Black else Color.White
val (gr, gg, gb) = p.ground
if (!p.light) {
return GamepadInk(
fg = Color.White,
@@ -85,20 +59,9 @@ class GamepadInk(
shade = Color.Black,
shadeScale = 1f,
isLight = false,
// The palette's own ground, lifted just off it so the card reads as a surface
// ABOVE the field rather than a hole in it. For the brand violet that lands on
// the #1A1730 the dialogs were hardcoded to, which is where the number came from.
card = Color(
(gr + 0.030).toFloat().coerceAtMost(1f),
(gg + 0.030).toFloat().coerceAtMost(1f),
(gb + 0.040).toFloat().coerceAtMost(1f),
0.94f,
),
modalScrim = Color.Black.copy(alpha = 0.62f),
highlight = Color.White.copy(alpha = 0.30f),
danger = Color(0xFFE0736F),
)
}
val (gr, gg, gb) = p.ground
return GamepadInk(
// Tinted toward the palette's own ground so it doesn't read as a foreign grey.
fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()),
@@ -110,15 +73,6 @@ class GamepadInk(
shade = Color.White,
shadeScale = 0.45f,
isLight = true,
// Near-white rather than near-black: the card carries this palette's DARK ink.
card = Color.White.copy(alpha = 0.94f),
// Lighter than the dark field's: a pastel backdrop is closer to the card already,
// so the same 0.62 would read as a bruise rather than a recession.
modalScrim = Color.Black.copy(alpha = 0.38f),
highlight = Color.White.copy(alpha = 0.55f),
// Deepened for the near-white card the pale palettes' modals use — the dark
// field's salmon has nothing like enough contrast against it.
danger = Color(0xFFB3352F),
)
}
@@ -65,10 +65,6 @@ fun GamepadNavEffect(
) {
val activity = LocalContext.current as? MainActivity ?: return
val state = remember { NavInputState() }
// Menu feel, inherited by every console screen that navigates through here rather than wired
// per screen: a tick as the cursor steps, a pulse on confirm. Renders on the driving pad's own
// motors, the phone body if it has none, and nothing at all on a TV.
val haptics by rememberUpdatedState(rememberConsoleHaptics())
// The effects below are keyed on `active` only (they must NOT restart on every recomposition), so
// they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are
// discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the
@@ -82,9 +78,8 @@ fun GamepadNavEffect(
val currentOnOptions by rememberUpdatedState(onOptions)
DisposableEffect(active) {
// One entry on the MainActivity probe stack (see GamepadNavEffect2D), removed by identity on
// dispose — a cross-fading-out screen must take only its OWN claim, never the incoming
// screen's, and never the console shell's underneath.
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
@@ -103,10 +98,7 @@ fun GamepadNavEffect(
KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true }
KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true }
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
if (edge) { haptics.confirm(); currentOnActivate() }
true
}
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
// The gamepad Select / View / Share button → context options (a remote uses Down).
KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true }
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
@@ -114,10 +106,13 @@ fun GamepadNavEffect(
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
}
}
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
probes?.let { activity.pushPadProbes(it) }
if (active) {
activity.padMotionProbe = motionProbe
activity.padKeyProbe = keyProbe
}
onDispose {
probes?.let { activity.removePadProbes(it) }
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
state.reset()
}
}
@@ -144,10 +139,8 @@ fun GamepadNavEffect(
}
when {
dir == 0 -> committed = 0
dir != committed -> {
haptics.tick(); currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS
}
now >= fireAt -> { haptics.tick(); currentOnMove(dir); fireAt = now + REPEAT_MS }
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
}
delay(16)
}
@@ -174,9 +167,6 @@ fun GamepadNavEffect2D(
) {
val activity = LocalContext.current as? MainActivity ?: return
val state = remember { NavInputState() }
// See [GamepadNavEffect] — the same menu feel, so a form screen and a carousel answer a press
// identically.
val haptics by rememberUpdatedState(rememberConsoleHaptics())
val currentOnDirection by rememberUpdatedState(onDirection)
val currentOnActivate by rememberUpdatedState(onActivate)
val currentOnTertiary by rememberUpdatedState(onTertiary)
@@ -184,11 +174,9 @@ fun GamepadNavEffect2D(
val currentOnShoulder by rememberUpdatedState(onShoulder)
DisposableEffect(active) {
// One entry on the MainActivity probe stack, removed by identity on dispose — during a
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
// teardown must take only its own claim. On the console this effect sits OVER the Skia
// shell's probes: pushing (not overwriting) is what lets the shell's pad input resurface
// the moment this screen pops, instead of dying with a nulled slot.
// teardown must not null out the incoming screen's just-installed probes.
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
@@ -208,22 +196,22 @@ fun GamepadNavEffect2D(
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true }
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
if (edge) { haptics.confirm(); currentOnActivate() }
true
}
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) { haptics.tick(); currentOnShoulder(-1) }; true }
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) { haptics.tick(); currentOnShoulder(1) }; true }
KeyEvent.KEYCODE_BUTTON_L1 -> { if (edge) currentOnShoulder(-1); true }
KeyEvent.KEYCODE_BUTTON_R1 -> { if (edge) currentOnShoulder(1); true }
else -> false // B → MainActivity (remapped to BACK → BackHandler)
}
}
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
probes?.let { activity.pushPadProbes(it) }
if (active) {
activity.padMotionProbe = motionProbe
activity.padKeyProbe = keyProbe
}
onDispose {
probes?.let { activity.removePadProbes(it) }
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
state.reset()
}
}
@@ -241,10 +229,8 @@ fun GamepadNavEffect2D(
when {
raw == null && nearCentre -> committed = null
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
raw != committed -> {
haptics.tick(); currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS
}
now >= fireAt -> { haptics.tick(); currentOnDirection(raw); fireAt = now + REPEAT_MS }
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
}
delay(16)
}
@@ -17,21 +17,6 @@ import androidx.compose.ui.graphics.Color
// on every client. Keep the three copies in step: a palette added here without the others is a
// value the other clients silently render as Violet.
/**
* One wandering interior control point of the mesh: [x]/[y] its resting place in unit UV, [amp] how
* far it strays, [sx]/[sy] its per-axis rates in rad·s¹ and [phase] its offset. Its live
* displacement `(amp·sin(t·sx+ph), amp·cos(t·sy+ph·1.3))` drives a bounded domain warp, so the
* bright colour pools drift with it.
*/
class MeshWarpPoint(
val x: Double,
val y: Double,
val amp: Double,
val sx: Double,
val sy: Double,
val phase: Double,
)
/** One background colour family. */
class GamepadPalette(
/** The stored `ui_palette` value ([Settings.uiPalette]). */
@@ -62,29 +47,11 @@ class GamepadPalette(
/** The accent as a Compose colour. */
val accentColor: Color by lazy { color(accent) }
/**
* The 16 mesh colours this palette's field is woven from: the ramp sampled per cell (see
* [CELL_RAMP]), or [MESH_COLORS] verbatim for the brand default the exact rule
* `pf-console-ui`'s `Palette::mesh_colors` follows, so one `ui_palette` value is one field on
* every client. Consumed by the AGSL backdrop on API 33+; the blob field
* ([blobColors]) approximates the same table below that.
*/
val meshColors: List<Triple<Double, Double, Double>> by lazy {
if (stops.isEmpty()) {
MESH_COLORS
} else {
(0..15).map { i ->
ramp(stops, 0.5 * ((i % 4) / 3.0 + (i / 4) / 3.0) + CELL_RAMP[i])
}
}
}
companion object {
/**
* Where each of the 16 mesh cells samples the ramp. The base is the diagonal
* `0.5·(x + y)` top-left the ramp's dark end, bottom-right its bright one and the
* per-cell nudges break the banding a pure diagonal would show. Mirrored from
* `pf-console-ui`'s `CELL_RAMP`.
* Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept
* here so the three ports stay one table even though this client approximates the field
* with blobs.
*/
val CELL_RAMP = listOf(
0.10, -0.06, 0.04, -0.12,
@@ -93,34 +60,6 @@ class GamepadPalette(
-0.10, 0.08, -0.06, 0.12,
)
/**
* The brand default's 16 mesh colours, used verbatim (rather than sampled from a ramp) so
* `violet` stays bit-identical to what every install already sees. Mirrors
* `pf-console-ui`'s `MESH_COLORS`.
*/
val MESH_COLORS = listOf(
Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72),
Triple(0.30, 0.26, 0.74), Triple(0.075, 0.060, 0.160),
Triple(0.42, 0.20, 0.54), Triple(0.49, 0.39, 0.95),
Triple(0.28, 0.31, 0.84), Triple(0.16, 0.26, 0.64),
Triple(0.45, 0.23, 0.60), Triple(0.53, 0.31, 0.75),
Triple(0.35, 0.35, 0.91), Triple(0.19, 0.28, 0.70),
Triple(0.075, 0.060, 0.160), Triple(0.22, 0.18, 0.54),
Triple(0.24, 0.20, 0.58), Triple(0.075, 0.060, 0.160),
)
/**
* The four interior points that wander; the 12 boundary points stay pinned to the frame (a
* drifting edge point would shrink the field and expose the ground behind it). Periods
* ~90130 s, out of phase, so the field never visibly loops. Mirrors `MESH_INTERIOR`.
*/
val MESH_INTERIOR = listOf(
MeshWarpPoint(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
MeshWarpPoint(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
MeshWarpPoint(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
MeshWarpPoint(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
)
/** The brand default's blob ramp — the colours the pre-palette field used. */
private val VIOLET_BLOBS = listOf(
Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80),
@@ -128,7 +67,7 @@ class GamepadPalette(
)
/**
* The thirteen shipped palettes: the brand default, six more dark fields, then six pale
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
* ones. Cycling order runs dark light, so stepping the row walks the range one way.
*/
val ALL = listOf(
@@ -138,26 +77,6 @@ class GamepadPalette(
ground = Triple(0.075, 0.060, 0.160),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
// of the field is genuinely off rather than "very dark grey", and the ground is
// pure black too: the calm mix on the form screens lifts toward nothing. What is
// left is a faint indigo→violet ember in the bright corner. The accent stays the
// brand violet — focus has to be findable on black.
// Named for the look, not the panel technology — black with a thin violet corona
// belongs beside Nebula and Abyss. ⚠ The ID stays "oled": it is the stored
// `ui_palette` value and the cross-client key, so renaming it would orphan saved
// choices and desync the clients.
"oled", "Eclipse",
listOf(
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
Triple(0.120, 0.024, 0.130),
),
ground = Triple(0.0, 0.0, 0.0),
accent = Triple(0.525, 0.471, 0.961), light = false,
),
GamepadPalette(
// Deep indigo climbing through violet into a hot magenta.
"nebula", "Nebula",
@@ -0,0 +1,715 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
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.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
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with
// the touch settings.
//
// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone.
// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant
// walking past Display and Audio to reach the controller settings. The tab names match the desktop
// console's and the Apple client's, so a setting is found under the same word wherever you look.
/**
* The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
* match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`.
*/
enum class GpTab(val title: String) {
STREAM("Stream"),
VIDEO("Video"),
AUDIO("Audio"),
CONTROLLER("Controller"),
INTERFACE("Interface"),
PROFILES("Profiles"),
}
internal class GpRow(
val id: String,
val tab: GpTab,
/**
* A sub-heading above this row, for the few tabs that hold more than one group. Most rows have
* none: the tab pill already names the section, and repeating it would be a second label
* saying the same word.
*/
val header: String?,
val label: String,
val value: String,
val detail: String,
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
)
/**
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
* already-focused row) cannot drift apart before this, `enabled` dimmed the label and nothing
* else, and every dimmed row still stepped its setting.
*/
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
rows.getOrNull(index)?.takeIf { it.enabled }
@Composable
fun GamepadSettingsScreen(
initial: Settings,
onChange: (Settings) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad
) {
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 }
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
// The catalog is read once per screen entry: this screen can't create or edit profiles
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
// on each toggle, keeping the "Pinned to N hosts" counts honest.
val knownHostStore = remember { KnownHostStore(context) }
val profileStore = remember { ProfileStore(context) }
val profiles = remember { profileStore.all() }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
// only: pin appends at the end (card order), unpin removes, and the host's default binding
// (profileId) is never touched.
fun togglePin(kh: KnownHost, profile: StreamProfile) {
val pins = if (profile.id in kh.pinnedProfileIds) {
kh.pinnedProfileIds - profile.id
} else {
kh.pinnedProfileIds + profile.id
}
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
savedHosts = knownHostStore.all()
}
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
// interface remote-navigably. The strings branch on it.
val tv = remember { isTvDevice(context) }
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
// Which section is showing, and where each one's focus was when it was last left — a detour
// into another tab shouldn't lose your place.
var tab by remember { mutableStateOf(GpTab.STREAM) }
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
// shoulder buttons at all (and is exactly what a TV box ships with).
var tabFocused by remember { mutableStateOf(false) }
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
val rows = allRows.filter { it.tab == tab }
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
fun selectTab(next: GpTab) {
if (next == tab) return
tabFocus[tab] = focus
tab = next
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
// outlive the row it pointed at.
focus = (tabFocus[next] ?: 0)
.coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0))
}
fun stepTab(delta: Int) {
val all = GpTab.entries
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
}
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
// value text slides in its AnimatedContent, so the motion matches the button press.
var adjustDir by remember { mutableIntStateOf(1) }
val listState = rememberLazyListState()
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
BackHandler(onBack = onBack)
GamepadNavEffect2D(
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
// drops its probes — the pattern ConnectScreen's dialogs use.
active = navActive && pinProfile == null,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus-- else tabFocused = true
NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++
// On the strip, left/right walks sections; on a row it steps the value. A disabled
// row is INERT, not just dim — the step is refused instead of writing a setting
// that has nothing to act on (see `liveRow`).
NavDir.LEFT ->
if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
NavDir.RIGHT ->
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
}
},
// A on the strip drops into the section you picked, which is what "confirm" means there.
onActivate = {
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
},
// The shoulders work from either place — a real pad never has to visit the strip.
onShoulder = { delta -> stepTab(delta) },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
// +1 accounts for the heading being item 0.
LaunchedEffect(focus, tab) {
runCatching {
val itemIndex = focus + 1
val info = listState.layoutInfo
val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex }
val offScreen = item == null ||
item.offset < info.viewportStartOffset ||
item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend
if (offScreen) listState.animateScrollToItem(itemIndex)
}
}
val hazeState = remember { HazeState() }
Box(Modifier.fillMaxSize()) {
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadFormBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().systemBarsPadding()) {
// The strip is PINNED while the rows scroll under it: it is this screen's primary
// navigation now, and a switcher you have to scroll back up to find isn't one. The
// title stays in the scrolling list (landscape has no height to spare, and the
// selected pill already says which section you are in).
ConsoleTabStrip(
titles = GpTab.entries.map { it.title },
selected = GpTab.entries.indexOf(tab),
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
focused = tabFocused,
)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
item(key = "__title") {
// "Default settings", not "Settings": this screen edits the base layer only. The
// console honours a host's profile but doesn't edit profiles (design §5.4), so a
// bare "Settings" would quietly imply it changes whatever that host streams with.
ConsoleHeader("Default settings", horizontalInset = false)
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(
row,
focused = index == focus && !tabFocused,
adjustDir = adjustDir,
onClick = {
// Same inertness as the pad path above — tapping a dimmed row focuses it
// (so its detail explains itself) but never flips it.
tabFocused = false
if (focus != index) focus = index
else if (row.enabled) { adjustDir = 1; row.activate() }
},
)
}
}
}
}
// Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated
// strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset.
Box(
Modifier
.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
// The legend follows the focused row (the desktop console's hints() does the same):
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
val focused = rows.getOrNull(focus)
// The shoulders always change section, so that cell leads on every row. Tappable too,
// like the others — a user without a working pad can still reach every tab.
// Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up
// into the strip) and a touch user taps a pill, so on those the cell would be both a
// lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
val sections = listOfNotNull(
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
.takeIf { padIsGamepad },
)
GamepadHintBar(
if (tabFocused) listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Section"),
PadGlyph.hint('A', "Open") { tabFocused = false },
PadGlyph.hint('B', "Done", onClick = onBack),
) else sections + when {
focused != null && !focused.enabled -> listOf(
PadGlyph.hint('B', "Done", onClick = onBack),
)
focused != null && !focused.adjustable -> listOf(
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
else -> listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
},
hazeState = hazeState,
)
}
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
// touch UI's per-profile pin toggles in the host edit sheet.
pinProfile?.let { p ->
GamepadPinHostsDialog(
profileName = p.name,
hosts = savedHosts,
pinned = { kh -> p.id in kh.pinnedProfileIds },
onToggle = { kh -> togglePin(kh, p) },
onDismiss = { pinProfile = null },
)
}
}
}
@Composable
private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
val chevronAlpha by animateFloatAsState(
if (focused && row.adjustable) 0.6f else 0f,
tween(160),
label = "chevrons",
)
val valueColor by animateColorAsState(
ink.fg(if (focused) 1f else 0.6f),
tween(160),
label = "valueColor",
)
Column {
if (row.header != null) {
Text(
row.header.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = ink.fg(0.45f),
letterSpacing = 1.4.sp,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 13.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
// so its detail line can still explain what would go here.
color = ink.fg(if (row.enabled) 1f else 0.45f),
maxLines = 1,
)
Spacer(Modifier.weight(1f))
if (row.toggled != null) {
// A toggle is a switch, not text — the sliding knob + tinting track IS the value.
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
// The value slides in the direction it was stepped and its width animates, so
// cycling a choice reads as motion through a list rather than a text swap.
AnimatedContent(
targetState = row.value,
transitionSpec = {
val dir = adjustDir
(slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith
(slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using
SizeTransform(clip = false)
},
label = "value",
) { value ->
Text(
value,
style = MaterialTheme.typography.bodyMedium,
color = valueColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(" ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha })
}
}
// The focused row carries its own one-line description — no dedicated (space-eating)
// detail strip. It unfolds right where you're looking, and the row grows to fit.
AnimatedVisibility(
visible = focused && row.detail.isNotBlank(),
enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)),
exit = fadeOut(tween(90)) + shrinkVertically(tween(150)),
) {
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
color = ink.fg(0.6f),
maxLines = 2,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
/** 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`). Every row declares its [GpTab]; the screen shows one
* tab at a time. */
internal fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
av1Capable: Boolean,
update: (Settings) -> Unit,
): List<GpRow> {
fun <T> choice(
id: String, tab: GpTab, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
): GpRow {
val idx = options.indexOfFirst { it.first == current }
return GpRow(
id, tab, header, label,
value = options.getOrNull(idx)?.second ?: "",
detail = detail,
enabled = enabled,
adjust = { delta ->
if (idx < 0) {
options.firstOrNull()?.let { write(it.first) } != null
} else {
val t = idx + delta
if (t in options.indices) { write(options[t].first); true } else false
}
},
activate = {
val i = if (idx < 0) 0 else (idx + 1) % options.size
options.getOrNull(i)?.let { write(it.first) }
},
)
}
fun toggle(
id: String, tab: GpTab, header: String?, label: String, detail: String,
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
): GpRow = GpRow(
id, tab, header, label,
value = if (value) "On" else "Off",
detail = detail,
enabled = enabled,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
toggled = value,
)
// Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface /
// Profiles), so a setting sits under the same word whichever client you found it on. The ROWS
// stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for
// the sake of symmetry would be parity in name only.
return listOf(
choice(
"resolution", GpTab.STREAM, null, "Resolution",
"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
// selectable here instead of being silently snapped to Native — a pad can keep a
// custom size, it just can't type one.
(if (s.isCustomResolution()) {
listOf((s.width to s.height) to "Custom · ${s.width} × ${s.height}")
} else {
emptyList()
}) + RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl },
s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) },
choice(
"refresh", GpTab.STREAM, null, "Refresh rate",
"Frame rate the host renders and streams at.",
REFRESH_OPTIONS, s.hz,
) { update(s.copy(hz = it)) },
choice(
"bitrate", GpTab.STREAM, null, "Bitrate",
"Automatic uses the host's default. A host's options (Up on its tile) can measure the " +
"link and set an informed value.",
BITRATE_OPTIONS, s.bitrateKbps,
) { update(s.copy(bitrateKbps = it)) },
choice(
"compositor", GpTab.STREAM, "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(
"codec", GpTab.VIDEO, null, "Video codec",
"A preference — the host falls back if it can't encode this one.",
codecOptionsFor(s.codec, av1Capable), s.codec,
) { update(s.copy(codec = it)) },
toggle(
"hdr", GpTab.VIDEO, null, "10-bit HDR",
"HDR10 — engages when the host sends HDR content and this display supports it.",
s.hdrEnabled,
) { update(s.copy(hdrEnabled = it)) },
toggle(
"lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode",
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
s.lowLatencyMode,
) { update(s.copy(lowLatencyMode = it)) },
choice(
"audio", GpTab.AUDIO, null, "Audio channels",
"The speaker layout requested from the host.",
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
) { update(s.copy(audioChannels = it)) },
toggle(
"mic", GpTab.AUDIO, null, "Microphone",
"Send this device's microphone to the host's virtual mic.",
s.micEnabled,
) { update(s.copy(micEnabled = it)) },
toggle(
"echoCancel", GpTab.AUDIO, null, "Echo cancellation",
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
s.echoCancel,
) { update(s.copy(echoCancel = it)) },
toggle(
"padForward", GpTab.CONTROLLER, null, "Forward controllers",
"Send this device's controllers to the host. Turn it off when your controller " +
"already reaches the host another way — USB passthrough such as VirtualHere — " +
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
// Everything below the master switch follows it — dim and inert while nothing is being
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
// the pad rows kept stepping settings that had nothing to act on.
choice(
"padType", GpTab.CONTROLLER, null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
) { update(s.copy(gamepad = it)) },
choice(
"systemButtons", GpTab.CONTROLLER, null, "Guide button",
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
"sends them to the host whenever this device delivers them.",
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
) { update(s.copy(systemButtons = it)) },
choice(
"guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide",
"Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
) { update(s.copy(guideGesture = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
toggle(
"phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone",
"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.
toggle(
"sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough",
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
"it as-is — Steam on the host drives it like the physical pad.",
s.sc2Capture, enabled = s.gamepadForwarding,
) { update(s.copy(sc2Capture = it)) },
// The SC2 row's twin, and missing here until now: the touch settings have carried both
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
toggle(
"dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)",
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
"triggers, lightbar and gyro.",
s.dsCapture, enabled = s.gamepadForwarding,
) { update(s.copy(dsCapture = it)) },
// The palette leads Interface: it is the one row whose effect you can see while you step
// it (the backdrop behind this very list recolours), so it wants to be the first thing
// found in the section.
choice(
"palette", GpTab.INTERFACE, null, "Background",
"The colour family this backdrop drifts through — it changes as you step, so pick by " +
"looking. Appearance only.",
GamepadPalette.ALL.map { it.id to it.name },
GamepadPalette.named(s.uiPalette).id,
) { update(s.copy(uiPalette = it)) },
choice(
"hud", GpTab.INTERFACE, null, "Statistics overlay",
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
"A 3-finger tap cycles the tiers live.",
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
) { update(s.copy(statsVerbosity = it)) },
toggle(
"autoWake", GpTab.INTERFACE, null, "Auto-wake on connect",
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
s.autoWakeEnabled,
) { update(s.copy(autoWakeEnabled = it)) },
toggle(
"library", GpTab.INTERFACE, null, "Game library",
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
)
}
/**
* The trailing Profiles section the Android mirror of the desktop console's (design §5.2a, §5.4):
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
* instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points
* nowhere useful on a touchless device, so the strings name the actual route the
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
*/
private fun buildProfileRows(
profiles: List<StreamProfile>,
savedHosts: List<KnownHost>,
tv: Boolean,
openPinPicker: (StreamProfile) -> Unit,
): List<GpRow> {
val createHint = if (tv) {
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
"and use the standard interface."
} else {
"Profiles are created and edited in the touch interface."
}
if (profiles.isEmpty()) {
return listOf(
GpRow(
id = "noProfiles",
tab = GpTab.PROFILES,
header = null,
label = "No profiles yet",
value = "",
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
"one-press connect cards here. " + createHint,
adjust = { false },
activate = {},
adjustable = false,
enabled = false,
),
)
}
return profiles.map { p ->
// Counted straight off the host records, so it agrees with what the carousel renders.
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
GpRow(
id = "profile:${p.id}",
tab = GpTab.PROFILES,
header = null,
label = p.name,
value = when (pins) {
0 -> "Not pinned"
1 -> "Pinned to 1 host"
else -> "Pinned to $pins hosts"
},
detail = "Pin this profile to a host and it appears as its own card — one press " +
"connects with it. " + createHint,
adjust = { false },
activate = { openPinPicker(p) },
adjustable = false,
)
}
}
@@ -16,35 +16,15 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.kit.Gamepad
/**
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
* the switch meant when it was a lone Boolean.
*/
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
/**
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad for a phone or
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
* always awake.
*/
const val GAMEPAD_UI_ALWAYS = "always"
/**
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
* replace the touch UI the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
* the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached
* OR this is a TV OR the dev [forced] flag). A TV counts unconditionally its remote/gamepad is
* the only input, so it's always the console UI (as long as the setting is on), which is why the
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
* client wrote can never strand this one in a layout it has no way back out of.
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
* flag). A TV counts unconditionally its remote/gamepad is the only input, so it's always the
* console UI (as long as the setting is on).
*/
fun gamepadUiActive(
enabled: Boolean,
mode: String,
controllerConnected: Boolean,
tv: Boolean,
forced: Boolean,
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
enabled && (controllerConnected || tv || forced)
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
fun isTvDevice(context: Context): Boolean {
@@ -1,6 +1,7 @@
package io.unom.punktfunk
import android.content.Context
import android.os.Build
import android.util.Log
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
@@ -41,16 +42,6 @@ suspend fun connectToHost(
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)
// The requested audio format as the two Hello fields — `0`/`0` when the user chose Standard,
// which is what keeps the lossless capability bit OFF (see `audioFormatWire`).
//
// Sent at every channel count, including surround. This used to be clamped to Opus on 5.1/7.1
// because a lossless surround frame did not fit one QUIC datagram, but the frame ladder is
// channel-aware: a 5.1 session simply negotiates a shorter frame (and pays for it in packet
// rate) and 96/24 5.1 fits nothing and is declined. That is the host's decision to make with the
// connection's real datagram size in hand, not one to pre-empt from here with an MTU this side
// never measured.
val (audioRateHz, audioBits) = settings.audioFormatWire()
return withContext(Dispatchers.IO) {
// Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media
// sockets) — must be applied before connect, since sockets are tagged at creation.
@@ -85,19 +76,14 @@ suspend fun connectToHost(
hdrEnabled, multiSlice,
frameParts,
settings.audioChannels,
// The audio format this session asks for. Only ever a request: the host's own gate
// may resolve it back to Opus, and the native side downgrades it first if AAudio on
// this device will not open the rate — a rate the wire has committed to cannot be
// rescued afterwards, so the fallback has to happen before the Hello.
audioRateHz, audioBits,
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
// the soft codec preference (user choice, or the Automatic AV1 rule above) — the
// host resolves the emitted codec from both.
codecBits, preferredCodec, timeoutMs,
launch,
// The host's approval-list / trust-store label for this device — the same
// user-set device name the pairing dialogs offer for nativePair.
deviceName(context),
// Build.MODEL convention the pairing dialogs use for nativePair.
Build.MODEL ?: "Android",
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
@@ -1,52 +0,0 @@
package io.unom.punktfunk
import android.content.Context
// Where the player was in a host's library, so the round trip back from a stream doesn't lose it.
// The Android mirror of the Apple client's `LibraryScrollMemory`.
//
// Leaving a stream re-composes the library screen from scratch — new `remember`s, a new
// `LazyGridState`, a new `PagerState` — so a library of any size came back at the top every time.
// For the loop this screen exists to serve (browse → play → quit → browse), that means
// re-scrolling to the same place on every lap.
//
// The position is remembered as the ID OF THE TITLE the player last opened, not as a scroll offset
// or an index. An offset is meaningless across the things that legitimately change between visits —
// a rotation, a window resize, a foldable unfolding, a host that gained or lost titles, or the
// running-first ordering this screen now applies. A title id survives all of them, and the grid
// turns it back into a position at whatever the current layout is.
/**
* Per-host "last title opened", in `SharedPreferences`.
*
* Small, non-sensitive and worth surviving a process death the app being killed in the background
* while a stream is up is exactly when this is most useful so preferences rather than an in-memory
* cache. One key per host record id, namespaced so nothing else can collide with it.
*/
object LibraryPosition {
private const val PREFS = "punktfunk_library_position"
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/** The title last opened from this host's library, if any is remembered. */
fun last(context: Context, hostId: String): String? =
prefs(context).getString(hostId, null)
/**
* Remember a title as this host's position. Called when one is LAUNCHED, which is the only
* moment the player is definitely leaving the grid for it remembering on mere focus would
* make a scroll past a tile into a decision.
*/
fun remember(context: Context, hostId: String, gameId: String) {
prefs(context).edit().putString(hostId, gameId).apply()
}
/**
* Forget a host's position part of removing the host, so a forgotten host leaves no trace of
* what somebody was playing behind on the device.
*/
fun forget(context: Context, hostId: String) {
prefs(context).edit().remove(hostId).apply()
}
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,8 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -23,130 +19,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
/**
* Open-source licenses: punktfunk's own license (MIT OR Apache-2.0) plus the third-party software
* notices, read from the bundled `THIRD-PARTY-NOTICES.txt` asset (generated by
* scripts/gen-third-party-notices.sh). Reached from [SettingsScreen]; Back returns there.
*
* This is the TOUCH entry point; [ConsoleLicensesScreen] shows the same notices on the console's
* field, where they need a scroll route a controller can actually drive.
*/
@Composable
fun LicensesScreen(onBack: () -> Unit) {
BackHandler(onBack = onBack)
Column(Modifier.fillMaxSize()) {
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
Row(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
}
LicensesBody(
scroll = rememberScrollState(),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 24.dp),
)
}
}
/**
* The notices on the console's field. The reason this exists as its own screen rather than the touch
* one dropped into the shell is the SCROLL: the body is a wall of text with exactly one focusable
* node (the touch screen's back arrow), and Compose scrolls a container only to keep a FOCUSED child
* visible so a controller could reach the first screenful of `THIRD-PARTY-NOTICES.txt` and not one
* line further. Here up/down steps and the shoulders page, driving the scroll state directly.
*
* B closes, as everywhere else; there is nothing on this screen to confirm, so A is not advertised.
*/
@Composable
fun ConsoleLicensesScreen(onBack: () -> Unit, navActive: Boolean = true) {
BackHandler(onBack = onBack)
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val hazeState = remember { HazeState() }
val scroll = rememberScrollState()
val scrollBy = rememberConsoleScroller(scroll)
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
GamepadNavEffect2D(
active = navActive,
onDirection = { dir ->
when (dir) {
NavDir.UP -> scrollBy(-1, false)
NavDir.DOWN -> scrollBy(1, false)
// Left/right are deliberately inert: there is nothing beside this text, and paging
// sideways off a D-pad would be a second, undocumented way to do the shoulders' job.
NavDir.LEFT, NavDir.RIGHT -> {}
}
},
onActivate = {},
onShoulder = { delta -> scrollBy(delta, true) },
)
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
// Calm: this is a screen to read, and a drifting field behind small monospace text is
// the one place the aurora would be actively unhelpful. Full-bleed under the cutout —
// only the content takes the safe area.
GamepadFormBackground(Modifier.fillMaxSize())
// Inked from the palette: the notices carry no colour of their own, so outside a Surface
// they would render in Material's default BLACK content colour over the aurora.
ConsoleInkedTheme {
Column(Modifier.fillMaxSize().consoleSafeArea()) {
LicensesBody(
scroll = scroll,
contentPadding = PaddingValues(
start = ConsoleEdgeInset,
end = ConsoleEdgeInset,
bottom = ConsoleLegendClearance,
),
) {
ConsoleHeader("Open-source licenses", horizontalInset = false)
}
}
}
}
Box(
Modifier
.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
listOfNotNull(
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
// A TV remote has no shoulders — its route is the D-pad, one step at a time.
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
PadGlyph.hint('B', "Close", onClick = onBack),
),
hazeState = hazeState,
)
}
}
}
/**
* The notices themselves, shared by both interfaces the licenses are a legal obligation, so the
* two routes must show the same text rather than two copies that can drift. [heading] is empty for
* the touch screen, which pins its own title row above the scroll.
*/
@Composable
private fun LicensesBody(
scroll: ScrollState,
contentPadding: PaddingValues,
heading: @Composable () -> Unit = {},
) {
val context = LocalContext.current
BackHandler(onBack = onBack)
val notices = remember {
runCatching {
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
@@ -166,40 +52,52 @@ private fun LicensesBody(
}.getOrNull()
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
heading()
if (version != null) {
Text(
"Punktfunk $version",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(Modifier.fillMaxSize()) {
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
Row(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
}
Text(
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
"components below, each under its own license.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
notices,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
if (fontLicense != null) {
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (version != null) {
Text(
"Punktfunk $version",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
"components below, each under its own license.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
fontLicense,
notices,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
if (fontLicense != null) {
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
Text(
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
fontLicense,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
}
}
}
}
@@ -1,34 +0,0 @@
package io.unom.punktfunk
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
// The clipboard half of "Copy link" (design/client-deep-links.md §4/§5), shared by every surface
// that hands a `punktfunk://` URL out: a host card, a pinned card, and a library title. The URL
// each one builds is its own business; whether the platform TOOK it, and what to say about that,
// is the same answer three times over — and getting it wrong in one place is how a menu item ends
// up silently doing nothing on exactly one screen.
/** Put a `punktfunk://` URL on the clipboard. False = no clipboard service, or it refused. */
internal fun putLinkOnClipboard(context: Context, url: String): Boolean {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return false
return runCatching {
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
}.isSuccess
}
/**
* What to tell the user about a copy, or null for "say nothing".
*
* Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is the
* platform's own documented anti-pattern. Below it nothing visible happens at all unless we say
* so a silent menu item reads as a broken one.
*/
internal fun linkCopyMessage(copied: Boolean): String? = when {
copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> null
copied -> "Link copied."
else -> "Couldn't copy the link to the clipboard."
}
@@ -24,7 +24,6 @@ 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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -34,8 +33,6 @@ 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.Sc2BleLink
import io.unom.punktfunk.kit.SessionAccess
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
@@ -44,9 +41,6 @@ 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"
/** Request code for the SC2's Bluetooth grant (see [MainActivity.maybeAskSc2BtPermission]). */
private const val REQ_SC2_BLUETOOTH = 0x5C2B
/**
* 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.
@@ -91,16 +85,6 @@ class MainActivity : ComponentActivity() {
*/
var streamHandle: Long = 0L
/**
* The active session's access-grant mask ([SessionAccess] bits) set with [streamHandle] by
* StreamScreen and kept live by its access poll; back to [SessionAccess.ALL] when the stream
* leaves. Consulted only while streaming: the VK keyboard path below goes inert without
* [SessionAccess.KEYBOARD] (the keys are consumed, not sent the host would drop them, and
* letting them fall through would drive Android navigation under a live stream). Courtesy
* gating; the host enforces regardless.
*/
var streamAccess: Int = SessionAccess.ALL
/**
* Multi-controller router for the active session (built/released by StreamScreen): assigns each
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
@@ -109,29 +93,12 @@ class MainActivity : ComponentActivity() {
var gamepadRouter: GamepadRouter? = null
/**
* One screen's claim on the pad while not streaming: its key/motion observers, consulted for
* every event before the focus-navigation fallbacks below; a `true` return consumes the event.
* Holders are the Skia console shell, [GamepadNavEffect2D] on the Compose screens the console
* opens over itself, and the Controllers screen's input test.
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
* Called for every key/motion event while not streaming; a `true` return consumes the event
* the screen's "test inputs" mode uses that to keep pad input from also driving focus navigation.
*/
class PadProbes(val key: (KeyEvent) -> Boolean, val motion: (MotionEvent) -> Boolean)
/**
* The pad-probe claims, a STACK only the top entry sees events. A single last-writer-wins
* slot is how the console shell used to lose the pad for good: a screen composed over it
* (Controllers/Licenses) overwrote the slot, then nulled it on its way out, and the shell
* whose install effect had no reason to re-run never got it back. Pushing on install and
* removing BY IDENTITY on dispose survives every ordering Compose produces (cross-fades
* compose both screens at once, and dispose is not always LIFO): whatever leaves takes only
* its own entry, and whatever is left on top resumes seeing the pad.
*/
private val padProbes = mutableListOf<PadProbes>()
fun pushPadProbes(p: PadProbes) { padProbes += p }
fun removePadProbes(p: PadProbes) { padProbes.remove(p) }
private val padKeyProbe: ((KeyEvent) -> Boolean)? get() = padProbes.lastOrNull()?.key
private val padMotionProbe: ((MotionEvent) -> Boolean)? get() = padProbes.lastOrNull()?.motion
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
/**
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
@@ -175,16 +142,6 @@ class MainActivity : ComponentActivity() {
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
private set
/**
* The `InputDevice.id` of the controller driving the console UI, or 0 for none. Kept beside
* [lastPadStyle] because the console's menu haptics render on the DRIVING pad's own motors when
* it has any a rumble that comes out of the device you are not holding is worse than none.
* Falls back to the phone body (see `rememberConsoleHaptics`), and to silence on a TV, where
* neither a remote nor the box has an actuator.
*/
var lastPadDeviceId by mutableIntStateOf(0)
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.
@@ -213,9 +170,6 @@ class MainActivity : ComponentActivity() {
private var sc2Receiver: BroadcastReceiver? = null
private var sc2PermissionAsked = false
/** Bluetooth asked once this process — a denial must not re-prompt on every resume. */
private var sc2BtPermissionAsked = 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
@@ -354,8 +308,7 @@ class MainActivity : ComponentActivity() {
* 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, asking for Bluetooth access once if one appears to be attached
* ([maybeAskSc2BtPermission]). Safe to call repeatedly.
* controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly.
*/
fun startSc2MenuNav(forceAsk: Boolean = false) {
if (forceAsk) sc2PermissionAsked = false
@@ -383,46 +336,10 @@ class MainActivity : ComponentActivity() {
),
)
}
dev == null && Sc2BleLink.permissionGranted(this) -> {
dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED -> {
cap.pairedBleAddress()?.let { cap.startBle(it) }
}
dev == null -> maybeAskSc2BtPermission()
}
}
/**
* Ask for Bluetooth access when a BLE-paired SC2 looks like it is attached and we cannot see
* it once per process, and never on a device that shows no sign of owning one.
*
* The permission is the whole reason a Bluetooth SC2 used to go unnoticed: the bonded list and
* `connectGatt` both need it from API 31, nothing in the client had ever requested it, and the
* bonded-list call answers an empty list rather than an error when it is missing so the
* capture stood down silently and the console UI never flipped to its controller layout, while
* the same pad over USB worked (field report, 2026-08-15). Asking is gated on
* [Gamepad.sc2InputDevicePresent] because an uncaptured SC2 sits in lizard mode as a
* keyboard/mouse [android.view.InputDevice] visible without any permission at all so the
* prompt reaches the people who have the hardware and nobody else.
*/
private fun maybeAskSc2BtPermission() {
val permission = Sc2BleLink.CONNECT_PERMISSION ?: return // granted at install time here
if (sc2BtPermissionAsked) return
if (!Gamepad.sc2InputDevicePresent()) return
sc2BtPermissionAsked = true
requestPermissions(arrayOf(permission), REQ_SC2_BLUETOOTH)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
// Engage immediately on the grant — the pad is already paired, so there is nothing else to
// wait for and the user just told us what they want it for.
if (requestCode == REQ_SC2_BLUETOOTH &&
grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
) {
startSc2MenuNav()
}
}
@@ -598,25 +515,10 @@ class MainActivity : ComponentActivity() {
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handle = streamHandle
if (handle != 0L) {
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
// Resolved before the gamepad and remote-pointer hooks so neither can claim them as
// its own BACK. See [mouseSideButton] for how a mouse's BACK is told from a pad's or
// a remote's; it answers null for every device that cannot be a mouse, so asking it
// first re-routes nothing else.
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
}
// Gamepad buttons (incl. DPAD only when truly from a gamepad — else KEYCODE_DPAD_* are
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
// why this asks it rather than `buttonBit`).
// keyboard arrows and belong to the VK path below).
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
val bit = Gamepad.buttonBit(event.keyCode)
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
@@ -627,6 +529,17 @@ class MainActivity : ComponentActivity() {
return true // consumed
}
}
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
mouseSideButton(event)?.let { back ->
when (event.action) {
KeyEvent.ACTION_DOWN ->
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
}
return true
}
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
@@ -643,13 +556,12 @@ class MainActivity : ComponentActivity() {
return true
}
when (event.keyCode) {
// Whatever [mouseSideButton] and the pad branch 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 and never gamepad-sourced,
// so it still falls through to the BackHandler and exits — which for a device with
// no pad on it is the documented way out.
// 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
@@ -668,10 +580,6 @@ class MainActivity : ComponentActivity() {
KeyEvent.ACTION_UP -> false
else -> return super.dispatchKeyEvent(event)
}
// Without the KEYBOARD grant the key path is inert: consumed (so nothing
// drives Android navigation under the stream) but never sent — the host
// would drop it, and the Access chip is what says why. Courtesy gating.
if (streamAccess and SessionAccess.KEYBOARD == 0) return true
// Full-event overload: evdev scancode first (positional under ANY selected
// physical-keyboard layout), keycode fallback — see Keymap docs.
val vk = Keymap.toVk(event)
@@ -698,10 +606,7 @@ class MainActivity : ComponentActivity() {
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (lastPadIsGamepad) {
lastPadStyle = Gamepad.styleFor(event.device)
lastPadDeviceId = event.deviceId
}
if (lastPadIsGamepad) lastPadStyle = Gamepad.styleFor(event.device)
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
@@ -790,7 +695,6 @@ class MainActivity : ComponentActivity() {
if (dir != 0) {
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
lastPadStyle = Gamepad.styleFor(event.device)
lastPadDeviceId = event.deviceId
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
return true
@@ -44,18 +44,6 @@ class MouseForwarder(
var onRequestCapture: (() -> Unit)? = null
var onReleaseCapture: (() -> Unit)? = null
/**
* Whether this session's access includes the POINTER grant ([io.unom.punktfunk.kit.SessionAccess.POINTER])
* seeded from the Welcome, kept live by StreamScreen's access poll. Without it the mouse
* path goes inert: nothing forwards, and the part that matters the pointer is never
* GRABBED, because a captured mouse that moves nothing is the "my mouse does nothing and
* nobody says why" failure the grants UX exists to prevent (the Access chip says why
* instead). Revocation mid-session releases an existing grab (StreamScreen calls [release]).
* Volatile: set on the main thread, read wherever the dispatch path runs.
*/
@Volatile
var pointerGranted: Boolean = true
/** Live capture state, updated from [android.app.Activity.onPointerCaptureChanged]. */
var captured = false
private set
@@ -71,7 +59,6 @@ class MouseForwarder(
/** Uncaptured mouse events on the TOUCH stream (position while a button is down). */
fun onTouchEvent(ev: MotionEvent): Boolean {
if (!pointerGranted) return true // inert: consumed over the stream, nothing forwards
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (captureWanted && !captured && !userReleased) {
@@ -93,7 +80,6 @@ class MouseForwarder(
/** Uncaptured mouse events on the GENERIC stream (hover motion, wheel, button edges). */
fun onGenericMotion(ev: MotionEvent): Boolean {
if (!pointerGranted) return true // inert: consumed over the stream, nothing forwards
when (ev.actionMasked) {
MotionEvent.ACTION_HOVER_MOVE -> sendAbs(ev)
MotionEvent.ACTION_SCROLL -> wheel(ev)
@@ -112,7 +98,6 @@ class MouseForwarder(
* gesture layer is the touchpad story); returning false leaves those to the framework.
*/
fun onCapturedPointer(ev: MotionEvent): Boolean {
if (!pointerGranted) return true // a revocation is racing the release of the grab
if (!ev.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
when (ev.actionMasked) {
MotionEvent.ACTION_MOVE -> {
@@ -146,7 +131,7 @@ class MouseForwarder(
if (captured) {
userReleased = true
onReleaseCapture?.invoke()
} else if (pointerGranted) { // never grab a pointer whose input can't land
} else {
userReleased = false
onRequestCapture?.invoke()
}
@@ -154,7 +139,7 @@ class MouseForwarder(
/** Auto-engage at stream start (setting on + a mouse actually present). */
fun engageFromStart() {
if (pointerGranted && captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
if (captureWanted && !captured && !userReleased && hasPhysicalMouse()) {
onRequestCapture?.invoke()
}
}
@@ -219,9 +204,7 @@ class MouseForwarder(
* 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) {
if (pointerGranted) press(if (back) 4 else 5, down)
}
fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down)
private fun button(actionButton: Int, down: Boolean) {
val b = when (actionButton) {
@@ -37,12 +37,6 @@ data class SettingsOverlay(
val hdrEnabled: Boolean? = null,
val compositor: Int? = null,
val audioChannels: Int? = null,
/**
* The requested audio format ([AUDIO_FORMAT_OPTIONS]'s stored value). Profileable because it
* is about how a HOST is streamed a wired desktop can afford lossless, a phone on a hotspot
* cannot rather than about this device's hardware.
*/
val audioFormat: String? = null,
val micEnabled: Boolean? = null,
val echoCancel: Boolean? = null,
val touchMode: TouchMode? = null,
@@ -79,7 +73,6 @@ data class SettingsOverlay(
hdrEnabled = hdrEnabled ?: base.hdrEnabled,
compositor = compositor ?: base.compositor,
audioChannels = audioChannels ?: base.audioChannels,
audioFormat = audioFormat ?: base.audioFormat,
micEnabled = micEnabled ?: base.micEnabled,
echoCancel = echoCancel ?: base.echoCancel,
touchMode = touchMode ?: base.touchMode,
@@ -117,7 +110,6 @@ data class SettingsOverlay(
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,
audioFormat = if (after.audioFormat != before.audioFormat) after.audioFormat else audioFormat,
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
@@ -149,7 +141,6 @@ data class SettingsOverlay(
"hdr_enabled" -> copy(hdrEnabled = null)
"compositor" -> copy(compositor = null)
"audio_channels" -> copy(audioChannels = null)
"audio_format" -> copy(audioFormat = null)
"mic_enabled" -> copy(micEnabled = null)
"echo_cancel" -> copy(echoCancel = null)
"touch_mode" -> copy(touchMode = null)
@@ -176,7 +167,6 @@ data class SettingsOverlay(
if (hdrEnabled != null) add("hdr_enabled")
if (compositor != null) add("compositor")
if (audioChannels != null) add("audio_channels")
if (audioFormat != null) add("audio_format")
if (micEnabled != null) add("mic_enabled")
if (echoCancel != null) add("echo_cancel")
if (touchMode != null) add("touch_mode")
@@ -211,7 +201,6 @@ data class SettingsOverlay(
hdrEnabled?.let { j.put("hdr_enabled", it) }
compositor?.let { j.put("compositor", it) }
audioChannels?.let { j.put("audio_channels", it) }
audioFormat?.let { j.put("audio_format", it) }
micEnabled?.let { j.put("mic_enabled", it) }
echoCancel?.let { j.put("echo_cancel", it) }
touchMode?.let { j.put("touch_mode", it.name) }
@@ -235,7 +224,7 @@ data class SettingsOverlay(
/** 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", "audio_format", "mic_enabled", "echo_cancel",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
@@ -252,7 +241,6 @@ data class SettingsOverlay(
hdrEnabled = j.optBooleanOrNull("hdr_enabled"),
compositor = j.optIntOrNull("compositor"),
audioChannels = j.optIntOrNull("audio_channels"),
audioFormat = j.optStringOrNull("audio_format"),
micEnabled = j.optBooleanOrNull("mic_enabled"),
echoCancel = j.optBooleanOrNull("echo_cancel"),
touchMode = j.optStringOrNull("touch_mode")
@@ -44,21 +44,6 @@ class RemotePointer(
var active = false
private set
/**
* Whether this session's access includes the POINTER grant StreamScreen keeps it live from
* the access poll. Ungranted, the SELECT long-press stops entering pointer mode (a mode whose
* every action the host would drop; the Access chip says why), and a revocation while the
* mode is on leaves it cleanly ([setGranted]). Everything else passes through untouched,
* exactly as when the mode is off the remote stays a remote.
*/
private var granted = true
/** Update the POINTER grant; revoking while pointer mode is on leaves the mode. Main thread. */
fun setGranted(ok: Boolean) {
granted = ok
if (!ok && active) toggle()
}
private val handler = Handler(Looper.getMainLooper())
private val held = mutableSetOf<Int>() // D-pad keycodes currently down
private var moveAccX = 0f
@@ -184,7 +169,6 @@ class RemotePointer(
}
private fun toggle() {
if (!active && !granted) return // never enter a mode whose input can't land
active = !active
if (!active) {
held.clear()
@@ -62,21 +62,6 @@ data class Settings(
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
/**
* Requested audio format the cross-client `audio_format` key: [AUDIO_FORMAT_OPUS] (the
* default, and byte-for-byte the session every build before the lossless plane ran) or one of
* the lossless rows in [AUDIO_FORMAT_OPTIONS], which span both rate families.
*
* Off by default and deliberately: lossless takes 2.18.5 Mbps off the top of the link,
* OUTSIDE the ABR loop that manages the video budget, against the ~256 kbps Opus it replaces
* so a user has to pick it. Since 2026-08-17 this setting is the ONLY opt-in: the host's half
* (`PUNKTFUNK_AUDIO_HIRES`) defaults ON and is an opt-OUT (`=0`), so this choice is enough on
* any host that has not deliberately turned the plane off.
* A REQUEST, never a fact: the host runs its gate and may answer Opus anyway, and
* the native side downgrades the rate first if THIS device will not open it. What actually
* happened is on the stats HUD, and in logcat's `audio: plane codec= rate=` line.
*/
val audioFormat: String = AUDIO_FORMAT_OPUS,
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
* preference the host emits it when it can, else falls back. AMediaCodec decodes whichever
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
@@ -109,20 +94,11 @@ data class Settings(
val touchMode: TouchMode = TouchMode.TRACKPAD,
/**
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
* gamepad chrome) mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
* gamepad chrome) whenever a controller is connected mirrors the Apple client's
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
*/
val gamepadUiEnabled: Boolean = true,
/**
* When [gamepadUiEnabled] actually takes over the cross-client `gamepad_ui_mode` pair,
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
* is on, which is why both settings screens hide the row when the switch is off. Anything
* unrecognized resolves to `"connected"`. A TV ignores it it is always in console mode.
*/
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
/**
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
@@ -131,10 +107,9 @@ data class Settings(
val libraryEnabled: Boolean = true,
/**
* Which colour family the console (gamepad) UI's living backdrop drifts through the
* cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`,
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See
* [GamepadPalette], whose table and maths mirror the desktop console's and the Apple
* client's under the same names. Presentation only: nothing
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
* desktop console's and the Apple client's under the same names. Presentation only: nothing
* about a stream depends on it, so it is a device preference and never part of a profile.
* An unknown value reads as the default rather than failing a newer client may have shipped
* a palette this build doesn't know.
@@ -183,16 +158,6 @@ data class Settings(
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
*/
val rumbleOnPhone: Boolean = false,
/**
* Opt-in: use this phone's own gyroscope as controller 1's motion when the forwarded pad has
* none of its own for clip-on gamepads without an IMU, where the phone body moves with the
* player's hands. The rumble mirror's sibling, data flowing the other way. Off by default;
* read once per session by StreamScreen (it starts a [io.unom.punktfunk.kit.DeviceGyro] only
* when set), and the mirror stands down by itself whenever wire pad 0 is fed by a capture
* link (USB DualSense / SC2 pads with a real gyro). The toggle is hidden on devices
* without a gyroscope (TVs), where this would be a silent no-op.
*/
val gyroOnPhone: Boolean = false,
/**
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
@@ -310,7 +275,6 @@ class SettingsStore(context: Context) {
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
audioFormat = prefs.getString(K_AUDIO_FORMAT, AUDIO_FORMAT_OPUS) ?: AUDIO_FORMAT_OPUS,
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
@@ -329,8 +293,6 @@ class SettingsStore(context: Context) {
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
?: GAMEPAD_UI_WHEN_CONNECTED,
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
@@ -338,7 +300,6 @@ class SettingsStore(context: Context) {
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
gyroOnPhone = prefs.getBoolean(K_GYRO_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
@@ -366,14 +327,12 @@ class SettingsStore(context: Context) {
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
.putString(K_GUIDE_GESTURE, s.guideGesture)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_AUDIO_FORMAT, s.audioFormat)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putString(K_UI_PALETTE, s.uiPalette)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
@@ -381,7 +340,6 @@ class SettingsStore(context: Context) {
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_GYRO_ON_PHONE, s.gyroOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
@@ -404,7 +362,6 @@ class SettingsStore(context: Context) {
const val K_SYSTEM_BUTTONS = "system_buttons"
const val K_GUIDE_GESTURE = "guide_gesture"
const val K_AUDIO_CH = "audio_channels"
const val K_AUDIO_FORMAT = "audio_format"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
const val K_ECHO_CANCEL = "echo_cancel"
@@ -415,7 +372,6 @@ class SettingsStore(context: Context) {
const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
const val K_LIBRARY = "library_enabled"
const val K_UI_PALETTE = "ui_palette"
@@ -434,7 +390,6 @@ class SettingsStore(context: Context) {
const val K_SMOOTH_BUFFER = "smooth_buffer"
const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_GYRO_ON_PHONE = "gyro_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_PAD_HAPTICS = "pad_haptics"
@@ -709,123 +664,6 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
8 to "7.1 Surround",
)
/** Opus 48 kHz — the default, and byte-for-byte the session every earlier build ran. */
const val AUDIO_FORMAT_OPUS = "opus"
/**
* Bit-exact PCM at 44.1 kHz / 24-bit (~2.1 Mbps). The CD family's base rate: what an ordinary
* Windows endpoint or a 44.1 kHz interface reports as its own engine rate, and the request that
* spares such a host a resample it would otherwise do on the way out.
*/
const val AUDIO_FORMAT_LOSSLESS_441 = "lossless441"
/**
* Bit-exact PCM at 48 kHz / 24-bit (~2.3 Mbps). The honest win even without a hi-res interface:
* no lossy stage at all, and no double resample on a host whose engine already runs at 48 kHz.
*/
const val AUDIO_FORMAT_LOSSLESS_48 = "lossless48"
/** Bit-exact PCM at 88.2 kHz / 24-bit (~4.2 Mbps) — 96 kHz's counterpart in the 44.1 family. */
const val AUDIO_FORMAT_LOSSLESS_882 = "lossless882"
/**
* Bit-exact PCM at 96 kHz / 24-bit (~4.6 Mbps), and only real if the host's capture endpoint
* genuinely runs at 96 kHz the host declines rather than upsampling to meet the request.
*/
const val AUDIO_FORMAT_LOSSLESS_96 = "lossless96"
/**
* Bit-exact PCM at 176.4 kHz / 24-bit **8.5 Mbps**, and the one row far more likely to be
* declined than granted. Three separate things have to go right: the host's bandwidth gate gives
* audio at most a quarter of the video budget, so the session needs ~34 Mbps of video before it
* will even consider it; a stereo frame only fits a QUIC datagram on the ladder's shortest rung
* (1 ms a thousand datagrams a second at ~1 069 B, so the first connection with a smaller
* datagram declines it), and a surround one fits no rung at all; and very few Android outputs will
* open the rate, which the native probe settles before the handshake. Offered because it is
* reachable, not because it is likely the HUD's `audio lossless ` line is what says which
* happened.
*/
const val AUDIO_FORMAT_LOSSLESS_1764 = "lossless1764"
/**
* (stored value, label) for the requested audio format the cross-client table, matching the
* Apple client's `AudioFormatChoice` raw values and the desktop `AUDIO_FORMATS` so a profile
* written on any of them is honoured on the others.
*
* **The stored values are shared VERBATIM and must never be renamed.** A profile carries the key
* through untouched, so a spelling that differs by one character fails in the worst possible way:
* the profile keeps "working" on the other client and silently inherits its global default
* instead. The naming rule is the kHz figure with the decimal point dropped `lossless48`,
* `lossless96`, and for the 44.1 family `lossless441` / `lossless882` / `lossless1764`.
*
* **Both rate families are here now.** They were not: every buffer figure in the shared jitter
* policy used to be `ms × perMs` with `perMs` an INTEGER number of samples per millisecond, which
* made 44 100 44.1 truncate to 44 a silent 2.3 % error in every target, every de-prime fuse
* and every reported buffer depth, and the whole reason the 44.1 family was deferred rather than
* refused (design/hi-res-audio.md §4.1). Core now multiplies before it divides, which is exact at
* every rate, so the deferral is lifted.
*
* A row being offered is not a promise it can be delivered: the host's gate, this device's own
* output, and the path MTU each get a veto, and the ones at the top of the list get vetoed often.
* What actually happened is on the HUD.
*
* Lossless at **16**-bit is deliberately absent at every rate: it spends ~1.41.5 Mbps to sound
* like the transparent 256 kbps Opus it replaces, and it is the one lossless request whose wire
* parameters are indistinguishable from a legacy one. 24-bit is where the plane earns its
* bandwidth.
*/
val AUDIO_FORMAT_OPTIONS = listOf(
AUDIO_FORMAT_OPUS to "Standard (Opus)",
AUDIO_FORMAT_LOSSLESS_441 to "Lossless 44.1 kHz / 24-bit",
AUDIO_FORMAT_LOSSLESS_48 to "Lossless 48 kHz / 24-bit",
AUDIO_FORMAT_LOSSLESS_882 to "Lossless 88.2 kHz / 24-bit",
AUDIO_FORMAT_LOSSLESS_96 to "Lossless 96 kHz / 24-bit",
AUDIO_FORMAT_LOSSLESS_1764 to "Lossless 176.4 kHz / 24-bit",
)
/**
* The `(rateHz, bits)` pair [audioFormat] asks the host for, in `nativeConnect`'s terms.
*
* **Opus is `0`/`0`, the "did not ask" sentinel NOT `48000`/`16`.** Core sets
* `CLIENT_CAP_AUDIO_HIRES` when either field is non-zero, because it keys on *a format was
* specified* rather than *the format differs from the default*: 48 kHz/16-bit is the cheapest
* lossless rung as well as the legacy pair, so the other rule would make it the one rung nobody
* could ask for. Sending `48000`/`16` for a user who chose Standard therefore advertises the
* capability, and the host then hands that user 1.5 Mbps of lossless PCM instead of 256 kbps of
* Opus. This returned that pair until all four clients were compared.
*
* **That bug got worse on 2026-08-17, when the host's `PUNKTFUNK_AUDIO_HIRES` gate went
* default-ON.** It used to need a host whose operator had opted in rare, so a slip here would
* have been survivable and probably unnoticed. The blast radius is now every host that has not
* deliberately opted out, i.e. all of them. The zeroes below are load-bearing.
*
* The zeroes are also what keeps a default `Hello` byte-identical to a pre-lossless one the wire
* encodes an explicit 48 000/16 the same as absent, and the whole difference is the capability bit.
*
* Deriving the pair FROM the stored format is what stops the two ever disagreeing. An unrecognized
* stored value a newer build's, or a corrupted pref resolves to Opus rather than blocking the
* connect.
*
* The rate this returns is only the REQUEST. The native side runs it down a fallback ladder first
* (`session::connect::rate_fallback_ladder`), because AAudio grants an explicitly-asked rate or
* fails the open and never substitutes so a rate this device cannot play must never reach the
* wire.
*/
fun Settings.audioFormatWire(): Pair<Int, Int> = when (audioFormat) {
AUDIO_FORMAT_LOSSLESS_441 -> 44_100 to 24
AUDIO_FORMAT_LOSSLESS_48 -> 48_000 to 24
AUDIO_FORMAT_LOSSLESS_882 -> 88_200 to 24
AUDIO_FORMAT_LOSSLESS_96 -> 96_000 to 24
AUDIO_FORMAT_LOSSLESS_1764 -> 176_400 to 24
else -> AUDIO_FORMAT_WIRE_UNSPECIFIED
}
/**
* The `(rateHz, bits)` that mean "this session is not asking for the lossless plane" see
* [audioFormatWire] for why it is a pair of zeroes rather than the legacy 48 000/16.
*/
val AUDIO_FORMAT_WIRE_UNSPECIFIED = 0 to 0
/**
* (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.
@@ -927,13 +765,6 @@ fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
)
}
/** (stored value, label) for when the console UI takes over the Apple client's table verbatim.
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
val GAMEPAD_UI_MODE_OPTIONS = listOf(
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
GAMEPAD_UI_ALWAYS to "Always",
)
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -77,7 +77,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import io.unom.punktfunk.kit.DeviceGyro
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.security.KnownHostStore
@@ -577,7 +576,7 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
selected = s.statsVerbosity,
field = "stats_verbosity",
caption = "Compact is one line; Detailed adds the decoder and latency breakdown. " +
"A 3-finger tap, or Select + X on a pad, cycles the tiers in-stream.",
"A 3-finger tap cycles the tiers in-stream.",
) { v -> update(s.copy(statsVerbosity = v)) }
}
DeviceScopeOnly {
@@ -592,24 +591,11 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
SettingsGroup("Interface") {
ToggleRow(
title = "Controller-optimized UI",
subtitle = "Swap the touch home for the console home — the host carousel and " +
"gamepad chrome. A TV always uses it.",
subtitle = "Switch to the console home when a controller is connected. A TV " +
"always uses it.",
checked = s.gamepadUiEnabled,
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
)
// Only decides anything while the switch above is on, so it is HIDDEN rather than
// dimmed when it isn't — a picker whose every option changes nothing is worse than
// no picker, and this group is short enough that nothing jumps far.
if (s.gamepadUiEnabled) {
SettingDropdown(
label = "Show it",
options = GAMEPAD_UI_MODE_OPTIONS,
selected = s.gamepadUiMode,
caption = "With a controller: the touch home comes back when the last one " +
"disconnects. Always keeps the console home either way — for a device " +
"that lives docked to a TV.",
) { v -> update(s.copy(gamepadUiMode = v)) }
}
}
}
}
@@ -824,23 +810,6 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
field = "audio_channels",
caption = "Requested from the host; it downmixes if it has fewer.",
) { ch -> update(s.copy(audioChannels = ch)) }
// Offered at every channel count. It used to be hidden on 5.1/7.1, because a lossless
// surround frame did not fit one QUIC datagram at the default MTU — but the frame ladder is
// channel-aware, so a surround session negotiates a shorter frame instead of being refused,
// and only the top of this list genuinely fits nothing. Which rows a given session can
// actually have depends on the host, this device's output and the path MTU, none of which
// this screen knows; the HUD's `audio lossless …` line is what reports the answer.
SettingDropdown(
label = "Audio format",
options = AUDIO_FORMAT_OPTIONS,
selected = s.audioFormat,
field = "audio_format",
caption = "Lossless sends uncompressed audio on top of the video — 2.3 Mbps at " +
"48 kHz, 4.6 at 96, 8.5 at 176.4 — and the top rates are often declined, " +
"surround especially. The host has its own switch and both must be on; " +
"otherwise the session stays on Opus, which is already effectively " +
"transparent. The overlay shows what a session actually got.",
) { f -> update(s.copy(audioFormat = f)) }
ToggleRow(
title = "Microphone",
subtitle = "Feeds this device's microphone to the host",
@@ -880,8 +849,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
field = "gamepad",
enabled = s.gamepadForwarding,
caption = "The virtual pad the host creates. Automatic matches your controller; " +
"every connected one is forwarded as its own player. An X-Box type has no " +
"gyroscope, so pick a DualSense-class one if you want motion.",
"every connected one is forwarded as its own player.",
) { g -> update(s.copy(gamepad = g)) }
SettingDropdown(
label = "Guide button",
@@ -920,18 +888,6 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
)
}
// The rumble mirror's sibling, data flowing the other way: needs a gyroscope to
// mirror FROM — a TV box has none, so the row would be a silent no-op there.
val hasGyroscope = remember { DeviceGyro.available(context) }
if (hasGyroscope) {
ToggleRow(
title = "Gyro from this phone",
subtitle = "When the controller has no gyro, send this phone's motion " +
"sensors as controller 1's",
checked = s.gyroOnPhone,
onCheckedChange = { on -> update(s.copy(gyroOnPhone = on)) },
)
}
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
@@ -962,14 +918,9 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
)
// The one row here that is OFF by default (see Settings.padSpeaker for why), which
// makes a silent pad speaker look exactly like broken hardware — the failure this
// subtitle exists to pre-empt, after it cost a full evening of host-side measuring.
// Say the default out loud rather than describing only what "on" does.
ToggleRow(
title = "Controller speaker",
subtitle = "Play audio the game sends to the controller's own speaker" +
"off by default, so the pad's speaker stays silent until you turn this on",
subtitle = "Play audio the game sends to the controller's own speaker",
checked = s.padSpeaker,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
@@ -18,13 +18,12 @@ import kotlin.math.roundToInt
* The live stats overlay the unified HUD (`design/stats-unification.md`): headline is
* `capturedisplayed` tiled by `host+network` + `decode` + `display` when the platform delivered
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
* `capturedecoded` headline without the `display` term when it didn't. Reads the 38-double
* `capturedecoded` headline without the `display` term when it didn't. Reads the 33-double
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs,
* audioAvOffsetMs, audioCodec, audioRateHz, audioBits]`. Every read
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
* is length-guarded, so an older native lib simply omits the lines it can't feed.
*
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
@@ -45,11 +44,7 @@ import kotlin.math.roundToInt
* reliability counters (1821) when nonzero.
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
* the excluded-floor line when one was measured, and the audio plane's own latency (33/34).
*
* The RESOLVED audio format (3537) is the one figure that is not reserved for
* [StatsVerbosity.DETAILED] it renders from [StatsVerbosity.NORMAL] up, and only on a lossless
* session. See [audioFormatLine]. (Not on COMPACT, which is one line by definition.)
* and the excluded-floor line when one was measured.
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
*/
@@ -183,95 +178,10 @@ internal fun StatsOverlay(
}
}
}
if (detailed) {
audioLine(s)?.let { statLine(it, Color.White) }
}
// NOT gated to the detailed tier, unlike the audio latency above it, and deliberately: it
// is the one thing a user who turned lossless on needs to see. The failure it guards
// against (design/hi-res-audio.md §4.3, §10) is a session that costs 2.18.5 Mbps and
// delivers ordinary Opus, which is indistinguishable from success without a surface naming
// what the HOST resolved. `null` on the Opus plane every ordinary session runs, so the
// common case gains no line at all — and the top of the format menu is declined often
// enough (176.4 kHz fits only the ladder's shortest 1 ms rung, and hi-res surround fits no
// rung at all) that "the setting says one thing" is not evidence of anything.
audioFormatLine(s)?.let { statLine(it, Color(0xFFB0FFD0)) }
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
}
}
/**
* The audio plane's own latency from the live gauges at 33/34 `audio buffer 42 ms · a/v +18 ms`,
* the same wording the desktop HUD uses. `buffer` is how much decoded audio is queued ahead of the
* speaker; `a/v` is where that PUTS it relative to the picture (positive = audio behind). `null`
* before any audio has been queued (buffer 0 audio off, or the ring not yet primed) and on an
* older native layout.
*
* Both terms, not just the depth: a deep ring on a jittery link is correct behaviour the
* underrun-driven floor earned that buffer and only the offset distinguishes it from a ring that
* is simply holding audio late. The offset term is dropped at zero, which is both "aligned" and
* "no measurement yet"; the depth alone is still the triage number, and it is the one that did not
* exist at all before (the plane published nothing any surface could render, so a "the audio delay
* is way too high" report had no instrument behind it).
*
* NOT shaved by [osFloorMs], unlike every video figure above. That shave is a reporting policy
* metrics report what Punktfunk controls but sound has to reach the ear when the light reaches
* the eye, so the sync loop aligns against the RAW capturedisplayed time (see the native
* `DisplayTracker`) and this offset is stated in those same terms. Subtracting the floor here would
* report an alignment the listener is not getting.
*/
private fun audioLine(s: DoubleArray): String? {
if (s.size < 35) return null
val bufferMs = s[33].roundToInt()
if (bufferMs <= 0) return null
val avOffset = s[34].roundToInt()
val avTerm = if (avOffset != 0) " · a/v ${if (avOffset > 0) "+" else ""}$avOffset ms" else ""
return "audio buffer $bufferMs ms$avTerm"
}
/**
* The RESOLVED audio format from 3537 `audio lossless 96 kHz / 24-bit` or `null` on the Opus
* plane and on an older native layout.
*
* Deliberately silent for Opus rather than printing `audio opus 48 kHz`: that is what every
* session has always been, so a line stating it would be noise on the HUD of every user who never
* touched the setting. The line exists for the opposite case, and it is the only surface that can
* answer it: the format the SETTINGS screen shows is what this device REQUESTED, and the host's
* gate can decline every one of them (its own switch is off by default) leaving a session that
* looks, sounds and measures exactly like a granted one. The native side can also have downgraded
* the request before the handshake, if this device's output would not open the rate. Both land
* here as the truth.
*
* `codec` is the wire byte: 0 = Opus on `0xC9`, 2 = lossless PCM on `0xD3` (1 is reserved for a
* FLAC that was measured and not taken).
*
* The rate is rendered in kHz to one decimal when it needs one, because half the ladder does: the
* 44.1 kHz family (44 100 / 88 200 / 176 400) does not divide by a thousand, and printing raw Hz
* for it as this did while the ladder was 48/96 only put the settings menu's "44.1 kHz" next
* to a HUD saying "44100 Hz" and left the reader to decide whether those were the same session.
* The whole point of this line is that it is comparable at a glance with what was asked for.
*
* Built by integer division rather than `"%.1f".format()` deliberately: that formatter renders
* through the default locale and would say "44,1 kHz" on a device set to most of Europe a
* decimal comma where the settings row it is meant to be compared against has a point. Every rate
* this plane carries is a whole number of hundreds of Hz, so the tenths digit is exact.
*/
private fun audioFormatLine(s: DoubleArray): String? {
if (s.size < 38) return null
if (s[35].roundToInt() != AUDIO_CODEC_PCM_WIRE) return null
val rateHz = s[36].roundToInt()
val bits = s[37].roundToInt()
if (rateHz <= 0 || bits <= 0) return null
val khz = if (rateHz % 1000 == 0) {
"${rateHz / 1000} kHz"
} else {
"${rateHz / 1000}.${rateHz % 1000 / 100} kHz"
}
return "audio lossless $khz / $bits-bit"
}
/** `quic::AUDIO_CODEC_PCM` — the `0xD3` lossless plane's wire byte. */
private const val AUDIO_CODEC_PCM_WIRE = 2
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
@Composable
private fun statLine(text: String, color: Color) {
@@ -28,16 +28,21 @@ import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -48,7 +53,7 @@ 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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
@@ -58,18 +63,16 @@ 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.DeviceGyro
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.PadSensors
import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.SessionAccess
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
@@ -102,20 +105,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
}
// The session's access level (the per-client grants of design/per-client-access.md), the
// courtesy mirror of what the host enforces: seeded from the Welcome's advert here, kept live
// by the 1 Hz poll below (the host's AccessUpdate messages fold latest-wins into the native
// state). Full control + permanent — the only state an old host or an old native lib ever
// reports — gates nothing and draws nothing: today's look, unchanged.
val initialAccess = remember(handle) { NativeBridge.nativeAccessState(handle) }
var accessGrants by remember(handle) {
mutableStateOf(initialAccess?.getOrNull(0) ?: SessionAccess.ALL)
}
// Seconds until this session's access expires (0 = permanent), as last reported natively.
var accessRemaining by remember(handle) {
mutableStateOf(initialAccess?.getOrNull(1) ?: 0)
}
// Start mic only if the user enabled it AND granted RECORD_AUDIO (else the AAudio input fails).
val micWanted = micEnabled && ContextCompat.checkSelfPermission(
context,
@@ -135,12 +124,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// it, and survives the same recreate because the composition outlives the surface.
var micMuted by remember(handle) { mutableStateOf(false) }
// Whether a capture is actually RUNNING, not merely wanted — set from surfaceCreated on what
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute chord and
// no chord line in the start banner, rather than an offer to mute a mic nobody is hearing.
// nativeMicActive reports. A device that refused every AAudio input rung gets no mute control
// rather than one that lies about a mic being heard.
var micRunning by remember(handle) { mutableStateOf(false) }
// Transient confirmation of a mic-chord toggle (null = nothing showing). With no standing mic
// element on screen, this is mute's only feedback: a chord has no on-screen state of its own,
// and "did that register?" is exactly the doubt to answer.
// Transient confirmation of a mic-chord toggle (null = nothing showing). Only the gamepad path
// needs it: the touch button confirms itself by changing under the finger, but a chord has no
// on-screen state of its own, and "did that register?" is exactly the doubt to answer.
var micHint by remember { mutableStateOf<String?>(null) }
LaunchedEffect(micHint) {
if (micHint != null) {
@@ -148,87 +137,16 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
micHint = null
}
}
// A captured pad has a gyro this session's virtual controller cannot carry (see
// GamepadRouter.onMotionUnreachable). Shown briefly, then gone: the failure is otherwise
// completely silent — the gyro simply does nothing, which from the couch is indistinguishable
// from a broken sensor — and the fix is a setting, so the notice has to name it.
var motionHint by remember { mutableStateOf(false) }
LaunchedEffect(motionHint) {
if (motionHint) {
// Longer than the mic chord's 1.6 s: that one confirms something the user just did,
// this one explains something they did not, in a sentence they have to read.
delay(6000)
motionHint = false
}
}
// Whether this session has a controller — the start banner names pad chords only when there is
// a pad to press them on. Seeded from the router the moment it is built (it opens a slot for
// every already-connected controller) and latched true by a pad that arrives later; it never
// goes back to false. A pad LEAVING inside the banner's six seconds is not worth the write:
// teardown closes every slot, and poking Compose state from there is exactly what the nulled
// callbacks in onDispose avoid. The latch is also what carries a pad through a USB capture
// claiming it — its InputDevice slot closes and reopens as a capture-link one.
var padPresent by remember(handle) { mutableStateOf(false) }
// The start-of-stream banner: what this session's shortcuts ARE, said once. A stream takes the
// whole screen and answers to none of the device's usual gestures, so it has to say how to get
// back out — the desktop console draws the same pill for the same reason
// (`pf-console-ui/src/skia_overlay.rs`, BANNER_S = 6 s with a BANNER_FADE_S = 0.6 s tail).
// Two states because the fade and the removal are different moments: `bannerUp` composes the
// pill at all, `bannerFading` runs its alpha down over the last 600 ms.
var bannerUp by remember(handle) { mutableStateOf(true) }
var bannerFading by remember(handle) { mutableStateOf(false) }
val bannerAlpha by animateFloatAsState(
targetValue = if (bannerFading) 0f else 1f,
// Linear, like the desktop's (BANNER_S - age) / BANNER_FADE_S ramp — Compose's default
// easing would hold near-opaque and then drop, which reads as a glitch rather than a fade.
animationSpec = tween(600, easing = LinearEasing),
label = "streamStartBanner",
)
LaunchedEffect(handle) {
delay(5400) // 6 s the 0.6 s tail: fully opaque until here, exactly as on the desktop
bannerFading = true
delay(600)
bannerUp = false // stop composing it once it is invisible
}
// The one place mute is toggled — Compose state + the native flag, always together.
val setMicMuted = { muted: Boolean ->
micMuted = muted
NativeBridge.nativeSetMicMuted(handle, muted)
}
// Push a grant mask into every gate that consults one — called at session start (once the
// router/forwarders exist) and again whenever the poll sees the mask change (an AccessUpdate
// revoked or restored something mid-session). A lambda, deliberately not a local fun — this
// codebase has been burned by `::localFun` references in composable scopes. The gates it does
// NOT reach (the Compose-side ones — the touch layer, the IME summon, the banner line, the
// chip) key on `accessGrants` directly and re-run on the state write.
val applyAccess: (Int) -> Unit = { grants ->
activity?.streamAccess = grants
activity?.gamepadRouter?.gamepadGranted = grants and SessionAccess.GAMEPAD != 0
val pointerOk = grants and SessionAccess.POINTER != 0
activity?.mouseForwarder?.let { m ->
m.pointerGranted = pointerOk
// A revocation must also let an existing grab go (and lift held buttons): a captured
// mouse that moves nothing reads as a broken mouse, not a spectator session.
if (!pointerOk) m.release()
}
activity?.remotePointer?.setGranted(pointerOk)
// Mic revoked mid-session: stop the capture — the host detaches its end regardless, and
// an open mic (with the platform's recording indicator lit) feeding a plane the host
// drops would be the worst kind of lie. Not restarted on a re-grant: the host attaches
// the mic service at session setup only, so a fresh session is the honest offer.
if (grants and SessionAccess.MIC == 0 && micRunning) {
releaseMicEffects(micEffects)
NativeBridge.nativeStopMic(handle)
micRunning = false
}
}
// Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline:
// the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per
// frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets
// the native window, so re-showing never renders stale data. A 3-finger tap — or the Select + X
// pad chord, which is the only route a TV or a passthrough-touch session has — cycles the
// the native window, so re-showing never renders stale data. A 3-finger tap cycles the
// verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from
// 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
@@ -251,11 +169,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
// A screen with fingers on it — the start banner may only name the three-finger stats tap on a
// device that can perform it. A TV box has no touchscreen at all, and its remote is not one.
val hasTouch = remember {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
}
LaunchedEffect(handle, statsOn) {
NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn)
if (statsOn) {
@@ -285,62 +198,22 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// open, so this fires only on a genuinely dead peer, never a false positive. Keyed on `handle`, so
// it stops the moment we navigate away (the handle is only freed later, in onDispose).
LaunchedEffect(handle) {
var lastAccessSeq = initialAccess?.getOrNull(2) ?: 0
while (true) {
delay(1000)
// Access first, ended second: a session about to close on its expiry gets its final
// countdown read, which is what lets the ended branch word that close honestly.
NativeBridge.nativeAccessState(handle)?.let { st ->
val grants = st.getOrNull(0) ?: SessionAccess.ALL
val seq = st.getOrNull(2) ?: 0
if (grants != accessGrants) {
accessGrants = grants
applyAccess(grants)
}
accessRemaining = st.getOrNull(1) ?: 0
if (seq != lastAccessSeq) {
lastAccessSeq = seq
// A fresh AccessUpdate close to the deadline is the host's T5 m / T1 m
// courtesy warning — surface it. Grant edits (and a warning's grant echo)
// otherwise just move the chip; a toast per edit would be noise.
if (accessRemaining in 1..330) {
val mins = (accessRemaining + 30) / 60
Toast.makeText(
context,
if (mins <= 1) {
"Access expires in about a minute."
} else {
"Access expires in about $mins minutes."
},
Toast.LENGTH_LONG,
).show()
}
}
}
if (NativeBridge.nativeSessionEnded(handle)) {
// WHY it ended decides what the user is told. This used to show the "host may be
// asleep" line for EVERY ending — including a game the player had just quit and a
// session the host ended on purpose — which reads as a failure report for
// something nobody did wrong. Only a connection that actually died says that now.
val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle))
when {
// The session died inside the access countdown's final stretch: that IS the
// typed expiry close (ACCESS_EXPIRED), worded with the shared rejection
// sentence rather than the generic host-ended silence. Recognized off the
// countdown because the generic end-reason byte predates the expiry code.
accessRemaining in 1..75 ->
Toast.makeText(
context,
"Your access to this host has expired.",
Toast.LENGTH_LONG,
).show()
reason == SessionEndReason.LOST ->
when (reason) {
SessionEndReason.LOST ->
Toast.makeText(
context,
"Connection lost — the host may be asleep. Wake it to reconnect.",
Toast.LENGTH_LONG,
).show()
reason == SessionEndReason.HOST_ERROR ->
SessionEndReason.HOST_ERROR ->
Toast.makeText(
context,
"The host ended the session with an error.",
@@ -348,7 +221,10 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
).show()
// Deliberate endings — the player quit the game, the host was stopped, or we
// closed it. Leaving the stream IS the feedback; a toast would only add noise.
else -> {}
SessionEndReason.GAME_EXITED,
SessionEndReason.HOST_ENDED,
SessionEndReason.LOCAL,
SessionEndReason.NONE -> {}
}
onSessionEnded(reason)
return@LaunchedEffect
@@ -419,8 +295,10 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(true)
}
// System bars: NOT hidden here — App.kt owns hide/show (one owner; the AnimatedContent
// handoff broke per-screen ownership, see the `immersive` effect there).
controller?.let {
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.
@@ -469,9 +347,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(),
)
activity?.gamepadRouter = router
// Every controller that was already connected got a slot in the router's constructor, so
// this is the session's pad answer at t=0 — what the start banner's words are chosen from.
padPresent = router.forwardedDevices().isNotEmpty()
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
// (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream
@@ -481,12 +356,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// 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 }
// Select + Y toggles the mic — with no on-screen mute element, this chord is the whole of
// the control. Ignored when no capture is running (there is nothing to mute, and a hint
// saying "Microphone muted" over a mic nobody opened would be a lie).
// A captured Sony pad whose motion this session cannot carry. Fires once per pad, at the
// moment it is claimed, on the main thread.
router.onMotionUnreachable = { motionHint = true }
// Select + Y toggles the mic — the couch reach for the on-screen mute button, which a
// gamepad/TV user has no pointer for. Ignored when no capture is running (there is nothing
// to mute, and claiming otherwise would be the lie the control exists to avoid).
router.onMicChord = {
if (micRunning) {
val next = !micMuted
@@ -494,11 +366,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
micHint = if (next) "Microphone muted" else "Microphone live"
}
}
// Select + X steps the stats overlay one tier — the same live cycle the three-finger tap
// performs, and the ONLY route to it on a TV or in a passthrough-touch session. Session-
// local on purpose: this mirrors the tap exactly (`onCycleStats` below), and the settings
// row calls it a live cycle — the stored default is what the next stream starts from.
router.onStatsChord = { statsVerbosity = statsVerbosity.next() }
// 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
@@ -542,32 +409,15 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
handle,
surfaceWidth = { videoView?.width?.takeIf { it > 0 } ?: decor?.width ?: 1920 },
onActiveChanged = { on -> remotePointerOn = on },
// The toggle TYPES — summoning also needs the KEYBOARD grant (hiding is free).
onKeyboardToggle = {
keyCapture?.let { v ->
if (v.imeShown || accessGrants and SessionAccess.KEYBOARD != 0) {
v.setImeVisible(!v.imeShown)
}
}
},
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
)
} else {
null
}
activity?.remotePointer = remote
// Everything the grant gates hang off now exists — apply the session's access level once
// up front (the poll only re-applies on change, and a restricted session is restricted
// from its first event, not from its first poll).
applyAccess(accessGrants)
// Shared clipboard (text v1): only when the user setting is on AND the session's access
// includes the clipboard AND the host has a working clipboard service. Ungranted, the
// host's policy resolution declines everything anyway (grants AND into it); not starting
// the sync is the client-side mirror — no offers announced, no poll thread for a plane
// that cannot move. Applied at session start only, like the host's own coordinator gate.
val clip = if (session.clipboardSync &&
accessGrants and SessionAccess.CLIPBOARD != 0 &&
NativeBridge.nativeClipSupported(handle)
) {
// 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
@@ -605,44 +455,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
router,
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
).also { it.start() }
// "Gyro from this phone" (opt-in): this device's IMU speaks for controller 1's motion
// while wire pad 0 is a controller without a gyro of its own — the rumble mirror's
// sibling, data flowing the other way. The mirror gates itself per sample (it stands
// down whenever pad 0's controller has motion of its own — a capture link below, or a
// pad whose own sensors PadSensors is reading), so it composes without coordination here.
val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) {
DeviceGyro(context, handle, router).also { it.start() }
} else {
null
}
// A Bluetooth controller's OWN gyro, through the platform sensor framework (API 31+):
// a BT DualSense / DS4 / Switch Pro / 8BitDo is an ordinary InputDevice, so none of the
// capture links below ever sees it and its motion used to go nowhere at all. No separate
// setting — this is the pad's own IMU doing what the pad is for, and unlike the USB
// captures it claims nothing; forwarding being off is the only thing that silences it.
val padSensors = if (initialSettings.gamepadForwarding) {
PadSensors(router).also { it.start() }
} else {
null
}
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
// session leaks until the session ends), and take its sensor listeners off with it — the
// same callback also fires when a USB capture below CLAIMS the pad, which is what keeps
// the claimed pad from being fed motion twice. The router owns hot-plug; the feedback owns
// the binds. Assigned before the captures are constructed, so their claims land on it.
router.onSlotClosed = { deviceId ->
feedback.onDeviceRemoved(deviceId)
padSensors?.onSlotClosed(deviceId)
}
// The other edge: a controller that arrives (or first speaks) mid-session gets its sensors
// read too. The pads already connected were swept by PadSensors.start() above — both run
// on the main thread with nothing between them, so no controller falls through the gap.
router.onSlotOpened = { deviceId ->
padSensors?.onSlotOpened(deviceId)
// A pad that wakes up a second into the stream still deserves the chord banner — the
// desktop rebuilds its banner text every frame for exactly this case.
padPresent = true
}
// 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
@@ -688,11 +503,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
),
)
}
// No USB pad: fall back to a bonded BLE one. The Bluetooth-permission gate lives
// inside pairedBleAddress() (it answers null, and says why, when the grant is
// missing) rather than being restated here — the grant itself is asked for where
// a user can act on it, in the console UI and the Controllers screen.
else -> {
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)
@@ -775,18 +587,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
feedback.onHidRaw = null
feedback.sink = null
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
phoneGyro?.stop() // join the sensor thread + park pad 0's rotation at zero, same ordering rule
// After the mirror, so it cannot resume writing pad 0 in the gap when a pad's own
// sensors let go of it; before the router is released, so the parks still find slots.
padSensors?.stop()
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.onMicChord = null // same: no mute toggle on buttons released during teardown
router.onStatsChord = null // same: no tier cycle on buttons released during teardown
router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown
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.
@@ -796,7 +602,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
activity?.remotePointer = null
decor?.pointerIcon = priorPointerIcon
activity?.streamHandle = 0L
activity?.streamAccess = SessionAccess.ALL // grants are per session, like the handle
activity?.requestStreamExit = null
// Back in the menus: the SC2 (if present) resumes driving the console UI.
activity?.startSc2MenuNav()
@@ -814,6 +619,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
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) {
window?.setPreferMinimalPostProcessing(false)
@@ -912,20 +718,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
// The SurfaceView's on-screen pixel size — the coordinate space the
// ASurfaceControl layer composites in (the aspect-fitted video rect,
// not the window's rotated buffer geometry). 0 if not laid out yet;
// native falls back to the window buffer size.
this@apply.width,
this@apply.height,
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
// The MIC grant is read live (a surface recreate re-runs this, and
// the mask may have changed since the last one): without it no
// capture opens — the host never attached this session to its mic
// service, so the platform's recording indicator would announce a
// mic nobody can hear.
if (micWanted && accessGrants and SessionAccess.MIC != 0) {
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
if (micWanted) {
val sessionId =
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
if (initialSettings.echoCancel) {
@@ -989,22 +784,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
)
}
}
// The Access chip — what this session is allowed to do, said in the preset vocabulary
// ("Controller only · 1 h 58 m left"), standing for the whole stream. Full control with
// no expiry — every session against an old host, and most against a new one — shows
// NOTHING: the chip exists for the sessions where input silently not landing needs an
// explanation, not as new chrome on everyone's stream. TopEnd, in the shared pill family
// (TopStart is the HUD's, TopCentre the transient cues', BottomCentre the banner's).
val accessChip = when {
accessGrants and SessionAccess.ALL == SessionAccess.ALL && accessRemaining == 0 -> null
accessRemaining > 0 ->
"${SessionAccess.label(accessGrants)} · " +
"${SessionAccess.remainingLabel(accessRemaining)} left"
else -> SessionAccess.label(accessGrants)
}
if (accessChip != null) {
AccessChip(accessChip, Modifier.align(Alignment.TopEnd).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.
@@ -1015,47 +794,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
if (remotePointerOn) {
RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
}
// The start banner (desktop parity), naming ONLY the shortcuts this session actually has:
// pad chords when a controller is here, the Back gesture and the three-finger tap when it
// is not. Recomputed rather than captured, because both inputs change under it — a pad can
// wake mid-banner, and `micRunning` only settles once the capture has actually opened.
// Above the video and below the gesture layer: it teaches touches, it must never eat one.
//
// Bottom-centre is the desktop's placement and the only edge left — TopStart is the HUD,
// TopEnd the Access chip, TopCentre the three transient cues — but MotionUnreachableHint
// already owns it, and both of these can be up at t≈0. The banner YIELDS rather than
// stacking or sliding off-centre: the notice reports something broken about THIS session
// and names the setting that fixes it, while the banner repeats shortcuts that will be
// there next stream too. Two pills sharing an edge for six seconds would cost the reader
// both.
if (bannerUp && !motionHint) {
StreamStartBanner(
text = buildList {
if (padPresent) {
add("Hold Select + Start + L1 + R1 to leave")
// Only while a capture is actually running: the chord itself no-ops
// without one, and offering a mute for a mic nobody has is the lie the
// whole control exists to avoid.
if (micRunning) add("Select + Y mic")
add("Select + X stats")
} else {
// No pad: Back is the deliberate exit (gesture, key, or a TV remote's
// button — all land on the same BackHandler).
add("Back leaves the stream")
// The tap lives in the pointer touch models only — passthrough gives every
// finger to the host verbatim — and needs a screen to put three fingers on,
// plus the POINTER grant (without it the gesture layer is not installed).
if (hasTouch && touchMode != TouchMode.TOUCH &&
accessGrants and SessionAccess.POINTER != 0
) {
add("three-finger tap for stats")
}
}
}.joinToString(" · "),
alpha = bannerAlpha,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.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.
@@ -1080,51 +818,41 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
// 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).
// The POINTER grant gates the whole touch/stylus capture layer — "don't capture what
// can't land": ungranted, no gesture handler is installed at all (and no pen lane opens),
// rather than fingers being read into events the host will drop. Keyed on the grant so an
// AccessUpdate flipping it mid-session swaps the layer live.
val pointerOk = accessGrants and SessionAccess.POINTER != 0
val stylus = remember(handle, pointerOk) {
if (pointerOk && NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
val stylus = remember(handle) {
if (NativeBridge.nativeHostSupportsPen(handle)) StylusStream(handle) else null
}
if (stylus != null) {
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
}
Box(
videoFit.pointerInput(handle, touchMode, pointerOk) {
when {
!pointerOk -> {} // no capture — the Access chip is what says why
touchMode == TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
videoFit.pointerInput(handle, touchMode) {
when (touchMode) {
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
else -> streamTouchInput(
handle,
stylus,
trackpad = touchMode == TouchMode.TRACKPAD,
invertScroll = initialSettings.invertScroll,
onCycleStats = { statsVerbosity = statsVerbosity.next() },
// The summon rides the pointer gesture but TYPES — so it also needs the
// KEYBOARD grant (dismissing is always allowed).
onKeyboard = { show ->
if (!show || accessGrants and SessionAccess.KEYBOARD != 0) {
keyCapture?.setImeVisible(show)
}
},
onKeyboard = { show -> keyCapture?.setImeVisible(show) },
)
}
},
)
// No standing mic element here: the in-stream mute control is deliberately absent until the
// on-screen overlay UI lands and can carry it as one of its controls. Mute itself is intact
// — the Select + Y chord toggles it, and the hint below is what confirms the toggle.
// Chord confirmation (gamepad/TV) — mute has no standing indicator, so this is the whole
// of its feedback: a toggle that showed nothing at all would be indistinguishable from one
// that never registered.
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
// Bottom, not top: this can coincide with a mic-chord confirmation or the exit cue, and a
// notice landing on top of one of those would cost the user both.
if (motionHint) {
MotionUnreachableHint(Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp))
// Mic mute, LAST in the stack — the one in-stream control, so unlike the purely visual
// overlays above it has to sit on top of the gesture layer to receive its own taps (it
// costs the stream that small corner of touch area, which is why it exists only while a
// capture actually runs). On TV it is the indicator alone: the Select + Y chord is the
// control there, and a focusable button would fight the game for the D-pad.
if (micRunning && (micMuted || !isTv)) {
MicMuteControl(
muted = micMuted,
onToggle = if (isTv) null else ({ setMicMuted(!micMuted) }),
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
)
}
// Chord confirmation (gamepad/TV) — the counterpart to the button changing under a finger.
micHint?.let { MicChordHint(it, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) }
}
}
@@ -1155,8 +883,47 @@ private fun releaseMicEffects(effects: MutableList<AudioEffect>) {
}
/**
* Transient confirmation that the mic chord (Select + Y) registered. Nothing else on screen says
* *muted* or *un*muted, so this pill carries both "did that press do anything?" is the whole
* The in-stream mic control and its muted indicator, in one element: a dim mic glyph while the
* uplink is live, a red **Muted** badge while it isn't so the state that matters is the loud one,
* readable at couch distance and impossible to mistake for the stream's own picture.
*
* [onToggle] `null` makes it a pure indicator (the TV/gamepad surface, where the Select + Y chord
* is the control); non-null makes the badge itself the touch target. Rendering it at all is the
* caller's decision it means a capture is genuinely running.
*/
@Composable
private fun MicMuteControl(muted: Boolean, onToggle: (() -> Unit)?, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(10.dp)
Row(
modifier = modifier
.clip(shape)
.background(if (muted) Color(0xE0B3261E) else Color.Black.copy(alpha = 0.45f))
.then(if (onToggle != null) Modifier.clickable(onClick = onToggle) else Modifier)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
// Spoken state first, then the action — a talkback user needs to know they are muted
// before they need to know how to stop being muted.
contentDescription = if (muted) {
"Microphone muted. Activate to unmute."
} else {
"Microphone live. Activate to mute."
},
tint = Color.White,
modifier = Modifier.size(20.dp),
)
if (muted) {
Spacer(Modifier.width(6.dp))
Text("Muted", color = Color.White, fontSize = 14.sp)
}
}
}
/**
* Transient confirmation that the mic chord (Select + Y) registered. The badge above already says
* *muted*, but nothing on screen says *un*muted and "did that press do anything?" is the whole
* doubt a chord with no button under the finger creates. Same pill vocabulary as the other
* in-stream cues; the caller clears it after a beat.
*/
@@ -1172,47 +939,6 @@ private fun MicChordHint(text: String, modifier: Modifier = Modifier) {
)
}
/**
* The standing Access chip the session's access level in the preset vocabulary, with the live
* countdown when the grant expires ("Controller only · 1 h 58 m left"). Same pill family as the
* other in-stream overlays, sized down a step because it stands for the whole session rather than
* flashing a moment's confirmation. Only composed when there is something to say: a full-control
* permanent session today's normal shows nothing at all.
*/
@Composable
private fun AccessChip(text: String, modifier: Modifier = Modifier) {
Text(
text,
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 10.dp, vertical = 5.dp),
color = Color.White,
fontSize = 12.sp,
)
}
/**
* "This pad's gyro can't reach the game" shown briefly when a captured controller with motion
* meets a session whose virtual pad has no motion plane (the X-Box classes have no gyro in their
* HID contract, so every sample would be decoded and dropped host-side).
*
* It names the setting because that is the whole point: without it the player has a gyro that
* silently does nothing and no way to tell that from a broken sensor. Not a control the setting
* applies from the next session, so offering to change it here would promise something this stream
* cannot deliver. [GamepadRouter.onMotionUnreachable] raises it.
*/
@Composable
private fun MotionUnreachableHint(modifier: Modifier = Modifier) {
Text(
"Motion won't reach this session — set Controller type to DualSense",
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 "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
@@ -1247,33 +973,6 @@ private fun RemotePointerHint(modifier: Modifier = Modifier) {
)
}
/**
* The start-of-stream banner: the shortcuts this session actually has, in the same pill as every
* other in-stream cue, shown once and then gone. The desktop console draws the identical thing
* bottom-centre (`pf-console-ui/src/skia_overlay.rs` six seconds with a 0.6 s fade), because a
* stream owns the whole screen and answers to none of the device's usual gestures: without a line
* saying how to get back out, the only discoverable exit is force-quitting the app.
*
* [text] and [alpha] are the caller's. Only it knows what this session HAS a pad, a mic, a
* touchscreen and only it owns the timer, which is precisely what a screenshot wants to skip.
* Purely visual: it sits below the gesture layer, takes no touches and is never clickable. Internal
* so the screenshot scene can shoot the real pill instead of a copy of it that drifts.
*/
@Composable
internal fun StreamStartBanner(text: String, alpha: Float, modifier: Modifier = Modifier) {
Text(
text,
// Alpha FIRST: the fade has to take the pill's backdrop with it, and everything after this
// in the chain draws inside the layer it opens.
modifier = modifier
.alpha(alpha)
.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:
@@ -1,99 +0,0 @@
package io.unom.punktfunk.components
// GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.
// Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.
// Per-mark provenance and licensing: assets/launcher-icons/README.md.
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 kotlin.math.max
/**
* The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
* Material ships no brand icons, so this is a curated registry the sibling of [OsIcons],
* which does the equivalent job for the host cards.
*
* Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds
* the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT
* all square, so the builder letterboxes a mark forced into a square box is a squashed mark.
*/
private class LauncherGlyph(
val viewportWidth: Float,
val viewportHeight: Float,
val d: String,
)
private val GLYPHS: Map<String, LauncherGlyph> = mapOf(
"steam" to LauncherGlyph(
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",
),
"lutris" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z",
),
"heroic" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z",
),
"playnite" to LauncherGlyph(
viewportWidth = 1024f,
viewportHeight = 1024f,
d = "M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z",
),
"epic" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z",
),
"gog" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z",
),
"xbox" to LauncherGlyph(
viewportWidth = 512f,
viewportHeight = 512f,
d = "M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z",
),
)
private val CACHE = HashMap<String, ImageVector>()
/**
* The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark
* this build ships no art for the caller then falls back to naming the launcher, which is
* what every launcher tile looked like before the token existed.
*
* Tinted by the caller via `tint`, so one mark serves every palette.
*/
fun launcherIcon(token: String?): ImageVector? {
val glyph = GLYPHS[token ?: return null] ?: return null
return CACHE.getOrPut(token) {
// Square the box and centre the mark in it, so a wide or tall master keeps its aspect
// ratio instead of being stretched to the tile.
val side = max(glyph.viewportWidth, glyph.viewportHeight)
val dx = (side - glyph.viewportWidth) / 2f
val dy = (side - glyph.viewportHeight) / 2f
ImageVector.Builder(
name = "launcher_$token",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = side,
viewportHeight = side,
).apply {
addGroup(translationX = dx, translationY = dy)
addPath(
pathData = PathParser().parsePathString(glyph.d).toNodes(),
fill = SolidColor(Color.White),
)
clearGroup()
}.build()
}
}
@@ -1,386 +0,0 @@
package io.unom.punktfunk.console
import android.view.InputDevice
import io.unom.punktfunk.MouseMode
import io.unom.punktfunk.Settings
import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.StreamProfile
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
import io.unom.punktfunk.kit.library.GameEntry
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.padInfoOf
import org.json.JSONArray
import org.json.JSONObject
/**
* The JSON that crosses into the Skia console written in the console's OWN model shapes
* (`crates/pf-console-ui/src/model.rs` `HostRow`/`WakeStatus`/`PairPhase`, `library.rs`
* `LibraryGame`/`LibraryPhase`, `pf-client-core/src/trust.rs` `Settings`/`KnownHosts`), so there
* is no Android-side mirror type to drift; the Rust structs deserialize these directly.
*/
internal object ConsoleJson {
// ---- host rows (`HostRow`) ------------------------------------------------------------
/** `HostRow.key` — the pinned fingerprint when there is one, else `addr:port` (Rust parity). */
fun rowKey(fpHex: String, address: String, port: Int): String =
if (fpHex.isEmpty()) "$address:$port" else fpHex
private fun profileChip(p: StreamProfile): JSONObject = JSONObject()
.put("id", p.id)
.put("name", p.name)
.put("accent", p.accent ?: JSONObject.NULL)
/**
* The home carousel: saved hosts (name order Android records carry no last-used time),
* each followed by its pinned profile cards, then discovered-but-unsaved hosts. Mirrors
* `clients/session/src/console.rs::rows()` the desktop service's ordering so a
* player who moves between a Deck and a phone finds the same carousel.
*/
fun hostRows(
saved: List<KnownHost>,
discovered: List<DiscoveredHost>,
reachable: Set<String>,
profiles: List<StreamProfile>,
): String {
val out = JSONArray()
fun advertFor(h: KnownHost): DiscoveredHost? = discovered.firstOrNull { d ->
(h.fpHex.isNotEmpty() && d.fingerprint.equals(h.fpHex, ignoreCase = true)) ||
(d.host == h.address && d.port == h.port)
}
for (h in saved.sortedBy { it.name.lowercase() }) {
val key = rowKey(h.fpHex, h.address, h.port)
val advert = advertFor(h)
val online = advert != null || "${h.address}:${h.port}" in reachable
val base = JSONObject()
.put("key", key)
.put("name", h.name.ifBlank { h.address })
.put("addr", h.address)
.put("port", h.port)
.put("fp_hex", h.fpHex)
.put("paired", h.paired)
.put("saved", true)
.put("online", online)
.put("mgmt_port", advert?.mgmtPort ?: h.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", !online && h.mac.isNotEmpty())
.put("clipboard_sync", h.clipboardSync)
.put("last_used", JSONObject.NULL)
.put("os", advert?.os?.takeIf { it.isNotEmpty() } ?: h.os)
.put("pin", JSONObject.NULL)
.put(
"bound_profile",
h.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
?.let(::profileChip) ?: JSONObject.NULL,
)
out.put(base)
// A pinned card shares the primary tile's live state; its key rides the profile id
// behind a NUL (impossible in a fingerprint or `addr:port`) — Rust parity.
for (pid in h.pinnedProfileIds) {
val p = profiles.firstOrNull { it.id == pid } ?: continue
out.put(
JSONObject(base.toString())
.put("key", "$key\u0000${p.id}")
.put("pin", profileChip(p))
.put("bound_profile", JSONObject.NULL),
)
}
}
val extra = discovered.filter { d ->
saved.none { h ->
(h.fpHex.isNotEmpty() && h.fpHex.equals(d.fingerprint, ignoreCase = true)) ||
(h.address == d.host && h.port == d.port)
}
}.sortedBy { it.name.lowercase() }
for (d in extra) {
val fp = d.fingerprint.orEmpty()
out.put(
JSONObject()
.put("key", rowKey(fp, d.host, d.port))
.put("name", d.name.ifBlank { d.host })
.put("addr", d.host)
.put("port", d.port)
.put("fp_hex", fp)
.put("paired", false)
.put("saved", false)
.put("online", true)
.put("mgmt_port", d.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", false)
.put("clipboard_sync", false)
.put("last_used", JSONObject.NULL)
.put("os", d.os)
.put("pin", JSONObject.NULL)
.put("bound_profile", JSONObject.NULL),
)
}
return out.toString()
}
/** One `HostRow` for a console entry (`{"library": <HostRow>}`) — the shelf to open. */
fun hostRow(h: KnownHost, pin: StreamProfile?, profiles: List<StreamProfile>): JSONObject {
val key = rowKey(h.fpHex, h.address, h.port)
return JSONObject()
.put("key", if (pin == null) key else "$key\u0000${pin.id}")
.put("name", h.name.ifBlank { h.address })
.put("addr", h.address)
.put("port", h.port)
.put("fp_hex", h.fpHex)
.put("paired", h.paired)
.put("saved", true)
.put("online", true)
.put("mgmt_port", h.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", false)
.put("clipboard_sync", h.clipboardSync)
.put("last_used", JSONObject.NULL)
.put("os", h.os)
.put("pin", pin?.let(::profileChip) ?: JSONObject.NULL)
.put(
"bound_profile",
if (pin != null) JSONObject.NULL
else h.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
?.let(::profileChip) ?: JSONObject.NULL,
)
}
/** `KnownHosts` (Rust) — only what the console needs to build a link: id, address, fp. */
fun knownHosts(saved: List<KnownHost>): String {
val hosts = JSONArray()
for (h in saved) {
hosts.put(
JSONObject()
.put("name", h.name)
.put("addr", h.address)
.put("port", h.port)
.put("fp_hex", h.fpHex)
.put("paired", h.paired)
.put("id", h.id)
.put("mac", JSONArray(h.mac))
.put("os", h.os)
.put("mgmt_port", h.mgmtPort ?: JSONObject.NULL)
.put("profile_id", h.profileId ?: JSONObject.NULL)
.put("pinned_profiles", JSONArray(h.pinnedProfileIds)),
)
}
return JSONObject().put("hosts", hosts).toString()
}
fun profiles(profiles: List<StreamProfile>): String {
val out = JSONArray()
for (p in profiles) out.put(JSONArray().put(p.id).put(p.name))
return out.toString()
}
// ---- wake / pair ------------------------------------------------------------------------
fun wakeStatus(
key: String,
name: String,
seconds: Int,
timedOut: Boolean,
online: Boolean,
thenConnect: Boolean,
): String = JSONObject()
.put("key", key)
.put("name", name)
.put("seconds", seconds)
.put("timed_out", timedOut)
.put("online", online)
.put("then_connect", thenConnect)
.toString()
fun pairIdle(): String = "\"Idle\""
fun pairBusy(): String = "\"Busy\""
fun pairFailed(msg: String): String = JSONObject().put("Failed", msg).toString()
fun pairPaired(key: String): String =
JSONObject().put("Paired", JSONObject().put("key", key)).toString()
// ---- library ------------------------------------------------------------------------------
/** `[LibraryGame]` from the Kotlin catalog — the desktop service's `to_model` mapping. */
fun libraryGames(games: List<GameEntry>): String {
val out = JSONArray()
for (g in games) {
out.put(
JSONObject()
.put("id", g.id)
.put("title", g.title)
.put("store", g.store)
.put("launcher", g.isLauncher)
.put("icon", g.icon?.takeIf(::validIconToken) ?: "")
.put("platform", g.platform ?: JSONObject.NULL)
.put("running", false),
)
}
return out.toString()
}
/** `GameEntry::icon_token`'s re-validation: lowercase-first, ≤ 32 chars of [a-z0-9-]. */
private fun validIconToken(t: String): Boolean =
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
fun libraryError(title: String, body: String, canRetry: Boolean): String = JSONObject()
.put(
"Error",
JSONObject().put("title", title).put("body", body).put("can_retry", canRetry),
)
.toString()
fun stringArray(items: Collection<String>): String = JSONArray(items).toString()
// ---- pads -------------------------------------------------------------------------------
/**
* `{"label", "pref", "pads": [...]}` the controller chip's text (the driving pad's name),
* the glyph style's pref byte, and one entry per connected pad for the settings rows and the
* console's Connected-controllers screen.
*
* `detail`/`forwarded`/`rumble` come straight from [padInfoOf], the same reader the touch
* Controllers screen renders from: the support answer a user gets must not depend on which
* interface asked, and two readers of `InputDevice` would be two answers waiting to drift.
*/
fun pads(pads: List<InputDevice>, driving: InputDevice?): String {
val arr = JSONArray()
for (d in pads) {
val info = padInfoOf(d)
val entry = JSONObject()
.put("name", d.name)
.put("key", "${d.vendorId}:${d.productId}:${d.name}")
.put("pref", Gamepad.prefFor(d))
.put("steam_virtual", false)
.put("detail", info.detail)
.put("forwarded", info.forwarded)
.put("rumble", info.canRumble)
val battery = if (android.os.Build.VERSION.SDK_INT >= 31) {
val b = d.batteryState
if (b.isPresent && b.capacity >= 0f) {
JSONObject()
.put("percent", (b.capacity * 100f).toInt().coerceIn(0, 100))
.put(
"charging",
b.status == android.os.BatteryManager.BATTERY_STATUS_CHARGING ||
b.status == android.os.BatteryManager.BATTERY_STATUS_FULL,
)
} else null
} else null
entry.put("battery", battery ?: JSONObject.NULL)
arr.put(entry)
}
return JSONObject()
.put("label", driving?.name ?: JSONObject.NULL)
.put("pref", driving?.let { Gamepad.prefFor(it) } ?: JSONObject.NULL)
.put("pads", arr)
.toString()
}
// ---- settings (`trust::Settings`) -------------------------------------------------------
private val GAMEPAD_NAMES = listOf(
"auto", "xbox360", "dualsense", "xboxone", "dualshock4", "steamcontroller", "steamdeck",
"dualsenseedge", "switchpro", "steamcontroller2", "steamcontroller2puck", "xboxelite",
)
private val COMPOSITOR_NAMES = listOf("auto", "kwin", "wlroots", "mutter", "gamescope")
/**
* The console's settings document: [base] is the last snapshot the console saved (it owns
* keys Android has no field for `library_sort`, `library_view`, `reduce_motion`, ), and
* every field Android DOES own is written over it from [s], so the touch UI's edits win.
* `trust::Settings` is `#[serde(default)]`, so a partial document is fine.
*/
fun settings(s: Settings, base: JSONObject?): JSONObject {
val j = base?.let { JSONObject(it.toString()) } ?: JSONObject()
j.put("width", s.width)
j.put("height", s.height)
j.put("refresh_hz", s.hz)
j.put("bitrate_kbps", s.bitrateKbps)
j.put("render_scale", s.renderScale)
j.put("gamepad", GAMEPAD_NAMES.getOrElse(s.gamepad) { "auto" })
j.put("gamepad_forwarding", s.gamepadForwarding)
j.put("system_buttons", s.systemButtons)
j.put("guide_gesture", s.guideGesture)
j.put("compositor", COMPOSITOR_NAMES.getOrElse(s.compositor) { "auto" })
j.put("touch_mode", s.touchMode.name.lowercase())
j.put("mouse_mode", s.mouseMode.storedName)
j.put("mic_enabled", s.micEnabled)
j.put("echo_cancel", s.echoCancel)
j.put("audio_channels", s.audioChannels)
j.put("audio_format", s.audioFormat)
j.put("codec", s.codec)
j.put("hdr_enabled", s.hdrEnabled)
j.put("present_priority", s.presentPriority)
j.put("smooth_buffer", s.smoothBuffer)
j.put("show_stats", s.statsVerbosity != StatsVerbosity.OFF)
j.put("stats_verbosity", s.statsVerbosity.name.lowercase())
j.put("ui_palette", s.uiPalette)
j.put("auto_wake", s.autoWakeEnabled)
j.put("invert_scroll", s.invertScroll)
j.put("pad_haptics", s.padHaptics)
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
// Android-only rows ride `extra` (WP5 gives them RowIds); nothing on the desktop reads them.
val extra = j.optJSONObject("extra") ?: JSONObject()
extra.put("android.low_latency", s.lowLatencyMode)
extra.put("android.rumble_on_phone", s.rumbleOnPhone)
extra.put("android.gyro_on_phone", s.gyroOnPhone)
extra.put("android.sc2_capture", s.sc2Capture)
extra.put("android.ds_capture", s.dsCapture)
extra.put("android.gamepad_ui_mode", s.gamepadUiMode)
extra.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
j.put("extra", extra)
return j
}
/**
* The console saved [j]: fold every key Android owns back into [s]. Unknown values snap to
* the field's current value a newer console's spelling must never corrupt the store.
*/
fun applySettings(s: Settings, j: JSONObject): Settings {
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
val extra = j.optJSONObject("extra") ?: JSONObject()
return s.copy(
width = j.optInt("width", s.width),
height = j.optInt("height", s.height),
hz = j.optInt("refresh_hz", s.hz),
bitrateKbps = j.optInt("bitrate_kbps", s.bitrateKbps),
renderScale = j.optDouble("render_scale", s.renderScale),
gamepad = GAMEPAD_NAMES.indexOf(str("gamepad", "")).takeIf { it >= 0 } ?: s.gamepad,
gamepadForwarding = j.optBoolean("gamepad_forwarding", s.gamepadForwarding),
systemButtons = str("system_buttons", s.systemButtons),
guideGesture = str("guide_gesture", s.guideGesture),
compositor = COMPOSITOR_NAMES.indexOf(str("compositor", "")).takeIf { it >= 0 }
?: s.compositor,
touchMode = TouchMode.entries.firstOrNull { it.name.lowercase() == j.optString("touch_mode") }
?: s.touchMode,
mouseMode = MouseMode.entries.firstOrNull { it.storedName == j.optString("mouse_mode") }
?: s.mouseMode,
micEnabled = j.optBoolean("mic_enabled", s.micEnabled),
echoCancel = j.optBoolean("echo_cancel", s.echoCancel),
audioChannels = j.optInt("audio_channels", s.audioChannels),
audioFormat = str("audio_format", s.audioFormat),
codec = str("codec", s.codec),
hdrEnabled = j.optBoolean("hdr_enabled", s.hdrEnabled),
presentPriority = str("present_priority", s.presentPriority),
smoothBuffer = j.optInt("smooth_buffer", s.smoothBuffer),
statsVerbosity = StatsVerbosity.entries
.firstOrNull { it.name.lowercase() == j.optString("stats_verbosity") }
?: s.statsVerbosity,
uiPalette = str("ui_palette", s.uiPalette),
autoWakeEnabled = j.optBoolean("auto_wake", s.autoWakeEnabled),
invertScroll = j.optBoolean("invert_scroll", s.invertScroll),
padHaptics = j.optBoolean("pad_haptics", s.padHaptics),
padSpeaker = when (j.optString("pad_speaker", "")) {
"pad", "mix" -> true
"off" -> false
else -> s.padSpeaker
},
lowLatencyMode = extra.optBoolean("android.low_latency", s.lowLatencyMode),
rumbleOnPhone = extra.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
gyroOnPhone = extra.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
sc2Capture = extra.optBoolean("android.sc2_capture", s.sc2Capture),
dsCapture = extra.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = extra.optString("android.gamepad_ui_mode", s.gamepadUiMode)
.ifEmpty { s.gamepadUiMode },
gamepadUiEnabled = extra.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
)
}
}
@@ -1,832 +0,0 @@
package io.unom.punktfunk.console
import android.app.ActivityManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.InputDevice
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.unom.punktfunk.CONNECT_TIMEOUT_MS
import io.unom.punktfunk.ConnectErrors
import io.unom.punktfunk.ProfileStore
import io.unom.punktfunk.Settings
import io.unom.punktfunk.SettingsStore
import io.unom.punktfunk.StreamProfile
import io.unom.punktfunk.connectToHost
import io.unom.punktfunk.deviceName
import io.unom.punktfunk.effectiveFor
import io.unom.punktfunk.matches
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.library.LibraryCache
import io.unom.punktfunk.kit.library.LibraryClient
import io.unom.punktfunk.kit.library.LibraryResult
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 java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
/**
* The Skia console (`crates/pf-console-ui`, drawn by native over EGL design
* `android-skia-console-port.md`) as this app holds it: ONE instance for the process, created
* lazily and never torn down while the app lives, so the console's screen stack survives a trip
* through the stream exactly as the desktop's does (the shelf is where you left it when the game
* exits). [SkiaConsoleShell] attaches a surface, the pad probes and the overlays to it while the
* console is on screen; between, it idles parked.
*
* This object is the SERVICE side of the console's model (`ConsoleShared` / `LibraryShared` /
* `ConsoleBus`): it feeds host rows from the trust store + discovery + the reachability probe,
* runs the library fetch/cache/art pipeline, pairing, wake-and-wait, and the settings round-trip,
* and turns the console's own asks (`OverlayAction`) into a connect, a clipboard write, or a
* task-to-back. Everything blocking runs on its own executor; every native call is cheap.
*/
object SkiaConsole {
private const val TAG = "pf.console"
/**
* On-glass triage switch: `adb shell setprop debug.punktfunk.console_backend none` makes the
* app behave as if the native console host were absent (the touch UI fronts everything, a
* controller drives it through Compose focus). Anything else = the console.
*/
private const val BACKEND_PROP = "debug.punktfunk.console_backend"
/** Where the console-owned settings keys (`library_view`, `reduce_motion`, …) persist. */
private const val PREFS = "punktfunk_console_settings"
private var handle = 0L
/**
* False once the console has proven it cannot draw the native create failed, or the render
* thread died (a GL context that never came up, or one Android reclaimed and that would not
* come back). Compose observes it: `App` folds it into the gamepad-UI gate, so the answer to a
* dead console is the touch UI not the gray, never-painted `SurfaceView` the shell would
* otherwise sit on for the rest of the process.
*/
var healthy by mutableStateOf(true)
private set
private var appContext: Context? = null
private val main = Handler(Looper.getMainLooper())
private val ioPool = Executors.newCachedThreadPool { r -> Thread(r, "pf-console-io").apply { isDaemon = true } }
private val artPool = Executors.newFixedThreadPool(3) { r -> Thread(r, "pf-console-art").apply { isDaemon = true } }
private val artHttp by lazy { OkHttpClient() }
private var eventThread: Thread? = null
private val running = AtomicBoolean(false)
// Services.
private lateinit var knownHostStore: KnownHostStore
private lateinit var profileStore: ProfileStore
private lateinit var settingsStore: SettingsStore
private var identity: ClientIdentity? = null
private var discovery: HostDiscovery? = null
private var discovered: List<DiscoveredHost> = emptyList()
private var reachable: Set<String> = emptySet()
private var settings: Settings = Settings()
// What the composable hands us while it is on screen.
private var onConnected: ((ActiveSession) -> Unit)? = null
private var onSettingsChange: ((Settings) -> Unit)? = null
private var onQuit: (() -> Unit)? = null
private var onPlatformScreen: ((String) -> Unit)? = null
private var onPadAction: ((String, String) -> Unit)? = null
private var onPulse: ((String) -> Unit)? = null
/** The connect in flight, if any — cancelable through `OverlayAction::CancelConnect`. */
private class Dial(val cancelled: AtomicBoolean = AtomicBoolean(false))
private var dial: Dial? = null
/** The wake-and-wait loop in flight, if any. */
private var wakeGen = AtomicLong(0)
/** The library fetch in flight (its generation; a newer one supersedes it). */
private val fetchGen = AtomicLong(0)
// ---- availability -------------------------------------------------------------------
/**
* Whether the console can front the gamepad UI on this device: the native host must be in
* this build (every shipping ABI today see `nativeConsoleAvailable`) and the triage sysprop
* must not say `none`.
*/
fun wanted(): Boolean {
val available = runCatching { NativeBridge.nativeConsoleAvailable() }.getOrDefault(false)
if (!available) return false
return backendProp() != "none"
}
private fun backendProp(): String = runCatching {
val cls = Class.forName("android.os.SystemProperties")
cls.getMethod("get", String::class.java, String::class.java)
.invoke(null, BACKEND_PROP, "") as String
}.getOrDefault("").trim().lowercase()
// ---- lifecycle -----------------------------------------------------------------------
/**
* Build the console if it does not exist yet. Idempotent; call from the main thread. Returns
* the native handle (`0` = the console could not be built; the caller keeps the Compose
* console).
*/
fun ensure(context: Context, initial: Settings): Long {
if (handle != 0L) return handle
val app = context.applicationContext
appContext = app
knownHostStore = KnownHostStore(app)
profileStore = ProfileStore(app)
settingsStore = SettingsStore(app)
settings = initial
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val base = prefs.getString("json", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
val profiles = profileStore.all()
val opts = JSONObject()
.put("device_name", deviceName(app))
.put("gpu_cache_bytes", gpuCacheBytes(app))
// The touch shell exists as a fallback on phones/tablets but not on a TV —
// gates the console's own "Controller-optimized UI" off switch.
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
.put("settings", ConsoleJson.settings(initial, base))
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
.put("entry", JSONObject())
handle = runCatching { NativeBridge.nativeConsoleCreate(opts.toString()) }.getOrDefault(0L)
if (handle == 0L) {
Log.e(TAG, "console: native create failed")
healthy = false // see [healthy] — the touch UI fronts everything from here
return 0L
}
Log.i(TAG, "console: created (gpu cache ${gpuCacheBytes(app) shr 20} MB)")
startEventThread()
startServices(app)
return handle
}
/**
* Skia's resource budget: a quarter of the desktop's 160 MB on a 2 GB box, the desktop
* figure above (design D11 a 160 MB texture cache is how a TV box gets its process killed).
*/
private fun gpuCacheBytes(context: Context): Int {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
val classMb = am?.memoryClass ?: 128
return if (classMb >= 256) 160 shl 20 else 64 shl 20
}
private fun startServices(app: Context) {
ioPool.execute {
identity = runCatching { obtainIdentity(IdentityStore(app)) }
.onFailure { Log.w(TAG, "identity unavailable: ${it.message}") }
.getOrNull()
}
val d = HostDiscovery(app)
d.onChange = { list ->
discovered = list
// Learn wake MACs / mgmt ports from live adverts, as the desktop service does.
ioPool.execute {
var changed = false
for (dh in list) {
val kh = knownHostStore.all().firstOrNull { it.matches(dh) } ?: continue
if (dh.mac.isNotEmpty() && dh.mac.toSet() != kh.mac.toSet()) {
knownHostStore.learnMac(kh.address, kh.port, dh.mac); changed = true
}
dh.mgmtPort?.let { if (it != kh.mgmtPort) { knownHostStore.learnMgmtPort(kh.address, kh.port, it); changed = true } }
if (dh.os.isNotEmpty() && dh.os != kh.os) { knownHostStore.learnOs(kh.address, kh.port, dh.os); changed = true }
}
main.post { pushHosts(); if (changed) pushKnownHosts() }
}
pushHosts()
}
discovery = d
d.start()
// The reachability sweep: saved hosts not on mDNS, every ~12 s (the desktop's cadence).
main.post(object : Runnable {
override fun run() {
if (handle == 0L) return
// Only while the console is ON SCREEN (attached): parked behind the touch UI
// or a stream there is nobody to show the presence pips to — and mid-stream
// the radio belongs to the session, which is exactly why discovery stops for
// it. The timer keeps ticking so probes resume within a cadence of re-attach.
if (onConnected == null) {
main.postDelayed(this, 12_000)
return
}
val targets = knownHostStore.all().filter { kh -> discovered.none { kh.matches(it) } }
ioPool.execute {
val up = targets.filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) }
.map { "${it.address}:${it.port}" }.toSet()
main.post { if (up != reachable) { reachable = up; pushHosts() } }
}
main.postDelayed(this, 12_000)
}
})
// Commands from the console, drained on a short cadence.
main.post(object : Runnable {
override fun run() {
if (handle == 0L) return
drainCommands()
main.postDelayed(this, 100)
}
})
pushHosts()
}
private fun startEventThread() {
running.set(true)
eventThread = Thread({
while (running.get() && handle != 0L) {
val json = runCatching { NativeBridge.nativeConsoleNextEvent(handle) }.getOrDefault("")
if (json.isEmpty()) continue
val ev = runCatching { JSONObject(json) }.getOrNull() ?: continue
main.post { onEvent(ev) }
}
}, "pf-console-events").apply { isDaemon = true; start() }
}
// ---- what the composable attaches ------------------------------------------------------
fun attach(
onConnected: (ActiveSession) -> Unit,
onSettingsChange: (Settings) -> Unit,
onQuit: () -> Unit,
onPlatformScreen: (String) -> Unit,
onPadAction: (String, String) -> Unit,
onPulse: (String) -> Unit,
) {
this.onConnected = onConnected
this.onSettingsChange = onSettingsChange
this.onQuit = onQuit
this.onPlatformScreen = onPlatformScreen
this.onPadAction = onPadAction
this.onPulse = onPulse
discovery?.restart()
// The touch UI may have paired/forgotten/edited hosts or profiles while we were away.
pushHosts()
pushKnownHosts()
if (handle != 0L) NativeBridge.nativeConsoleSetProfiles(handle, ConsoleJson.profiles(profileStore.all()))
}
fun detach() {
onConnected = null
onSettingsChange = null
onQuit = null
onPlatformScreen = null
onPadAction = null
onPulse = null
}
/** The touch UI (or a link) changed settings: the console reads the new snapshot next. */
fun settingsChanged(s: Settings) {
settings = s
if (handle == 0L) return
val prefs = appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val base = prefs?.getString("json", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
NativeBridge.nativeConsoleSetSettings(handle, ConsoleJson.settings(s, base).toString())
}
/** The profile catalog changed (the touch settings edited it). */
fun profilesChanged() {
if (handle == 0L) return
NativeBridge.nativeConsoleSetProfiles(handle, ConsoleJson.profiles(profileStore.all()))
pushHosts()
}
/** The host store changed outside the console (touch UI pairing / forget). */
fun hostsChanged() {
if (handle == 0L) return
pushHosts()
pushKnownHosts()
}
/** A session the console started (or any session) has ended; [reason] = the abnormal one. */
fun sessionEnded(reason: String?) {
if (handle == 0L) return
NativeBridge.nativeConsoleSessionPhase(handle, 3, reason.orEmpty())
discovery?.restart()
}
/** Re-root the console on a host's shelf (a game launched from it just exited; a deep link). */
fun openLibrary(hostId: String, pinId: String?) {
if (handle == 0L) return
val kh = knownHostStore.byId(hostId) ?: return
val profiles = profileStore.all()
val pin = pinId?.let { id -> profiles.firstOrNull { it.id == id } }
val entry = JSONObject().put("library", ConsoleJson.hostRow(kh, pin, profiles))
NativeBridge.nativeConsoleNavigate(handle, entry.toString())
}
/**
* A `punktfunk://` link while the console is up. Known-and-pinned is the one-click contract
* (the same dial the console's own Launch takes); anything that would need a trust decision
* is a notice here a link may never establish trust, and the console's Pair screen is
* reached from the host's tile, not from a URL.
*/
fun handleDeepLink(url: String) {
if (handle == 0L) return
val parsed = io.unom.punktfunk.kit.link.DeepLinks.parse(url)
if (parsed is io.unom.punktfunk.kit.link.DeepLinkResult.Refused) {
if (parsed.error != io.unom.punktfunk.kit.link.LinkError.NOT_OUR_SCHEME) notice(parsed.message())
return
}
val link = (parsed as io.unom.punktfunk.kit.link.DeepLinkResult.Parsed).link
if (link.route != io.unom.punktfunk.kit.link.LinkRoute.CONNECT) {
notice("Punktfunk on Android can't do “${link.route.word}” links yet.")
return
}
val profileRef = link.profile
if (profileRef != null) {
val (_, resolution) = profileStore.resolve(profileRef)
if (resolution != io.unom.punktfunk.ProfileResolution.FOUND) {
notice("That link asks for a profile called “$profileRef”, which isn't on this device.")
return
}
}
when (val resolved = io.unom.punktfunk.kit.link.DeepLinks.resolveHost(link, knownHostStore.all())) {
is io.unom.punktfunk.kit.link.HostResolution.Known -> {
val kh = resolved.host
if (link.pinConflict(kh)) {
notice("That link's fingerprint doesn't match the one pinned for ${kh.name}.")
return
}
if (kh.fpHex.isEmpty() || !kh.paired) {
notice("Pair with ${kh.name} first — a link can't establish trust.")
return
}
launch(
JSONObject()
.put("addr", kh.address).put("port", kh.port).put("fp_hex", kh.fpHex)
.put("launch", link.launch ?: JSONObject.NULL)
.put("profile", profileRef?.let { profileStore.resolve(it).first?.id } ?: JSONObject.NULL)
.put("request_access", false),
)
}
is io.unom.punktfunk.kit.link.HostResolution.Unknown ->
notice("That link points at a host this device hasn't paired with.")
io.unom.punktfunk.kit.link.HostResolution.Ambiguous ->
notice("More than one saved host is called “${link.hostRef}”.")
io.unom.punktfunk.kit.link.HostResolution.Unresolvable ->
notice("That link points at a host this device doesn't know.")
}
}
/** The connected controllers, for the chip + settings rows. */
fun padsChanged(driving: InputDevice?) {
if (handle == 0L) return
NativeBridge.nativeConsoleSetPads(handle, ConsoleJson.pads(Gamepad.pads(), driving ?: Gamepad.firstPad()))
}
// ---- model pushers -----------------------------------------------------------------------
private fun pushHosts() {
if (handle == 0L) return
NativeBridge.nativeConsoleSetHosts(
handle,
ConsoleJson.hostRows(knownHostStore.all(), discovered, reachable, profileStore.all()),
)
}
private fun pushKnownHosts() {
if (handle == 0L) return
NativeBridge.nativeConsoleSetKnownHosts(handle, ConsoleJson.knownHosts(knownHostStore.all()))
}
internal fun notice(text: String) {
if (handle != 0L) NativeBridge.nativeConsoleNotice(handle, text)
}
// ---- events from the console ---------------------------------------------------------
private fun onEvent(ev: JSONObject) {
when {
ev.has("action") -> onAction(ev.get("action"))
ev.has("pulse") -> onPulse?.invoke(ev.optString("pulse"))
ev.has("editing") -> {} // the shell draws its own keyboard; nothing to raise here
ev.has("settings") -> onSettingsSaved(ev.getJSONObject("settings"))
ev.has("gles") -> Log.i(TAG, "console: GLES ${ev.optInt("gles")}")
ev.has("dead") -> {
Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
healthy = false // the touch UI takes over; only a process restart tries again
}
}
}
private fun onSettingsSaved(j: JSONObject) {
appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)?.edit()
?.putString("json", j.toString())?.apply()
val next = ConsoleJson.applySettings(settings, j)
if (next != settings) {
settings = next
settingsStore.save(next)
onSettingsChange?.invoke(next)
}
}
private fun onAction(action: Any) {
when (action) {
is String -> when (action) {
"Quit" -> onQuit?.invoke()
"CancelConnect" -> {
dial?.cancelled?.set(true)
dial = null
discovery?.restart()
}
}
is JSONObject -> {
action.optJSONObject("Launch")?.let(::launch)
action.optString("CopyText").takeIf { action.has("CopyText") }?.let { text ->
val cm = appContext?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
cm?.setPrimaryClip(ClipData.newPlainText("punktfunk", text))
}
}
}
}
/**
* `OverlayAction::Launch` the console asked for a session. The trust decision was the
* console's (an unpaired host went to its Pair screen first), so this is the dial itself:
* pinned by the row's fingerprint, with the host's bound profile or the pinned card's
* one-off, and for the pair screen's "Request access" the long approval budget.
*/
private fun launch(a: JSONObject) {
val app = appContext ?: return
val addr = a.optString("addr")
val port = a.optInt("port")
val fp = a.optString("fp_hex")
val launchId = a.optString("launch").takeIf { a.has("launch") && !a.isNull("launch") && it.isNotEmpty() }
val profileId = a.optString("profile").takeIf { a.has("profile") && !a.isNull("profile") && it.isNotEmpty() }
val requestAccess = a.optBoolean("request_access", false)
val id = identity
if (id == null) {
NativeBridge.nativeConsoleSessionPhase(handle, 2, "Identity not ready yet — try again in a moment")
return
}
val kh = knownHostStore.get(addr, port)
val profile: StreamProfile? = profileStore.resolveFor(kh, profileId)
val effective = settings.effectiveFor(profile)
val d = Dial()
dial = d
NativeBridge.nativeConsoleSessionPhase(handle, 0, "")
discovery?.stop() // free the Wi-Fi radio before the stream session
ioPool.execute {
val timeout = if (requestAccess) REQUEST_ACCESS_TIMEOUT_MS else CONNECT_TIMEOUT_MS
val h = kotlinx.coroutines.runBlocking {
connectToHost(app, effective, id, addr, port, fp, launchId, timeout)
}
main.post {
if (d.cancelled.get()) {
if (h != 0L) ioPool.execute { NativeBridge.nativeClose(h) }
return@post
}
dial = null
if (h != 0L) {
var record = kh
// A request-access approval, or a first TOFU-less connect: save the host as
// PAIRED, pinning what it presented, so the next connect is silent.
if (record == null || (requestAccess && !record.paired)) {
val seen = NativeBridge.nativeHostFingerprint(h)
if (seen.isNotEmpty()) {
val name = record?.name
?: discovered.firstOrNull { it.host == addr && it.port == port }?.name
?: addr
record = knownHostStore.trust(addr, port, name, seen, paired = requestAccess || record?.paired == true)
pushHosts(); pushKnownHosts()
}
}
if (record != null) {
NativeBridge.nativeHostMgmtPort(h).takeIf { it > 0 }?.let {
knownHostStore.learnMgmtPort(record.address, record.port, it)
}
}
NativeBridge.nativeConsoleSessionPhase(handle, 1, "")
onConnected?.invoke(
ActiveSession(
h,
effective,
clipboardSync = record?.clipboardSync ?: true,
profileName = profile?.name,
hostId = record?.id,
launchedFromLibrary = launchId != null,
libraryProfileId = profileId,
),
)
} else {
val token = NativeBridge.nativeTakeLastError()
NativeBridge.nativeConsoleSessionPhase(
handle, 2, ConnectErrors.connectMessage(token, requestAccess),
)
discovery?.restart()
}
}
}
}
// ---- commands from the console -----------------------------------------------------
private fun drainCommands() {
val arr = runCatching { JSONArray(NativeBridge.nativeConsoleDrainCmds(handle)) }.getOrNull() ?: return
for (i in 0 until arr.length()) {
when (val c = arr.opt(i)) {
is String -> when (c) {
"CancelWake" -> { wakeGen.incrementAndGet(); NativeBridge.nativeConsoleSetWake(handle, "null") }
"Probe" -> { discovery?.restart(); pushHosts() }
}
is JSONObject -> {
c.optJSONObject("FetchLibrary")?.let { fetchLibrary(it, refreshOnly = false) }
c.optJSONObject("RefreshRunning")?.let { fetchLibrary(it, refreshOnly = true) }
c.optJSONObject("Pair")?.let(::pair)
c.optJSONObject("SendLogs")?.let(::sendLogs)
c.optJSONObject("SaveHost")?.let(::saveHost)
c.optJSONObject("UpdateHost")?.let(::updateHost)
c.optJSONObject("ForgetHost")?.let(::forgetHost)
c.optJSONObject("Wake")?.let(::wake)
c.optJSONObject("SetPin")?.let(::setPin)
c.optJSONObject("BindProfile")?.let(::bindProfile)
c.optJSONObject("SetClipboard")?.let(::setClipboard)
c.optJSONObject("OpenPlatformScreen")?.let { onPlatformScreen?.invoke(it.optString("id")) }
c.optJSONObject("PadAction")?.let { onPadAction?.invoke(it.optString("action"), it.optString("pad_key")) }
c.optString("OpenPlatformScreen").takeIf { c.has("OpenPlatformScreen") && c.opt("OpenPlatformScreen") is String }
?.let { onPlatformScreen?.invoke(it) }
}
}
}
}
private fun hostForKey(key: String): KnownHost? {
val primary = key.substringBefore('\u0000')
return knownHostStore.all().firstOrNull { ConsoleJson.rowKey(it.fpHex, it.address, it.port) == primary }
}
private fun saveHost(c: JSONObject) {
val addr = c.optString("addr"); val port = c.optInt("port"); val name = c.optString("name")
val existing = knownHostStore.get(addr, port)
if (existing != null) {
if (name.isNotEmpty()) knownHostStore.save(existing.copy(name = name))
} else {
knownHostStore.save(KnownHost(address = addr, port = port, name = name.ifEmpty { addr }, fpHex = "", paired = false))
}
pushHosts(); pushKnownHosts()
}
private fun updateHost(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
val name = c.optString("name").trim(); val addr = c.optString("addr"); val port = c.optInt("port")
if (addr != kh.address || port != kh.port) knownHostStore.remove(kh)
knownHostStore.save(kh.copy(name = name.ifEmpty { addr }, address = addr, port = port))
pushHosts(); pushKnownHosts()
}
private fun forgetHost(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
knownHostStore.remove(kh)
appContext?.let { LibraryCache.standard(it.cacheDir).forget(kh.id) }
pushHosts(); pushKnownHosts()
}
/** `ConsoleCmd::BindProfile` — the host's default binding (`KnownHost.profileId`); null clears. */
private fun bindProfile(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
val pid = c.optString("profile_id")
.takeIf { c.has("profile_id") && !c.isNull("profile_id") && it.isNotEmpty() }
knownHostStore.save(kh.copy(profileId = pid))
pushHosts(); pushKnownHosts()
}
/** `ConsoleCmd::SetClipboard` — the per-host clipboard trust toggle. */
private fun setClipboard(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
knownHostStore.save(kh.copy(clipboardSync = c.optBoolean("on")))
pushHosts(); pushKnownHosts()
}
private fun setPin(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
val pid = c.optString("profile_id"); val pin = c.optBoolean("pin")
val pins = kh.pinnedProfileIds.toMutableList()
if (pin && pid !in pins) pins.add(pid) else if (!pin) pins.remove(pid)
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
pushHosts(); pushKnownHosts()
}
/**
* `ConsoleCmd::SendLogs` the native log ring (`nativeRenderLogs`) posted to this
* paired host's `POST /api/v1/client-logs` over the same mTLS client the library fetch
* uses; the result comes back as a notice, in the desktop console's wording. The header
* mirrors the desktop's identity line (`punktfunk-session <ver> (<os> <arch>) client
* log bundle`).
*/
private fun sendLogs(c: JSONObject) {
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
val hostName = c.optString("host_name").ifEmpty { addr }
val id = identity
if (id == null) {
notice("Identity not ready yet — try again in a moment")
return
}
val version = appContext?.let { app ->
runCatching { app.packageManager.getPackageInfo(app.packageName, 0).versionName }.getOrNull()
} ?: "?"
val header = "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
ioPool.execute {
val err = runCatching {
val body = NativeBridge.nativeRenderLogs(header)
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
id.certPem, id.privateKeyPem, addr, fp,
)
val req = Request.Builder()
.url("https://$addr:$mgmt/api/v1/client-logs")
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { resp ->
if (resp.code == 200) "" else "host answered HTTP ${resp.code}"
}
}.getOrElse { it.message ?: "upload failed" }
main.post {
notice(
if (err.isEmpty()) {
"Logs sent to $hostName — download them from its web console's Logs page"
} else {
"Couldn't send logs — $err"
},
)
}
}
}
private fun pair(c: JSONObject) {
val addr = c.optString("addr"); val port = c.optInt("port")
val pin = c.optString("pin"); val name = c.optString("device_name")
val id = identity
if (id == null) {
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairFailed("Identity not ready yet — try again in a moment"))
return
}
val hostName = knownHostStore.get(addr, port)?.name
?: discovered.firstOrNull { it.host == addr && it.port == port }?.name ?: addr
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairBusy())
ioPool.execute {
val fp = runCatching { NativeBridge.nativePair(addr, port, id.certPem, id.privateKeyPem, pin, name) }.getOrDefault("")
main.post {
if (fp.isNotEmpty()) {
knownHostStore.trust(addr, port, hostName, fp, paired = true)
pushHosts(); pushKnownHosts()
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairPaired(fp))
} else {
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairFailed(ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())))
}
}
}
}
/**
* The wake-and-wait loop (the desktop's `spawn_wake`): resend the magic packet every 6 s,
* probe once a second, 90 s timeout; the console reads `online`/`timed_out` off the status
* and acts (a `then_connect` wake dials from the shell's side once online).
*/
private fun wake(c: JSONObject) {
val key = c.optString("key"); val thenConnect = c.optBoolean("then_connect")
val kh = hostForKey(key) ?: return
if (kh.mac.isEmpty()) return
val gen = wakeGen.incrementAndGet()
val name = kh.name.ifBlank { kh.address }
ioPool.execute {
val started = System.currentTimeMillis()
var lastPacket = 0L
while (wakeGen.get() == gen && handle != 0L) {
val elapsed = ((System.currentTimeMillis() - started) / 1000).toInt()
val timedOut = elapsed >= 90
if (!timedOut && System.currentTimeMillis() - lastPacket >= 6_000) {
NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address)
lastPacket = System.currentTimeMillis()
}
val online = NativeBridge.nativeProbe(kh.address, kh.port, 900) ||
discovered.any { kh.matches(it) }
if (wakeGen.get() != gen) return@execute
NativeBridge.nativeConsoleSetWake(
handle,
ConsoleJson.wakeStatus(key, name, elapsed, timedOut, online, thenConnect),
)
if (online || timedOut) return@execute
Thread.sleep(1000)
}
}
}
/**
* The library pipeline (the desktop's `spawn_fetch`): cached shelf first, wake + retry
* across the boot window when the host has a MAC, then the catalog, the running set and
* the posters each poster fetched over the same mTLS client and pushed as bytes.
*/
private fun fetchLibrary(c: JSONObject, refreshOnly: Boolean) {
val app = appContext ?: return
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
val id = identity
val kh = knownHostStore.all().firstOrNull { it.fpHex.equals(fp, true) && fp.isNotEmpty() }
?: knownHostStore.get(addr, mgmt)
if (refreshOnly) {
if (id == null) return
ioPool.execute {
val up = LibraryClient.fetchRunning(addr, mgmt, id.certPem, id.privateKeyPem, fp)
.filter { it.isUp }.mapNotNull { it.appId }
main.post { if (handle != 0L) NativeBridge.nativeConsoleLibraryRunning(handle, ConsoleJson.stringArray(up)) }
}
return
}
val gen = fetchGen.incrementAndGet()
NativeBridge.nativeConsoleLibraryBegin(handle)
if (id == null) {
NativeBridge.nativeConsoleLibraryPhase(handle, ConsoleJson.libraryError("Couldn't load the library", "Identity not ready yet — try again in a moment", true))
return
}
val cache = LibraryCache.standard(app.cacheDir)
val cacheKey = kh?.id ?: fp.ifEmpty { "$addr:$mgmt" }
ioPool.execute {
val cached = cache.load(cacheKey)?.games?.takeIf { it.isNotEmpty() }
if (cached != null) main.post { if (gen == fetchGen.get()) NativeBridge.nativeConsoleLibraryGames(handle, ConsoleJson.libraryGames(cached), true) }
val macs = kh?.mac.orEmpty()
val waking = macs.isNotEmpty() && settings.autoWakeEnabled
if (waking) NativeBridge.nativeWakeOnLan(macs.joinToString(","), addr)
val attempts = if (waking) 12 else 1
var result: LibraryResult? = null
for (attempt in 0 until attempts) {
if (gen != fetchGen.get()) return@execute
val r = LibraryClient.fetch(addr, mgmt, id.certPem, id.privateKeyPem, fp)
result = r
if (r is LibraryResult.Ok || r is LibraryResult.Unauthorized) break
if (attempt + 1 >= attempts) break
if (attempt % 2 == 1) NativeBridge.nativeWakeOnLan(macs.joinToString(","), addr)
main.post { if (gen == fetchGen.get()) NativeBridge.nativeConsoleLibraryStale(handle, 1) }
Thread.sleep(5_000)
}
if (gen != fetchGen.get()) return@execute
when (val r = result) {
is LibraryResult.Ok -> {
val games = r.games
cache.store(cacheKey, games)
val up = LibraryClient.fetchRunning(addr, mgmt, id.certPem, id.privateKeyPem, fp)
.filter { it.isUp }.mapNotNull { it.appId }
main.post {
if (gen != fetchGen.get()) return@post
NativeBridge.nativeConsoleLibraryGames(handle, ConsoleJson.libraryGames(games), false)
NativeBridge.nativeConsoleLibraryStale(handle, 0)
NativeBridge.nativeConsoleLibraryRunning(handle, ConsoleJson.stringArray(up))
}
for (g in games) {
val candidates = g.art.posterCandidates
if (candidates.isEmpty()) continue
artPool.execute {
if (gen != fetchGen.get()) return@execute
val bytes = fetchArt(candidates, id, addr, fp) ?: return@execute
main.post { if (gen == fetchGen.get() && handle != 0L) NativeBridge.nativeConsoleLibraryArt(handle, g.id, bytes) }
}
}
}
is LibraryResult.Unauthorized -> main.post {
if (gen != fetchGen.get()) return@post
if (cached != null) NativeBridge.nativeConsoleLibraryStale(handle, 2)
else NativeBridge.nativeConsoleLibraryPhase(handle, ConsoleJson.libraryError("Not paired", r.message, false))
}
is LibraryResult.Error -> main.post {
if (gen != fetchGen.get()) return@post
if (cached != null) NativeBridge.nativeConsoleLibraryStale(handle, 2)
else NativeBridge.nativeConsoleLibraryPhase(handle, ConsoleJson.libraryError("Couldn't load the library", r.message, true))
}
null -> {}
}
}
}
/** One poster: the candidates in order, first success wins; the host's art proxy over mTLS. */
private fun fetchArt(candidates: List<String>, id: ClientIdentity, addr: String, fp: String): ByteArray? {
for (url in candidates) {
val client = if (url.contains(addr)) {
runCatching { io.unom.punktfunk.kit.library.mtlsHttpClient(id.certPem, id.privateKeyPem, addr, fp) }.getOrNull() ?: continue
} else artHttp
val bytes = runCatching {
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
if (resp.code == 200) resp.body?.bytes()?.takeIf { it.isNotEmpty() && it.size <= 16 shl 20 } else null
}
}.getOrNull()
if (bytes != null) return bytes
}
return null
}
/** The no-PIN request-access park (≥ the host's approval window) — ConnectScreen's figure. */
private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000
}
@@ -1,452 +0,0 @@
package io.unom.punktfunk.console
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.hardware.usb.UsbManager
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.union
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat
import io.unom.punktfunk.ConsoleLicensesScreen
import io.unom.punktfunk.DS_USB_PERMISSION_ACTION
import io.unom.punktfunk.MainActivity
import io.unom.punktfunk.Settings
import io.unom.punktfunk.SettingsStore
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.LibraryReturn
import io.unom.punktfunk.kit.Sc2BleLink
import io.unom.punktfunk.rememberConsoleHaptics
import io.unom.punktfunk.testRumble
import kotlin.math.roundToInt
/**
* The gamepad/console UI drawn by the Skia shell (`crates/pf-console-ui`), hosted on a
* `SurfaceView` this composable owns and driven through [SkiaConsole]. Same call shape as the
* Compose `GamepadShell` it replaces (`App.kt` picks one by [SkiaConsole.wanted]).
*
* What lives here is only what needs a composition: the surface lifecycle, the safe-area insets,
* the pad probes (raw pad the shared menu synthesizer, over JNI), the system Back, the
* platform-native sub-screen the console can open (Licences Compose, drawn over the surface;
* Connected controllers is the console's own Skia screen now), and the two intents the app hands
* over on the way in (a deep link, "come back to this shelf").
*/
@Composable
fun SkiaConsoleShell(
settings: Settings,
onSettingsChange: (Settings) -> Unit,
onConnected: (ActiveSession) -> Unit,
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
reopenLibrary: LibraryReturn? = null,
onReopenLibraryHandled: () -> Unit = {},
) {
val context = LocalContext.current
val activity = context as? MainActivity
val handle = remember { SkiaConsole.ensure(context, settings) }
val haptics = rememberConsoleHaptics()
// A platform-native screen the console opened over itself (design D7): the console's own
// input is held while it is up, and Back closes it.
var platformScreen by remember { mutableStateOf<String?>(null) }
val currentOnConnected by rememberUpdatedState(onConnected)
val currentOnSettingsChange by rememberUpdatedState(onSettingsChange)
DisposableEffect(handle) {
SkiaConsole.attach(
onConnected = { currentOnConnected(it) },
onSettingsChange = { currentOnSettingsChange(it) },
onQuit = { activity?.moveTaskToBack(true) },
onPlatformScreen = { platformScreen = it },
onPadAction = { action, key -> padAction(activity, action, key) },
onPulse = { pulse ->
when (pulse) {
"move" -> haptics.tick()
"confirm" -> haptics.confirm()
"boundary" -> haptics.boundary()
}
},
)
onDispose { SkiaConsole.detach() }
}
// Settings edited elsewhere (the touch UI shares the store) reach the shell on its next read.
LaunchedEffect(settings) { SkiaConsole.settingsChanged(settings) }
// "Come back to the shelf this game was launched from" — consumed once, on entry.
LaunchedEffect(reopenLibrary) {
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
SkiaConsole.openLibrary(id, pinId)
onReopenLibraryHandled()
}
// A `punktfunk://` link on the way in: consumed once; the bridge dials a known-and-pinned
// host and refuses (with a notice) anything that would need a trust decision.
LaunchedEffect(deepLink) {
val url = deepLink ?: return@LaunchedEffect
onDeepLinkHandled()
SkiaConsole.handleDeepLink(url)
}
// The console owns the whole panel while it fronts the app, exactly like the stream: the
// status bar and the gesture bar are hidden (a swipe shows them transiently). This is both the
// space win AND the safe-area fix — hidden bars report zero insets, so the scroll clips that
// used to end at the visible gesture-bar line now run to the panel edge. Only the display
// cutout stays a real inset. The hide/show itself lives in App.kt (one owner; a per-screen
// `onDispose { show }` fired after the stream's hide during the AnimatedContent cross-fade).
// The safe area, in surface pixels: system bars display cutout — the NP3's landscape punch
// is a SIDE inset, and the console's chrome must stay clear of it (its backdrop need not).
// With the bars hidden above, this is normally just the cutout.
val density = LocalDensity.current
val ld = LocalLayoutDirection.current
val insets = WindowInsets.systemBars.union(WindowInsets.displayCutout)
val left = insets.getLeft(density, ld).toFloat()
val top = insets.getTop(density).toFloat()
val right = insets.getRight(density, ld).toFloat()
val bottom = insets.getBottom(density).toFloat()
// Design-unit scale: TVs take the couch formula (0 = the shell decides: a 4K panel is 2.7×,
// the same 800-unit field as a Deck); a phone or tablet in the hand gets a density FLOOR
// under that formula, so type never shrinks below what the touch UI draws at the same
// density (design D5 — a bare height/800 on a 460 dpi phone lands ~26 % smaller than a Deck).
// The 0.75 is the on-glass tuning knob — raised from 0.6 after a 460 dpi phone (Nothing
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
val scale = if (tv) 0f else {
val dm = context.resources.displayMetrics
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
}
LaunchedEffect(handle, left, top, right, bottom, scale) {
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
}
// The pad, raw, before MainActivity's B→Back and stick→D-pad synthesis: face buttons and the
// stick/HAT become one MenuSample the shared synthesizer turns into menu events; a TV remote's
// D-pad keys (not SOURCE_GAMEPAD) go in as discrete events; hardware keys as `Key`s.
val padState = remember { PadState() }
val platformUp by rememberUpdatedState(platformScreen != null)
DisposableEffect(handle, activity) {
if (activity == null || handle == 0L) return@DisposableEffect onDispose {}
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
if (platformUp) return@probe false
val down = ev.action == KeyEvent.ACTION_DOWN
if (ev.action != KeyEvent.ACTION_DOWN && ev.action != KeyEvent.ACTION_UP) return@probe false
val fromPad = ev.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (fromPad) {
val bit = when (ev.keyCode) {
KeyEvent.KEYCODE_BUTTON_A -> 0
KeyEvent.KEYCODE_BUTTON_B -> 1
KeyEvent.KEYCODE_BUTTON_X -> 2
KeyEvent.KEYCODE_BUTTON_Y -> 3
KeyEvent.KEYCODE_BUTTON_L1 -> 4
KeyEvent.KEYCODE_BUTTON_R1 -> 5
else -> -1
}
if (bit >= 0) {
padState.button(bit, down)
padState.push(handle)
// MainActivity already noted the driving pad (lastPadDeviceId) before this
// probe ran; refresh the chip when the pad behind the buttons changes.
if (padState.deviceId != ev.deviceId) {
padState.deviceId = ev.deviceId
SkiaConsole.padsChanged(ev.device)
}
return@probe true
}
val dbit = when (ev.keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> 0
KeyEvent.KEYCODE_DPAD_DOWN -> 1
KeyEvent.KEYCODE_DPAD_LEFT -> 2
KeyEvent.KEYCODE_DPAD_RIGHT -> 3
else -> -1
}
if (dbit >= 0) {
padState.dpad(dbit, down)
padState.push(handle)
return@probe true
}
if (ev.keyCode == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
NativeBridge.nativeConsoleMenu(handle, 0) // ▲ opens the tile's options on Home
return@probe true
}
return@probe false
}
// A remote / keyboard. D-pad keys and DPAD_CENTER as discrete events with the
// framework's own repeat; the rest as console keys; printable text while editing.
if (!down) {
return@probe when (ev.keyCode) {
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_ESCAPE, KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_PAGE_DOWN -> true
else -> false
}
}
val repeat = ev.repeatCount > 0
when (ev.keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> NativeBridge.nativeConsoleMenu(handle, 0)
KeyEvent.KEYCODE_DPAD_DOWN -> NativeBridge.nativeConsoleMenu(handle, 1)
KeyEvent.KEYCODE_DPAD_LEFT -> NativeBridge.nativeConsoleMenu(handle, 2)
KeyEvent.KEYCODE_DPAD_RIGHT -> NativeBridge.nativeConsoleMenu(handle, 3)
KeyEvent.KEYCODE_DPAD_CENTER -> if (!repeat) NativeBridge.nativeConsoleMenu(handle, 4)
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> NativeBridge.nativeConsoleKey(handle, 4, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_SPACE -> NativeBridge.nativeConsoleKey(handle, 5, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_ESCAPE -> NativeBridge.nativeConsoleKey(handle, 6, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_BACK -> if (!repeat) NativeBridge.nativeConsoleMenu(handle, 5)
KeyEvent.KEYCODE_DEL -> NativeBridge.nativeConsoleKey(handle, 7, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_PAGE_UP -> NativeBridge.nativeConsoleKey(handle, 8, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_PAGE_DOWN -> NativeBridge.nativeConsoleKey(handle, 9, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_TAB -> NativeBridge.nativeConsoleKey(handle, 10, ev.isShiftPressed, repeat)
else -> {
val ch = ev.unicodeChar
if (ch != 0 && !ev.isCtrlPressed && !ev.isAltPressed && ch >= 0x20) {
NativeBridge.nativeConsoleText(handle, String(Character.toChars(ch)))
} else {
return@probe false
}
}
}
true
}
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (platformUp) return@probe false
if (!ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && !ev.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
return@probe false
}
val lx = ev.getAxisValue(MotionEvent.AXIS_X)
val ly = ev.getAxisValue(MotionEvent.AXIS_Y)
val hx = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
val hy = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
padState.stick(lx, ly)
padState.hat(hx, hy)
padState.push(handle)
true
}
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
activity.pushPadProbes(probes)
SkiaConsole.padsChanged(Gamepad.firstPad())
onDispose {
// Remove OUR claim only — a platform screen pushed over us keeps its own, and when it
// pops, this one resurfaces (the stack is what fixed the pad dying after Controllers).
activity.removePadProbes(probes)
padState.reset()
if (handle != 0L) padState.push(handle)
}
}
// The system Back (gesture or key) is the console's B; at its root the shell raises Quit.
BackHandler(enabled = platformScreen == null) {
if (handle != 0L) NativeBridge.nativeConsoleMenu(handle, 5)
}
Box(Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
SurfaceView(ctx).apply {
// The console draws opaque, edge to edge; Compose overlays sit above it.
setZOrderMediaOverlay(false)
isFocusable = false
isFocusableInTouchMode = false
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(h: SurfaceHolder) {
if (handle != 0L) NativeBridge.nativeConsoleSurfaceCreated(handle, h.surface)
}
override fun surfaceChanged(h: SurfaceHolder, format: Int, width: Int, height: Int) {
if (handle != 0L) NativeBridge.nativeConsoleSurfaceChanged(handle)
}
override fun surfaceDestroyed(h: SurfaceHolder) {
if (handle != 0L) NativeBridge.nativeConsoleSurfaceDestroyed(handle)
}
})
// Touch → the console's pointer (surface pixels): the escape hatch when no
// pad is attached, and the natural way to press a legend hint on a phone.
// A finger's down is kind 6 (the shell defers it so a swipe scrolls); a
// mouse — which Android delivers through this same listener — keeps kind 1
// and acts on the press, as a mouse should.
setOnTouchListener { v, ev ->
if (handle == 0L) return@setOnTouchListener false
val kind = when (ev.actionMasked) {
MotionEvent.ACTION_DOWN ->
if (ev.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) 1 else 6
MotionEvent.ACTION_MOVE -> 0
MotionEvent.ACTION_UP -> 2
MotionEvent.ACTION_CANCEL -> 5
else -> return@setOnTouchListener false
}
NativeBridge.nativeConsolePointer(handle, kind, ev.x, ev.y, 0f)
if (ev.actionMasked == MotionEvent.ACTION_UP) v.performClick()
true
}
setOnGenericMotionListener { _, ev ->
if (handle != 0L && ev.actionMasked == MotionEvent.ACTION_SCROLL &&
ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)
) {
NativeBridge.nativeConsolePointer(handle, 4, ev.x, ev.y, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
true
} else false
}
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
},
)
when (platformScreen) {
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
}
}
}
/**
* A `ConsoleCmd::PadAction` from the console's Connected-controllers screen the handful of
* things only the platform can do: a rumble pulse on the real [InputDevice], the USB/Bluetooth
* grant dialogs, the DualSense pad-audio self test. The touch Controllers screen keeps its own
* buttons for the same actions; both routes end in the same helpers ([testRumble], the grant
* intents, `nativePadAudioSelfTest`), so the support answer cannot drift between interfaces.
* Runs on the main thread (the command drain lives there); results ride [SkiaConsole.notice].
*/
private fun padAction(activity: MainActivity?, action: String, padKey: String) {
if (activity == null) return
val settings = SettingsStore(activity).load()
val usb = activity.getSystemService(Context.USB_SERVICE) as UsbManager
when (action) {
"rumble" ->
Gamepad.pads()
.firstOrNull { "${it.vendorId}:${it.productId}:${it.name}" == padKey }
?.let(::testRumble)
"sc2_bluetooth" -> when {
!settings.sc2Capture ->
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
Sc2BleLink.permissionGranted(activity) ->
SkiaConsole.notice("Bluetooth access is already granted.")
// The system dialog pauses the activity; onResume re-probes and engages the capture,
// the same way the menu-time auto-ask completes.
else -> Sc2BleLink.CONNECT_PERMISSION?.let {
ActivityCompat.requestPermissions(activity, arrayOf(it), 5)
}
}
"sc2_usb" ->
if (!settings.sc2Capture) {
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
} else {
// Asks for the USB grant when one is missing and engages the capture on it.
activity.startSc2MenuNav(forceAsk = true)
}
"ds_usb" -> {
val dev = usb.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
when {
!settings.dsCapture ->
SkiaConsole.notice(
"Enable \"DualSense / DualShock passthrough (USB)\" in Settings first.",
)
dev == null -> SkiaConsole.notice("No wired DualSense or DualShock 4 detected.")
usb.hasPermission(dev) -> SkiaConsole.notice("USB access is already granted.")
else -> usb.requestPermission(
dev,
PendingIntent.getBroadcast(
activity, 3, // requestCode 3 — shared with the touch card's button
Intent(DS_USB_PERMISSION_ACTION).setPackage(activity.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
}
"ds_haptics" -> {
val dev = usb.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
when {
dev == null -> SkiaConsole.notice("No wired DualSense detected.")
DsDevice.modelFor(dev.productId) == DsDevice.Model.DUALSHOCK4 ->
SkiaConsole.notice("The DualShock 4 has no haptics audio device.")
!usb.hasPermission(dev) -> SkiaConsole.notice("Grant USB access first.")
else -> Thread({
// Its OWN connection: the renderer's descriptor must never be shared with
// another transfer engine, and that applies to this test as much as to the
// real path (same rule as the touch card's test).
val conn = runCatching { usb.openDevice(dev) }.getOrNull()
val fd = conn?.fileDescriptor ?: -1
val r = if (fd >= 0) NativeBridge.nativePadAudioSelfTest(fd, 3, 60) else -1
conn?.close()
SkiaConsole.notice(
when {
r > 0 -> "Haptics test passed — $r frames to the pad."
r == -1 ->
"Could not open the pad's audio interface. Some kernels " +
"refuse it; the pad still works normally."
r == -2 -> "The audio stream stopped part-way."
else -> "The stream opened but no audio reached the pad."
},
)
}, "pf-pad-selftest-console").start()
}
}
}
}
/** The raw pad as one `MenuSample`, pushed whenever any part of it changes. */
private class PadState {
var deviceId = -1
private var buttons = 0
private var dpad = 0
private var lx = 0
private var ly = 0
private var last: IntArray? = null
fun button(bit: Int, down: Boolean) {
buttons = if (down) buttons or (1 shl bit) else buttons and (1 shl bit).inv()
}
fun dpad(bit: Int, down: Boolean) {
dpad = if (down) dpad or (1 shl bit) else dpad and (1 shl bit).inv()
}
fun stick(x: Float, y: Float) {
lx = (x.coerceIn(-1f, 1f) * 32767f).roundToInt()
ly = (y.coerceIn(-1f, 1f) * 32767f).roundToInt()
}
/** The HAT is the D-pad on most pads' motion path (±1 per axis). */
fun hat(x: Float, y: Float) {
dpad(2, x <= -0.5f); dpad(3, x >= 0.5f); dpad(0, y <= -0.5f); dpad(1, y >= 0.5f)
}
fun reset() {
buttons = 0; dpad = 0; lx = 0; ly = 0
}
fun push(handle: Long) {
val now = intArrayOf(buttons, lx, ly, dpad)
if (last?.contentEquals(now) == true) return
last = now
NativeBridge.nativeConsolePadSample(handle, buttons, lx, ly, dpad)
}
}
@@ -71,24 +71,8 @@ data class ActiveSession(
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
*/
val launchedFromLibrary: Boolean = false,
/**
* Which of [hostId]'s shelves that library launch came off: the pinned host+profile card's
* profile id (design §5.2a), or null for the host's own tile. Carried purely so the return
* trip above lands back on the SAME shelf a player who launched from a pinned card is still
* on that card when the game exits, and coming back to the host's default shelf would silently
* change what the next title streams with.
*/
val libraryProfileId: String? = null,
)
/**
* The library shelf a finished game launch should return to: the saved host's id, and the pinned
* profile card it was opened from (null = the host's own tile). One value rather than two parallel
* ones, because a hostId that arrives without its profile is not "the same shelf" it is the
* default one wearing the same name.
*/
data class LibraryReturn(val hostId: String, val profileId: String? = null)
/** Trust state of a host, shown as a colored pill on its card. */
enum class HostStatus(val label: String) {
PAIRED("Paired"),
@@ -1,155 +0,0 @@
package io.unom.punktfunk
import java.io.File
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The console UI's cross-client contract, against `clients/shared/console-vectors.json`.
*
* The background palettes, the settings section names and the screen-transition motion each exist
* in three hand-written copies this client, `pf-console-ui` (Rust) and the Apple client and
* until this file they were held together by nothing but a comment asking the next person to keep
* them in step. Two of the three had already drifted.
*
* Read straight off disk with a relative path rather than copied into test resources, for the
* reason the deeplink vectors state: a copy would be a fourth contract, free to go stale. Gradle
* runs a unit test with the MODULE directory as its working directory, so `../../shared/` from
* `clients/android/app` lands on `clients/shared`.
*
* What this pins that the older [GamepadPaletteTest] could not: the DERIVED 16-cell mesh and the
* 4 blob colours per palette. Those are what actually reach the screen the mesh through the AGSL
* shader on API 33+, the blobs through the fallback field below it and the existing tests only
* ever measured the `stops` they are computed from.
*/
class ConsoleVectorsTest {
private companion object {
/** One step of Compose's 8-bit-per-component sRGB packing — see the blob comparison. */
const val EIGHT_BIT_STEP = 1.0 / 255.0
}
private val vectors: JSONObject by lazy {
val file = File("../../shared/console-vectors.json")
assertTrue(
"the shared vector file must be reachable at ${file.absolutePath}",
file.isFile,
)
JSONObject(file.readText())
}
private fun JSONObject.doubles(key: String): List<Double> =
getJSONArray(key).let { a -> (0 until a.length()).map { a.getDouble(it) } }
private fun close(what: String, got: Double, want: Double, tol: Double = 1e-6) {
assertTrue(
"$what: vectors say $want, this client computes $got",
kotlin.math.abs(got - want) <= tol,
)
}
@Test
fun cellRampAndMeshInteriorMatch() {
assertEquals("CELL_RAMP", vectors.doubles("cell_ramp"), GamepadPalette.CELL_RAMP)
val interior = vectors.getJSONArray("mesh_interior")
assertEquals("mesh interior count", GamepadPalette.MESH_INTERIOR.size, interior.length())
GamepadPalette.MESH_INTERIOR.forEachIndexed { i, p ->
val w = interior.getJSONArray(i)
val got = listOf(p.x, p.y, p.amp, p.sx, p.sy, p.phase)
got.forEachIndexed { k, v -> close("mesh_interior[$i][$k]", v, w.getDouble(k)) }
}
}
/** Every palette, field by field — and then the two tables derived from it. */
@Test
fun everyPaletteMatchesTheSharedVectors() {
val want = vectors.getJSONArray("palettes")
assertEquals("palette count", want.length(), GamepadPalette.ALL.size)
GamepadPalette.ALL.forEachIndexed { i, p ->
val w = want.getJSONObject(i)
val id = w.getString("id")
assertEquals("palette order", id, p.id)
assertEquals("$id name", w.getString("name"), p.name)
assertEquals("$id light", w.getBoolean("light"), p.light)
val stops = w.getJSONArray("stops")
assertEquals("$id stop count", stops.length(), p.stops.size)
p.stops.forEachIndexed { s, t ->
val ws = stops.getJSONArray(s)
close("$id stops[$s].r", t.first, ws.getDouble(0))
close("$id stops[$s].g", t.second, ws.getDouble(1))
close("$id stops[$s].b", t.third, ws.getDouble(2))
}
val ground = w.doubles("ground")
close("$id ground.r", p.ground.first, ground[0])
close("$id ground.g", p.ground.second, ground[1])
close("$id ground.b", p.ground.third, ground[2])
val accent = w.doubles("accent")
close("$id accent.r", p.accent.first, accent[0])
close("$id accent.g", p.accent.second, accent[1])
close("$id accent.b", p.accent.third, accent[2])
// The mesh the shader is built from — 16 cells, sampled off the ramp per CELL_RAMP.
val mesh = w.getJSONArray("mesh")
assertEquals("$id mesh cells", mesh.length(), p.meshColors.size)
p.meshColors.forEachIndexed { c, t ->
val wc = mesh.getJSONArray(c)
close("$id mesh[$c].r", t.first, wc.getDouble(0))
close("$id mesh[$c].g", t.second, wc.getDouble(1))
close("$id mesh[$c].b", t.third, wc.getDouble(2))
}
// The four blobs the API 2832 fallback field drifts. These come back as Compose
// `Color`s, which pack an sRGB colour at 8 bits per component — so the table
// round-trips through 1/255 quantisation and the tolerance below IS that quantisation,
// not slack. Anything the contract actually cares about (a mistyped stop, a shifted
// sample point) moves these by far more than one 8-bit step.
val blobs = w.getJSONArray("blobs")
assertEquals("$id blob count", blobs.length(), p.blobColors.size)
p.blobColors.forEachIndexed { b, colour ->
val wb = blobs.getJSONArray(b)
close("$id blob[$b].r", colour.red.toDouble(), wb.getDouble(0), EIGHT_BIT_STEP)
close("$id blob[$b].g", colour.green.toDouble(), wb.getDouble(1), EIGHT_BIT_STEP)
close("$id blob[$b].b", colour.blue.toDouble(), wb.getDouble(2), EIGHT_BIT_STEP)
}
}
}
// The tab-names assertion moved with the tabs: the Skia shell renders them, and its Rust twin
// (`tab_names_match_the_shared_vectors` in pf-console-ui) pins them against the same file.
/**
* The screen-transition contract. The easing is sampled rather than compared as Bézier
* control points: this client evaluates the desktop's analytic `1 (1t)³` directly, while
* SwiftUI can only approximate it samples with a tolerance are the one form all three can
* meet. It is also the assertion that would have caught the curve this client shipped with
* first, a "cubic-bezier(0.215, 0.61, 0.355, 1)" that is a full 0.08 slack at the midpoint.
*/
@Test
fun motionMatchesTheSharedVectors() {
val motion = vectors.getJSONObject("motion")
close("transition", ConsoleMotion.TRANSITION_MS / 1000.0, motion.getDouble("transition_s"))
close("push slide", ConsoleMotion.PUSH_SLIDE.value.toDouble(), motion.getDouble("push_slide_dp"))
close("enter scale", ConsoleMotion.ENTER_SCALE.toDouble(), motion.getDouble("enter_scale"), 1e-5)
close("exit scale", ConsoleMotion.EXIT_SCALE.toDouble(), motion.getDouble("exit_scale"), 1e-5)
close("reveal alpha", ConsoleMotion.REVEAL_ALPHA.toDouble(), motion.getDouble("reveal_alpha"), 1e-5)
val curve = motion.getJSONObject("ease_out_cubic")
val tol = curve.getDouble("tolerance")
val samples = curve.getJSONArray("samples")
assertTrue("the curve needs enough samples to pin it", samples.length() >= 5)
for (i in 0 until samples.length()) {
val s = samples.getJSONObject(i)
val t = s.getDouble("t")
close(
"ease_out_cubic($t)",
ConsoleMotion.EaseOutCubic.transform(t.toFloat()).toDouble(),
s.getDouble("p"),
tol,
)
}
}
}
@@ -33,14 +33,14 @@ class GamepadPaletteTest {
fun tableMatchesTheOtherClients() {
assertEquals(
listOf(
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
"violet", "nebula", "abyss", "ember", "moss", "graphite",
"holo", "sunset", "bloom", "dawn", "mint", "opal",
),
GamepadPalette.ALL.map { it.id },
)
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
assertEquals(7, firstLight)
assertEquals(6, firstLight)
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
// An unknown name is a newer client's palette, not an error.
assertEquals("violet", GamepadPalette.named("chartreuse").id)
@@ -72,25 +72,6 @@ class GamepadPaletteTest {
}
}
/**
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
* not merely the darkest of the dark fields. The blob field this client draws samples the
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
*/
@Test
fun oledIsActuallyBlack() {
val oled = GamepadPalette.named("oled")
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
val darkestOther = GamepadPalette.ALL
.filter { it.id != "oled" && it.stops.isNotEmpty() }
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
}
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
@Test
fun palettesAreHonestAboutLightness() {
@@ -136,9 +117,42 @@ class GamepadPaletteTest {
assertTrue(light.shadeScale < 0.5f)
}
/**
* Every settings row lands in exactly one tab a row missing from the tab map is a setting
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
*/
@Test
fun everySettingsRowHasATab() {
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
assertTrue(rows.isNotEmpty())
assertEquals(rows.size, rows.map { it.id }.toSet().size)
// Profiles is built separately (from the catalog), so no settings row claims it.
assertTrue(rows.none { it.tab == GpTab.PROFILES })
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
assertTrue("$t is empty", rows.any { it.tab == t })
}
}
// The settings-rows tests that lived here pinned the Compose console's row
// catalog (`buildSettingsRows`). That console is gone — the shared Skia shell owns the rows
// now, pinned in Rust (`pf-console-ui/src/screens/settings.rs`).
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
@Test
fun backgroundRowStepsTheSharedKey() {
var s = Settings()
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
fun palette() = rows().first { it.id == "palette" }
assertEquals("violet", s.uiPalette)
assertEquals("Violet", palette().value)
assertTrue("already the first = thud", !palette().adjust(-1))
assertTrue(palette().adjust(1))
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
// A from the last entry wraps home.
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
palette().activate()
assertEquals("violet", s.uiPalette)
// A store written by a newer client shows the palette that is actually drawing.
s = s.copy(uiPalette = "chartreuse")
assertEquals("Violet", palette().value)
}
}
@@ -0,0 +1,97 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
* governed row is inert rather than merely dim.
*
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
* these tests pin both halves the flag AND the refusal to write.
*/
class GamepadSettingsRowsTest {
/** Rows for a given forwarding state, capturing whatever a row writes back. */
private fun rows(
forwarding: Boolean,
sink: MutableList<Settings> = mutableListOf(),
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
av1Capable = true,
) { sink += it }
private fun row(rows: List<GpRow>, id: String): GpRow =
rows.first { it.id == id }
/** Every row that only means something while a controller is actually being forwarded. */
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
@Test
fun `forwarding off dims every row that depends on it`() {
val off = rows(forwarding = false)
for (id in governed) {
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
}
// The master switch itself stays live — otherwise it could never be turned back on.
assertTrue(row(off, "padForward").enabled)
}
@Test
fun `forwarding on leaves them all live`() {
val on = rows(forwarding = true)
for (id in governed) {
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
}
}
@Test
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
val writes = mutableListOf<Settings>()
val off = rows(forwarding = false, sink = writes)
for (id in governed) {
val i = off.indexOfFirst { it.id == id }
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
// What the screen actually does on left/right/A — the whole point is that it no-ops.
liveRow(off, i)?.adjust(1)
liveRow(off, i)?.adjust(-1)
liveRow(off, i)?.activate()
}
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
}
@Test
fun `the same rows do write once forwarding is on`() {
val writes = mutableListOf<Settings>()
val on = rows(forwarding = true, sink = writes)
val i = on.indexOfFirst { it.id == "sc2" }
assertNotNull(liveRow(on, i))
liveRow(on, i)?.activate()
assertEquals(1, writes.size)
assertFalse("activate flips the toggle", writes[0].sc2Capture)
}
/**
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
* where this screen is the only one reachable a TV box has no touch interface to fall back to.
*/
@Test
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
val on = rows(forwarding = true)
val ids = on.map { it.id }
assertTrue("dsCapture row is missing", "dsCapture" in ids)
assertEquals(
"the two passthrough rows belong side by side",
ids.indexOf("sc2") + 1,
ids.indexOf("dsCapture"),
)
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
}

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