Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ff62b087c |
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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 06–09 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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,46 +105,15 @@ 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
|
||||
|
||||
@@ -179,8 +124,8 @@ jobs:
|
||||
# `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.)
|
||||
#
|
||||
@@ -220,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)
|
||||
@@ -253,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"
|
||||
@@ -264,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: |
|
||||
@@ -301,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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -205,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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[*]}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,340 +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 signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
|
||||
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
|
||||
#
|
||||
# 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
|
||||
|
||||
- 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" }
|
||||
$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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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)**.
|
||||
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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 ~90–130 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 28–32 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 28–32 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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* 0–9), 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 A→center, B→back, sticks→D-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
|
||||
* ~90–130 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()
|
||||
}
|
||||
}
|
||||
@@ -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.1–8.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.4–1.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
|
||||
* `capture→displayed` tiled by `host+network` + `decode` + `display` when the platform delivered
|
||||
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
|
||||
* `capture→decoded` headline without the `display` term when it didn't. Reads the 38-double
|
||||
* `capture→decoded` 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 (18–21) when nonzero.
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
|
||||
* the excluded-floor line when one was measured, and the audio plane's own latency (33/34).
|
||||
*
|
||||
* The RESOLVED audio format (35–37) 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.1–8.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 capture→displayed time (see the native
|
||||
* `DisplayTracker`) and this offset is stated in those same terms. Subtracting the floor here would
|
||||
* report an alignment the listener is not getting.
|
||||
*/
|
||||
private fun audioLine(s: DoubleArray): String? {
|
||||
if (s.size < 35) return null
|
||||
val bufferMs = s[33].roundToInt()
|
||||
if (bufferMs <= 0) return null
|
||||
val avOffset = s[34].roundToInt()
|
||||
val avTerm = if (avOffset != 0) " · a/v ${if (avOffset > 0) "+" else ""}$avOffset ms" else ""
|
||||
return "audio buffer $bufferMs ms$avTerm"
|
||||
}
|
||||
|
||||
/**
|
||||
* The RESOLVED audio format from 35–37 — `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 T−5 m / T−1 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,381 +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("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("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("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)
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,755 +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.OkHttpClient
|
||||
import okhttp3.Request
|
||||
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))
|
||||
.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
|
||||
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 { notice("Sending logs isn't available on this device yet") }
|
||||
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("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()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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,448 +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.
|
||||
setOnTouchListener { v, ev ->
|
||||
if (handle == 0L) return@setOnTouchListener false
|
||||
val kind = when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> 1
|
||||
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 28–32 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 − (1−t)³` 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)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [gamepadUiActive] is pure — table-tested over its inputs, and the mirror of the Apple client's
|
||||
* `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a
|
||||
* disagreement here is a device that behaves differently from the same setting.
|
||||
*/
|
||||
class GamepadUiTest {
|
||||
|
||||
/** The default mode is what the switch meant when it was a lone Boolean. */
|
||||
@Test
|
||||
fun whenConnectedWaitsForAPad() {
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||
// A TV is in console mode whatever the mode says — its remote is the only input.
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false))
|
||||
}
|
||||
|
||||
/** Always drops the controller from the decision — but never the switch, which is the one
|
||||
* way back to the touch UI. */
|
||||
@Test
|
||||
fun alwaysIgnoresThePadButNotTheSwitch() {
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||
}
|
||||
|
||||
/** A value a newer client wrote waits for a pad rather than stranding this build in a
|
||||
* layout it has no way back out of. */
|
||||
@Test
|
||||
fun anUnknownModeWaitsForAPad() {
|
||||
assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false))
|
||||
assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false))
|
||||
assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false))
|
||||
}
|
||||
|
||||
/** The shipped default: the console UI still waits for a controller. */
|
||||
@Test
|
||||
fun theDefaultIsUnchangedBehaviour() {
|
||||
val s = Settings()
|
||||
assertTrue(s.gamepadUiEnabled)
|
||||
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||
assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false))
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@ class ProfilesTest {
|
||||
hdrEnabled = false,
|
||||
compositor = 4,
|
||||
audioChannels = 6,
|
||||
audioFormat = AUDIO_FORMAT_LOSSLESS_96,
|
||||
micEnabled = true,
|
||||
touchMode = TouchMode.POINTER,
|
||||
mouseMode = MouseMode.CAPTURE,
|
||||
@@ -68,7 +67,6 @@ class ProfilesTest {
|
||||
assertFalse(out.hdrEnabled)
|
||||
assertEquals(4, out.compositor)
|
||||
assertEquals(6, out.audioChannels)
|
||||
assertEquals(AUDIO_FORMAT_LOSSLESS_96, out.audioFormat)
|
||||
assertTrue(out.micEnabled)
|
||||
assertEquals(TouchMode.POINTER, out.touchMode)
|
||||
assertEquals(MouseMode.CAPTURE, out.mouseMode)
|
||||
@@ -79,7 +77,6 @@ class ProfilesTest {
|
||||
|
||||
// Device-scope settings are not in the overlay at all, so no profile can move them.
|
||||
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
|
||||
assertEquals(base.gamepadUiMode, out.gamepadUiMode)
|
||||
assertEquals(base.libraryEnabled, out.libraryEnabled)
|
||||
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
|
||||
assertEquals(base.sc2Capture, out.sc2Capture)
|
||||
@@ -238,125 +235,6 @@ class ProfilesTest {
|
||||
assertEquals(base, made.first().overrides.apply(base))
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio-format setting is a STRING, and the two numbers it turns into are what the `Hello`
|
||||
* carries — get the mapping wrong and the session either spends 8.5 Mbps it was not asked for
|
||||
* or silently declines to ask for what it was. The Opus row is the load-bearing one: it must
|
||||
* be the `0`/`0` "did not ask" sentinel, because core sets `CLIENT_CAP_AUDIO_HIRES` on ANY
|
||||
* non-zero field — see [theOpusSettingDoesNotAdvertiseTheLosslessCapability].
|
||||
*/
|
||||
@Test
|
||||
fun theAudioFormatSettingMapsToTheWireFieldsItClaims() {
|
||||
assertEquals(
|
||||
AUDIO_FORMAT_WIRE_UNSPECIFIED,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_OPUS).audioFormatWire(),
|
||||
)
|
||||
// Both rate families. The 44.1 one was deferred only for as long as the shared jitter
|
||||
// policy divided by 1 000 before it multiplied (44 100 → 44 samples/ms, every buffer
|
||||
// figure 2.3 % out); core multiplies first now, so these are simply rates.
|
||||
assertEquals(
|
||||
44_100 to 24,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_441).audioFormatWire(),
|
||||
)
|
||||
assertEquals(
|
||||
48_000 to 24,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_48).audioFormatWire(),
|
||||
)
|
||||
assertEquals(
|
||||
88_200 to 24,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_882).audioFormatWire(),
|
||||
)
|
||||
assertEquals(
|
||||
96_000 to 24,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_96).audioFormatWire(),
|
||||
)
|
||||
assertEquals(
|
||||
176_400 to 24,
|
||||
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_1764).audioFormatWire(),
|
||||
)
|
||||
// The default is the legacy request — a fresh install asks for exactly what it always did.
|
||||
assertEquals(AUDIO_FORMAT_WIRE_UNSPECIFIED, Settings().audioFormatWire())
|
||||
// A newer build's value (or a corrupted pref) falls back to Opus rather than reaching the
|
||||
// host as an unrepresentable rate: a settings string must never be able to block a connect.
|
||||
assertEquals(
|
||||
AUDIO_FORMAT_WIRE_UNSPECIFIED,
|
||||
base.copy(audioFormat = "lossless192").audioFormatWire(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠⚠ **A user who chose Standard (Opus) must not advertise `CLIENT_CAP_AUDIO_HIRES`**, and the
|
||||
* only thing standing between them and 1.5 Mbps of PCM they did not ask for is that this pair
|
||||
* is `0`/`0`.
|
||||
*
|
||||
* Core's `advertised_client_caps` sets the bit when EITHER field is non-zero — it keys on "a
|
||||
* format was specified", not on "the format differs from the default", because 48 kHz/16-bit is
|
||||
* both the legacy pair AND the cheapest lossless rung and the other rule would make that rung
|
||||
* unrequestable. The host's gate then accepts 48 kHz/16-bit as a perfectly supported format. So
|
||||
* a client that sends the legacy-looking numbers as its stand-in for "default" opts every one of
|
||||
* its users in, on every host that has not deliberately opted out — which since 2026-08-17 is
|
||||
* every host, `PUNKTFUNK_AUDIO_HIRES` having gone default-ON — with no surface anywhere saying
|
||||
* so: a declined session and a silently granted one look identical from the settings screen.
|
||||
*
|
||||
* This client did exactly that until the four clients were compared. The rule is restated here
|
||||
* rather than reached through core because Kotlin cannot call it; core's own tests pin the other
|
||||
* half.
|
||||
*/
|
||||
@Test
|
||||
fun theOpusSettingDoesNotAdvertiseTheLosslessCapability() {
|
||||
// Core's rule, verbatim: `audio_rate_hz != 0 || audio_bits != 0`.
|
||||
fun asksForHiRes(wire: Pair<Int, Int>) = wire.first != 0 || wire.second != 0
|
||||
|
||||
assertFalse(asksForHiRes(base.copy(audioFormat = AUDIO_FORMAT_OPUS).audioFormatWire()))
|
||||
assertFalse(asksForHiRes(Settings().audioFormatWire()))
|
||||
assertFalse(asksForHiRes(base.copy(audioFormat = "lossless192").audioFormatWire()))
|
||||
// …and every row that IS a lossless choice must ask, or the setting does nothing at all.
|
||||
// That asymmetry is the whole contract.
|
||||
for ((value, _) in AUDIO_FORMAT_OPTIONS.drop(1)) {
|
||||
assertTrue(value, asksForHiRes(base.copy(audioFormat = value).audioFormatWire()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored values are a CROSS-CLIENT contract, shared verbatim with the Apple client's
|
||||
* `AudioFormatChoice` raw values and the desktop `AUDIO_FORMATS`. A profile carries the key
|
||||
* through untouched, so a rename here does not break a round trip loudly — it breaks it
|
||||
* silently, by leaving the other client to fall back to its own global default on a profile
|
||||
* that looks like it applied. Spelled out as literals rather than referenced through the
|
||||
* constants, because a test that reads the constant cannot detect the constant changing.
|
||||
*
|
||||
* The naming rule for anything added later is the kHz figure with the decimal point dropped.
|
||||
*/
|
||||
@Test
|
||||
fun theStoredAudioFormatValuesAreTheOnesEveryOtherClientStores() {
|
||||
assertEquals("opus", AUDIO_FORMAT_OPUS)
|
||||
assertEquals("lossless441", AUDIO_FORMAT_LOSSLESS_441)
|
||||
assertEquals("lossless48", AUDIO_FORMAT_LOSSLESS_48)
|
||||
assertEquals("lossless882", AUDIO_FORMAT_LOSSLESS_882)
|
||||
assertEquals("lossless96", AUDIO_FORMAT_LOSSLESS_96)
|
||||
assertEquals("lossless1764", AUDIO_FORMAT_LOSSLESS_1764)
|
||||
// Opus first (the default), then the lossless rows by ascending rate.
|
||||
assertEquals(
|
||||
listOf(
|
||||
AUDIO_FORMAT_OPUS,
|
||||
AUDIO_FORMAT_LOSSLESS_441,
|
||||
AUDIO_FORMAT_LOSSLESS_48,
|
||||
AUDIO_FORMAT_LOSSLESS_882,
|
||||
AUDIO_FORMAT_LOSSLESS_96,
|
||||
AUDIO_FORMAT_LOSSLESS_1764,
|
||||
),
|
||||
AUDIO_FORMAT_OPTIONS.map { it.first },
|
||||
)
|
||||
// Every offered row resolves to a DISTINCT request — a duplicate would be a menu entry the
|
||||
// wire cannot tell from its neighbour — every lossless one is 24-bit, and exactly one row
|
||||
// (Opus, the first) is the "did not ask" sentinel.
|
||||
val wire = AUDIO_FORMAT_OPTIONS.map { base.copy(audioFormat = it.first).audioFormatWire() }
|
||||
assertEquals(wire.size, wire.toSet().size)
|
||||
assertTrue(wire.drop(1).all { it.second == 24 })
|
||||
assertEquals(1, wire.count { it == AUDIO_FORMAT_WIRE_UNSPECIFIED })
|
||||
assertEquals(AUDIO_FORMAT_WIRE_UNSPECIFIED, wire.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mintedIdsAreWellFormed() {
|
||||
val id = newProfileId()
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [sc2BluetoothGrantOffered] is pure — table-tested over its inputs.
|
||||
*
|
||||
* The rule exists because a Steam Controller 2 paired over Bluetooth is invisible to this client
|
||||
* until `BLUETOOTH_CONNECT` is granted (the bonded list answers "nothing is paired" rather than
|
||||
* refusing), and nothing ever asked for it — so the pad silently never engaged while the same
|
||||
* controller over USB worked. The offer has to reach those users without becoming a Bluetooth
|
||||
* prompt for everyone else, which is the whole content of these assertions.
|
||||
*/
|
||||
class Sc2BluetoothGrantTest {
|
||||
|
||||
/** The reported case: an SC2 sitting in lizard mode that we cannot capture. */
|
||||
@Test
|
||||
fun offeredWhenAnSc2IsAttachedButBluetoothIsNot() {
|
||||
assertTrue(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = false,
|
||||
usbSc2 = false,
|
||||
sc2Attached = true,
|
||||
anyPadDetected = false,
|
||||
),
|
||||
)
|
||||
// Still offered next to other working pads — the SC2 is the one we can't reach.
|
||||
assertTrue(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = false,
|
||||
usbSc2 = false,
|
||||
sc2Attached = true,
|
||||
anyPadDetected = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The probe reads an SC2's USB identity, which we cannot assume a BLE stack reports. When it
|
||||
* misses, "no controller detected" is exactly when a blind spot is worth naming.
|
||||
*/
|
||||
@Test
|
||||
fun offeredWhenNothingWasDetectedAtAll() {
|
||||
assertTrue(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = false,
|
||||
usbSc2 = false,
|
||||
sc2Attached = false,
|
||||
anyPadDetected = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Never a prompt for someone with working controllers and no sign of an SC2. */
|
||||
@Test
|
||||
fun notOfferedToUsersWithNoSignOfAnSc2() {
|
||||
assertFalse(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = false,
|
||||
usbSc2 = false,
|
||||
sc2Attached = false,
|
||||
anyPadDetected = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Granting it changes nothing that is already captured over USB — wired and Puck alike. */
|
||||
@Test
|
||||
fun notOfferedWhenTheSc2IsOnUsb() {
|
||||
assertFalse(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = false,
|
||||
usbSc2 = true,
|
||||
sc2Attached = true,
|
||||
anyPadDetected = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Nothing to ask for once it is held — including on releases that grant it at install time. */
|
||||
@Test
|
||||
fun notOfferedOncePermitted() {
|
||||
for (attached in listOf(true, false)) {
|
||||
for (pads in listOf(true, false)) {
|
||||
assertFalse(
|
||||
sc2BluetoothGrantOffered(
|
||||
permissionGranted = true,
|
||||
usbSc2 = false,
|
||||
sc2Attached = attached,
|
||||
anyPadDetected = pads,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||