Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647e2b8891 | ||
|
|
3c096835c2 |
@@ -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,15 +63,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. 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"
|
||||
@@ -113,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: |
|
||||
@@ -213,26 +184,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.
|
||||
- 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'
|
||||
--stacktrace
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
env:
|
||||
@@ -242,18 +193,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.
|
||||
@@ -263,12 +211,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)
|
||||
@@ -299,13 +245,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
|
||||
@@ -339,8 +283,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
|
||||
@@ -348,16 +291,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)
|
||||
|
||||
@@ -81,14 +81,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:
|
||||
@@ -228,39 +220,6 @@ jobs:
|
||||
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,19 +252,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
|
||||
@@ -332,21 +280,6 @@ jobs:
|
||||
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
|
||||
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
|
||||
|
||||
@@ -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,22 +320,81 @@ 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
|
||||
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
||||
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
||||
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
||||
#
|
||||
# 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.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
# 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)
|
||||
- name: Build the patched gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
|
||||
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
|
||||
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
|
||||
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
|
||||
# a half-built result impossible to ship.
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
|
||||
set -x
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
|
||||
apt-get build-dep -y gamescope || true
|
||||
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
|
||||
# gamescope — where noble has one at all — so it misses what the master tree needs, and
|
||||
# wayland-protocols is the gap that actually stops the build: meson dies in
|
||||
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
|
||||
# file was found", because the tree has no wrap fallback for it. That is what happened on
|
||||
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
|
||||
# shipped with no gamescope .deb while the notes said it had one.
|
||||
apt-get install -y --no-install-recommends wayland-protocols
|
||||
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
|
||||
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
|
||||
# releases should not fail the job. (No libstdc++ static package is needed here — g++
|
||||
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
|
||||
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
|
||||
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
|
||||
# purpose: a single transaction aborts wholesale on one unknown package, which would
|
||||
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
|
||||
# the missing one named; the end-of-job gate below is what actually decides.
|
||||
for p in 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 \
|
||||
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 \
|
||||
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
|
||||
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
|
||||
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
|
||||
cmake xwayland; do
|
||||
apt-get install -y --no-install-recommends "$p" \
|
||||
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
|
||||
done
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
|
||||
# skips the host .deb's own publish + release-attach steps below, which is how the
|
||||
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
|
||||
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
|
||||
# meant to protect the release. Never let a missing EXTRA stop a good artifact
|
||||
# shipping; go red afterwards instead.
|
||||
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
- name: Build punktfunk-gamescope .deb
|
||||
# Picked up by the publish loop below, which globs dist/*.deb.
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
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 — skipping its .deb"
|
||||
fi
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
@@ -450,111 +423,25 @@ 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)
|
||||
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
|
||||
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
|
||||
# because every failure on this path was a `::warning::` that returned 0.
|
||||
#
|
||||
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
|
||||
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
|
||||
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
|
||||
# gate protecting the release withheld the fix for it. Everything good ships first; the job
|
||||
# goes red afterwards.
|
||||
- name: A stable tag must ship the gamescope .deb
|
||||
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
|
||||
shopt -s nullglob
|
||||
built=(dist/punktfunk-gamescope_*.deb)
|
||||
if [ ${#built[@]} -eq 0 ]; then
|
||||
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
|
||||
exit 1
|
||||
fi
|
||||
echo "gamescope .deb present: ${built[*]}"
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
||||
@@ -572,14 +459,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 +503,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 +517,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 +543,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 }}
|
||||
|
||||
@@ -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.
|
||||
@@ -363,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/**'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,24 +7,10 @@
|
||||
# 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
|
||||
@@ -36,12 +22,6 @@
|
||||
# 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.
|
||||
#
|
||||
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
|
||||
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
|
||||
# being the one package nothing here compiles. 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. Run the dispatch after a flake.lock bump.
|
||||
#
|
||||
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
|
||||
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
|
||||
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
|
||||
@@ -86,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:
|
||||
@@ -189,13 +165,3 @@ jobs:
|
||||
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' }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-gamescope
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -51,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"
|
||||
@@ -88,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
|
||||
@@ -106,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 /
|
||||
@@ -148,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.
|
||||
@@ -214,20 +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: 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 }}
|
||||
@@ -293,11 +220,7 @@ jobs:
|
||||
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
|
||||
@@ -315,40 +238,15 @@ jobs:
|
||||
# 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
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/gamescope/build-gamescope-rpm.sh \
|
||||
--stage gs-cache \
|
||||
--binary gs-cache/punktfunk-gamescope \
|
||||
--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 }}
|
||||
@@ -376,9 +274,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
|
||||
@@ -389,19 +287,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.
|
||||
|
||||
@@ -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,7 +143,7 @@ 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.
|
||||
@@ -297,17 +291,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
|
||||
@@ -429,26 +415,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 +452,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 }}
|
||||
@@ -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.29.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
version = "0.26.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 |
@@ -48,11 +48,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 +64,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,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
|
||||
@@ -102,10 +99,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,9 +13,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 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.
|
||||
@@ -64,12 +61,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
|
||||
|
||||
@@ -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,352 +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 prompts that say the SAME thing in both interfaces.
|
||||
//
|
||||
// Every one of these existed twice — a Material `AlertDialog` in ConnectDialogs.kt and a console
|
||||
// glass card in GamepadDialogs.kt — with the two copies maintained by hand. Predictably they
|
||||
// drifted, and always in the direction of the console losing something: "Pair with PIN…" lost its
|
||||
// ellipsis, "if no prompt appears when you tap Allow" became "after Allow", and the speed test
|
||||
// stopped telling console users which layer Apply would write to at all.
|
||||
//
|
||||
// What is shared here is the DESCRIPTION of a prompt — a title, a list of [DialogAction]s and a
|
||||
// body — and what stays per-interface is only how that description is drawn. That split is the
|
||||
// whole point: a copy change now lands in both places because there is only one place.
|
||||
//
|
||||
// ⚠ Deliberately NOT unified, and they belong apart: the PIN ceremony (a numeric keyboard field
|
||||
// and an editable device name on touch; four D-pad digit slots on the console — different input
|
||||
// models, not different skins), Add/Edit Host (a bottom sheet and a full screen with its own
|
||||
// on-screen keyboard), and the host action list (an anchored dropdown vs a modal stack, and the
|
||||
// touch one grows a row per profile).
|
||||
|
||||
/**
|
||||
* One prompt, drawn as whichever interface is running.
|
||||
*
|
||||
* [actions] is ordered PRIMARY FIRST — the console stacks them in that order with the cursor on
|
||||
* the first, and the touch renderer lifts that same first action into `confirmButton` and lays the
|
||||
* rest out beside it. One order, two idioms, no per-dialog bookkeeping.
|
||||
*
|
||||
* The two renderers cannot be one tree: an `AlertDialog` composes into its own platform window
|
||||
* while [ConsoleModal] is a plain Box in the calling tree — which is also why the console one
|
||||
* needs a `BackHandler` and the caller's `navActive` gate while the touch one needs neither.
|
||||
*/
|
||||
@Composable
|
||||
fun PunktfunkDialog(
|
||||
gamepadUi: Boolean,
|
||||
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,
|
||||
) {
|
||||
if (gamepadUi) {
|
||||
GamepadDialog(title = title, onDismiss = onDismiss, actions = actions, body = body)
|
||||
return
|
||||
}
|
||||
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 in either interface. */
|
||||
@Composable
|
||||
private fun PromptText(text: String, gamepadUi: Boolean) {
|
||||
val ink = LocalGamepadInk.current
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (gamepadUi) ink.fg(0.7f) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
|
||||
@Composable
|
||||
fun TrustNewHostPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onTrust: () -> Unit,
|
||||
onPairInstead: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
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}.", gamepadUi)
|
||||
pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}…", gamepadUi) }
|
||||
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.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
|
||||
@Composable
|
||||
fun FingerprintChangedPrompt(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onRepair: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
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.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
gamepadUi: Boolean,
|
||||
pt: PendingTrust,
|
||||
onRequestAccess: () -> Unit,
|
||||
onUsePin: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
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.", gamepadUi)
|
||||
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.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(gamepadUi: Boolean, hostLabel: String, onCancel: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
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 = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
"Approve this device on $hostLabel.",
|
||||
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
PromptText(
|
||||
"Open the host's console (or web UI) and approve “$label”. It connects " +
|
||||
"automatically once you approve — no PIN needed.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
gamepadUi: Boolean,
|
||||
onAllow: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
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.",
|
||||
gamepadUi,
|
||||
)
|
||||
PromptText(
|
||||
"If no prompt appears after you allow it, enable “Nearby devices” for Punktfunk in " +
|
||||
"system settings.",
|
||||
gamepadUi,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
gamepadUi: Boolean,
|
||||
hostName: String,
|
||||
target: SpeedTestTarget,
|
||||
phase: SpeedTestPhase,
|
||||
onApply: (toProfile: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val done = phase as? SpeedTestPhase.Done
|
||||
PunktfunkDialog(
|
||||
gamepadUi = gamepadUi,
|
||||
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, gamepadUi)
|
||||
when (phase) {
|
||||
SpeedTestPhase.Connecting -> PromptText("Connecting…", gamepadUi)
|
||||
SpeedTestPhase.Measuring ->
|
||||
PromptText(
|
||||
"Measuring — the host is bursting test traffic for two seconds.",
|
||||
gamepadUi,
|
||||
)
|
||||
is SpeedTestPhase.Failed -> Text(
|
||||
phase.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (gamepadUi) ink.danger else 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 = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
PromptText(
|
||||
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
|
||||
gamepadUi,
|
||||
)
|
||||
PromptText(speedTestTargetNote(target), gamepadUi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,10 +37,8 @@ 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
|
||||
@@ -56,10 +51,7 @@ import io.unom.punktfunk.kit.link.HostResolution
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.LibraryReturn
|
||||
import io.unom.punktfunk.models.Tab
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun App(forceGamepadUi: Boolean = false) {
|
||||
@@ -75,7 +67,7 @@ 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) }
|
||||
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
|
||||
@@ -140,9 +132,9 @@ 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
|
||||
}
|
||||
@@ -155,8 +147,8 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
onConnected = { session = it },
|
||||
deepLink = pendingLink,
|
||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||
reopenLibrary = reopenLibrary,
|
||||
onReopenLibraryHandled = { reopenLibrary = null },
|
||||
reopenLibraryHostId = reopenLibraryHostId,
|
||||
onReopenLibraryHandled = { reopenLibraryHostId = null },
|
||||
)
|
||||
} else {
|
||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||
@@ -219,9 +211,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(
|
||||
@@ -254,21 +245,8 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
*/
|
||||
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
|
||||
|
||||
/**
|
||||
* Which console screen the gamepad shell is showing, and how deep it sits — Home is the root, and
|
||||
* everything reachable from it is one level in. The DEPTH is what decides whether a change is a
|
||||
* push or a pop, and therefore which way the screens travel.
|
||||
*/
|
||||
private enum class GamepadScreen(val depth: Int) {
|
||||
Home(0),
|
||||
Settings(1),
|
||||
Library(1),
|
||||
// Reached FROM Settings, not from Home, so they sit a level deeper again — which is precisely
|
||||
// what makes Settings → Controllers travel like a push and the way back like a pop. Give one of
|
||||
// these depth 1 and the transition would read as a sideways swap between two peers.
|
||||
Controllers(2),
|
||||
Licenses(2),
|
||||
}
|
||||
/** Which console screen the gamepad shell is showing. */
|
||||
private enum class GamepadScreen { Home, Settings, Library }
|
||||
|
||||
/**
|
||||
* The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch:
|
||||
@@ -283,44 +261,28 @@ fun GamepadShell(
|
||||
deepLink: String? = null,
|
||||
onDeepLinkHandled: () -> Unit = {},
|
||||
/**
|
||||
* Open this library shelf 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.
|
||||
* 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.
|
||||
*/
|
||||
reopenLibrary: LibraryReturn? = null,
|
||||
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) }
|
||||
// Which of that host's shelves is open: the pinned card's profile id, or null for the host's
|
||||
// own tile (design §5.2a). Held beside `libraryHost` because it is the same navigation fact —
|
||||
// a pinned card and its host are two tiles, and the library belongs to whichever you pressed.
|
||||
var libraryPinId by remember { mutableStateOf<String?>(null) }
|
||||
// Where the settings screen was when a sub-screen took over. The shell's AnimatedContent
|
||||
// discards a screen's `remember`s the moment it stops being the target, so a trip out to the
|
||||
// Controllers view and back would otherwise land on the Stream tab's first row — the couch
|
||||
// equivalent of a browser losing your scroll position on Back. Held here because this is the
|
||||
// only thing that outlives the screen.
|
||||
var settingsPlace by remember { mutableStateOf<GpSettingsPlace?>(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(reopenLibrary) {
|
||||
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
|
||||
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 }
|
||||
// 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.
|
||||
?.let { kh ->
|
||||
libraryHost = kh
|
||||
libraryPinId = pinId?.takeIf { it in kh.pinnedProfileIds }
|
||||
screen = GamepadScreen.Library
|
||||
}
|
||||
?.let { libraryHost = it; screen = GamepadScreen.Library }
|
||||
onReopenLibraryHandled()
|
||||
}
|
||||
|
||||
@@ -335,50 +297,12 @@ fun GamepadShell(
|
||||
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
|
||||
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
|
||||
|
||||
// The console's screen transition, and the desktop console's contract rather than a plain
|
||||
// cross-fade (see ConsoleMotion for the numbers and where they come from): a PUSH slides the
|
||||
// incoming screen up out of a fade while the outgoing one recedes; a POP runs it backwards, the
|
||||
// leaving screen sliding down and the revealed one growing back. Direction comes from the
|
||||
// screens' nav DEPTH, so Settings → Home pops even though nothing tracks a stack.
|
||||
//
|
||||
// Each slot's controller nav is gated on being the CURRENT target (`s == screen`), so mid-
|
||||
// transition 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 moves.
|
||||
val animated = animationsEnabled()
|
||||
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
|
||||
// Measured INSIDE the console's own density, not the device's: on a TV the console UI runs at a
|
||||
// reduced density to shrink the 10-foot layout, and a slide sized in device pixels would travel
|
||||
// further than every other dp in the same animation.
|
||||
val slidePx = with(LocalDensity.current) { ConsoleMotion.PUSH_SLIDE.toPx() }.roundToInt()
|
||||
AnimatedContent(
|
||||
targetState = screen,
|
||||
transitionSpec = {
|
||||
if (!animated) {
|
||||
// Reduce-motion: no travel, no scale — just a fast cross-fade, the same courtesy
|
||||
// the frozen backdrop pays.
|
||||
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
|
||||
fadeOut(tween(ConsoleMotion.REDUCED_MS))
|
||||
} else if (targetState.depth > initialState.depth) {
|
||||
(
|
||||
fadeIn(ConsoleMotion.ease()) +
|
||||
slideInVertically(ConsoleMotion.ease()) { slidePx } +
|
||||
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.ENTER_SCALE)
|
||||
) togetherWith (
|
||||
fadeOut(ConsoleMotion.ease()) +
|
||||
scaleOut(ConsoleMotion.ease(), targetScale = ConsoleMotion.EXIT_SCALE)
|
||||
)
|
||||
} else {
|
||||
(
|
||||
fadeIn(ConsoleMotion.ease(), initialAlpha = ConsoleMotion.REVEAL_ALPHA) +
|
||||
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.EXIT_SCALE)
|
||||
) togetherWith (
|
||||
fadeOut(ConsoleMotion.ease()) +
|
||||
slideOutVertically(ConsoleMotion.ease()) { slidePx }
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "consoleScreen",
|
||||
) { s ->
|
||||
// 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,
|
||||
@@ -388,33 +312,13 @@ fun GamepadShell(
|
||||
onDeepLinkHandled = onDeepLinkHandled,
|
||||
gamepadUi = true,
|
||||
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||
onOpenLibrary = { host, pinId ->
|
||||
libraryHost = host
|
||||
libraryPinId = pinId
|
||||
screen = GamepadScreen.Library
|
||||
},
|
||||
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
|
||||
navGate = s == screen,
|
||||
)
|
||||
GamepadScreen.Settings -> GamepadSettingsScreen(
|
||||
initial = settings,
|
||||
onChange = onSettingsChange,
|
||||
// Leaving for HOME forgets the place: coming back in from the carousel should start
|
||||
// at the top of the first section, exactly as it always has. Only a sub-screen's
|
||||
// Back is a return.
|
||||
onBack = { screen = GamepadScreen.Home; settingsPlace = null },
|
||||
navActive = s == screen,
|
||||
resume = settingsPlace,
|
||||
onPlace = { settingsPlace = it },
|
||||
onOpenControllers = { screen = GamepadScreen.Controllers },
|
||||
onOpenLicenses = { screen = GamepadScreen.Licenses },
|
||||
)
|
||||
GamepadScreen.Controllers -> ConsoleControllersScreen(
|
||||
gamepadSetting = settings.gamepad,
|
||||
onBack = { screen = GamepadScreen.Settings },
|
||||
navActive = s == screen,
|
||||
)
|
||||
GamepadScreen.Licenses -> ConsoleLicensesScreen(
|
||||
onBack = { screen = GamepadScreen.Settings },
|
||||
onBack = { screen = GamepadScreen.Home },
|
||||
navActive = s == screen,
|
||||
)
|
||||
GamepadScreen.Library -> libraryHost?.let { host ->
|
||||
@@ -422,9 +326,8 @@ fun GamepadShell(
|
||||
host = host,
|
||||
settings = settings,
|
||||
onLaunched = onConnected,
|
||||
onBack = { screen = GamepadScreen.Home; libraryHost = null; libraryPinId = null },
|
||||
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
||||
navActive = s == screen,
|
||||
pinnedProfileId = libraryPinId,
|
||||
)
|
||||
} ?: run { screen = GamepadScreen.Home }
|
||||
}
|
||||
@@ -434,128 +337,3 @@ fun GamepadShell(
|
||||
|
||||
/** 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
|
||||
|
||||
// --- Showing a TOUCH-written screen on the console's field -------------------------------------
|
||||
//
|
||||
// Two screens (Controllers, Licenses) exist once and are shown in both interfaces. They live beside
|
||||
// the shell rather than in `GamepadChrome.kt` because they are about the SHELL's job — putting a
|
||||
// screen that was written for one interface onto the other's field — rather than about the console's
|
||||
// own material.
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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,339 +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,
|
||||
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 {
|
||||
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
|
||||
@@ -205,10 +204,7 @@ internal fun ConnectTakeover(
|
||||
) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
// The backdrop runs full-bleed; the COPY keeps clear of the bars and the cutout. In
|
||||
// landscape a hole punch is a side inset deeper than this 40 dp gutter, so centred text
|
||||
// would otherwise sit under the camera.
|
||||
Modifier.consoleSafeArea().padding(horizontal = 40.dp).widthIn(max = 460.dp),
|
||||
Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
@@ -243,19 +239,7 @@ internal fun ConnectTakeover(
|
||||
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
|
||||
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
|
||||
}
|
||||
// The SAME bottom-start spot every console screen pins its legend at — this takeover sat
|
||||
// its pill at bottom-CENTRE, so pressing Connect made the one piece of chrome that is
|
||||
// supposed to read as fixed jump halfway across the screen (second on-glass verdict).
|
||||
val landscape =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints)
|
||||
}
|
||||
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,197 +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(
|
||||
gamepadUi: Boolean,
|
||||
/** 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,
|
||||
// ---- console host options (Up on a saved carousel tile) ---------------------------------
|
||||
optionsTarget: HostCardEntry?,
|
||||
onDismissOptions: () -> Unit,
|
||||
libraryEnabled: Boolean,
|
||||
onOpenLibrary: (KnownHost, String?) -> Unit,
|
||||
onWake: (KnownHost) -> Unit,
|
||||
onSpeedTest: (KnownHost) -> Unit,
|
||||
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
|
||||
onEditHost: (KnownHost) -> Unit,
|
||||
onForgetHost: (KnownHost) -> Unit,
|
||||
onTogglePin: (KnownHost, StreamProfile) -> 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(
|
||||
gamepadUi, pt,
|
||||
onTrust = { onTrustNew(pt) },
|
||||
onPairInstead = onPair,
|
||||
onDismiss = { onPendingTrustChange(null) },
|
||||
)
|
||||
PendingTrust.Kind.FP_CHANGED ->
|
||||
FingerprintChangedPrompt(gamepadUi, pt, onPair) { onPendingTrustChange(null) }
|
||||
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessPrompt(
|
||||
gamepadUi, pt,
|
||||
onRequestAccess = { onRequestAccess(pt) },
|
||||
onUsePin = onPair,
|
||||
onDismiss = { onPendingTrustChange(null) },
|
||||
)
|
||||
PendingTrust.Kind.PAIR -> {
|
||||
val onSavePaired = { fp: String -> onPaired(pt, fp) }
|
||||
if (gamepadUi) {
|
||||
GamepadPairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
|
||||
} else {
|
||||
PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
awaitingHostName?.let { hostLabel ->
|
||||
AwaitingApprovalPrompt(gamepadUi, hostLabel = hostLabel, onCancel = onCancelApproval)
|
||||
}
|
||||
|
||||
// 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 = !isOnline(kh)
|
||||
GamepadHostOptionsDialog(
|
||||
hostName = kh.name,
|
||||
canWake = kh.mac.isNotEmpty() && offline,
|
||||
onWake = { onDismissOptions(); onWake(kh) },
|
||||
// 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. A PIN card
|
||||
// gets it too, opening its own shelf: unlike wake/edit/forget, the library is a way to
|
||||
// start the card, not a property of the host.
|
||||
onLibrary = if (libraryEnabled) {
|
||||
{ onDismissOptions(); onOpenLibrary(kh, pin?.id) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onSpeedTest = if (pin == null) {
|
||||
{ onDismissOptions(); onSpeedTest(kh) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCopyLink = { onDismissOptions(); onCopyLink(kh, pin) },
|
||||
onEdit = { onDismissOptions(); onEditHost(kh) },
|
||||
onForget = { onForgetHost(kh); onDismissOptions() },
|
||||
onDismiss = onDismissOptions,
|
||||
// A pin's only action: unpinning touches neither the host nor the profile.
|
||||
onUnpin = pin?.let { p -> { onTogglePin(kh, p); onDismissOptions() } },
|
||||
profileName = pin?.name,
|
||||
)
|
||||
}
|
||||
|
||||
if (speedTest != null && speedTestTarget != null) {
|
||||
SpeedTestPrompt(
|
||||
gamepadUi, speedTest.host.name, speedTestTarget, speedTestPhase,
|
||||
onApplySpeedTest, onDismissSpeedTest,
|
||||
)
|
||||
}
|
||||
|
||||
editTarget?.let { kh ->
|
||||
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 = onDismissEdit,
|
||||
editHost = kh,
|
||||
suggestedMacs = editSuggestedMacs,
|
||||
onSave = onSaveHost,
|
||||
// Shared clipboard and the profile binding — the two host decisions that used to
|
||||
// exist only in the touch edit sheet, which a TV box has no way to reach.
|
||||
profiles = profiles,
|
||||
)
|
||||
} else {
|
||||
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(
|
||||
gamepadUi,
|
||||
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. Rides over both the touch
|
||||
// grid and the console home.
|
||||
ConnectOverlay(
|
||||
connectingHostName = connectingHostName,
|
||||
waker = waker,
|
||||
gamepadUi = gamepadUi,
|
||||
onCancelConnect = onCancelConnect,
|
||||
)
|
||||
}
|
||||
@@ -11,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
|
||||
@@ -20,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
|
||||
@@ -40,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
|
||||
@@ -74,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,
|
||||
@@ -100,9 +120,7 @@ fun ConnectScreen(
|
||||
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
|
||||
gamepadUi: Boolean = false,
|
||||
onOpenSettings: () -> 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.
|
||||
onOpenLibrary: (KnownHost, String?) -> 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
|
||||
@@ -243,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
|
||||
}
|
||||
@@ -322,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
|
||||
@@ -653,6 +651,52 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -738,63 +782,79 @@ 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
|
||||
}
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
fun forgetHost(kh: KnownHost) {
|
||||
knownHostStore.remove(kh)
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
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 },
|
||||
),
|
||||
)
|
||||
}
|
||||
GamepadHome(
|
||||
tiles = buildHomeTiles(
|
||||
savedHosts = savedHosts,
|
||||
profiles = profiles,
|
||||
pinsFor = profileStore::pinsFor,
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
isOnline = { it.isOnline(discovered, reachable) },
|
||||
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
|
||||
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
|
||||
onAddHost = { showManualSheet = true },
|
||||
),
|
||||
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.
|
||||
// ⚠ `speedTest` belongs in this list and was missing. It LOOKED covered by `!connecting`,
|
||||
// and is — right up until the measurement finishes: `startSpeedTest` clears `connecting`
|
||||
// before its Done/Failed card is dismissed, so from that moment the card AND the
|
||||
// carousel underneath both consumed the pad. One A then dismissed the card and started
|
||||
// a connect. Every other modal on this screen is named here for exactly this reason.
|
||||
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
|
||||
awaiting == null && editTarget == null && optionsTarget == null &&
|
||||
speedTest == null && waker.waking == null && !lnpPrompt,
|
||||
waker.waking == null && !lnpPrompt,
|
||||
onActivate = { it.activate() },
|
||||
onOpenLibrary = { tile -> tile.knownHost?.let { onOpenLibrary(it, tile.pinnedProfileId) } },
|
||||
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOptions = { tile ->
|
||||
tile.knownHost?.let { kh ->
|
||||
@@ -803,35 +863,239 @@ fun ConnectScreen(
|
||||
},
|
||||
)
|
||||
} else {
|
||||
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) },
|
||||
onRescan = { discovery.restart() },
|
||||
onAddHost = { showManualSheet = true },
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (gamepadUi) {
|
||||
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
|
||||
@@ -859,81 +1123,148 @@ fun ConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
gamepadUi = gamepadUi,
|
||||
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
|
||||
},
|
||||
optionsTarget = optionsTarget,
|
||||
onDismissOptions = { optionsTarget = null },
|
||||
libraryEnabled = settings.libraryEnabled,
|
||||
onOpenLibrary = onOpenLibrary,
|
||||
onWake = { kh -> wakeHost(kh) },
|
||||
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
|
||||
onCopyLink = { kh, pin -> copyLink(kh, pin) },
|
||||
onEditHost = { kh -> editTarget = kh },
|
||||
onForgetHost = { kh -> forgetHost(kh) },
|
||||
onTogglePin = { kh, p -> togglePin(kh, p) },
|
||||
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(
|
||||
@@ -941,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() },
|
||||
)
|
||||
}
|
||||
@@ -953,11 +1295,8 @@ fun ConnectScreen(
|
||||
* One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned
|
||||
* host+profile cards. Pins are additive presentation state on the host record — never duplicated
|
||||
* host entries, which would fork pairing, trust and renames (design §5.2a).
|
||||
*
|
||||
* The console reuses it deliberately: its options dialog acts on a host-or-pin exactly as the touch
|
||||
* card's overflow menu does, and one currency for "which card is this" keeps the two from drifting.
|
||||
*/
|
||||
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"}"
|
||||
}
|
||||
|
||||
@@ -966,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
|
||||
@@ -978,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
|
||||
@@ -988,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
|
||||
@@ -998,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")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
@@ -12,17 +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.ScrollState
|
||||
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
|
||||
@@ -45,18 +39,13 @@ 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
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
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
|
||||
|
||||
@@ -66,161 +55,16 @@ import kotlinx.coroutines.delay
|
||||
* 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.
|
||||
*
|
||||
* This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's
|
||||
* field. Both drive [ControllersBody] — the screen exists once, and the support answer it gives has
|
||||
* to be the same one whichever interface asked.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
|
||||
fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
BackHandler(onBack = onBack)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = rememberScrollState(),
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// The touch screen holds the probes for its whole life: 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, so there is no one to hand them to.
|
||||
observeInput = true,
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same screen on the console's field — the couch route to it, which a TV box has no other way to
|
||||
* reach (there is no touch interface to fall back to there, which is exactly why this matters).
|
||||
*
|
||||
* Navigation, and how the pad is shared with the test:
|
||||
* * up/down scrolls, the shoulders page — the body is cards and prose with no focusable rows, and
|
||||
* Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]);
|
||||
* * A starts the input test, which is the one thing on this screen a controller can act on;
|
||||
* * while the test runs it OWNS the pad — that is the whole point of it — so this screen's nav
|
||||
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleControllersScreen(
|
||||
gamepadSetting: Int,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
val scroll = rememberScrollState()
|
||||
val scrollBy = rememberConsoleScroller(scroll)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
|
||||
GamepadNavEffect2D(
|
||||
// Off while the test runs: both want the same single probe slot, and the test is the one
|
||||
// the user just asked for. The identity check in each teardown (here and in the body) is
|
||||
// what makes the handover safe in either direction.
|
||||
active = navActive && !testing,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> scrollBy(-1, false)
|
||||
NavDir.DOWN -> scrollBy(1, false)
|
||||
// Nothing on this screen steps sideways; paging is the shoulders' job.
|
||||
NavDir.LEFT, NavDir.RIGHT -> {}
|
||||
}
|
||||
},
|
||||
onActivate = { testing = true },
|
||||
onShoulder = { delta -> scrollBy(delta, true) },
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ,
|
||||
// and the aurora is ambience. Only the content takes the safe area.
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
// The body is written against the touch theme; on the console field it has to be inked
|
||||
// from the palette or it is grey-on-pastel over the six pale palettes.
|
||||
ConsoleInkedTheme {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = scroll,
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// Only while testing: the rest of the time the screen's own nav holds the
|
||||
// probes, so the "Last input" line is a test-time readout here rather than
|
||||
// an always-on one. A pad that reaches this screen at all has already
|
||||
// proved it is seen — by moving the cursor here.
|
||||
observeInput = testing,
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
// Clears the floating legend zone, like every other console list.
|
||||
bottom = ConsoleLegendClearance,
|
||||
),
|
||||
) {
|
||||
ConsoleHeader("Connected controllers", horizontalInset = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
if (testing) {
|
||||
// The rule, stated at the moment it applies: while the test runs, B is a BUTTON
|
||||
// UNDER TEST like any other — it lights its own chip — so only a hold ends the
|
||||
// test, after which B is the universal Back again. Tappable as the touch hatch.
|
||||
listOf(PadGlyph.hint('B', "Hold to finish") { testing = false })
|
||||
} else {
|
||||
listOfNotNull(
|
||||
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
|
||||
// Advertised only where they exist — a TV remote has no shoulders, and
|
||||
// claiming otherwise is both a lie and the reason a narrow legend overflows.
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
|
||||
PadGlyph.hint('A', "Test inputs") { testing = true },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they
|
||||
* differ: the touch screen pads for a thumb and titles with the Material headline, the console pads
|
||||
* to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader].
|
||||
*
|
||||
* [observeInput] decides whether this body installs the shared MainActivity probes at all — see the
|
||||
* two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun ControllersBody(
|
||||
gamepadSetting: Int,
|
||||
scroll: ScrollState,
|
||||
testing: Boolean,
|
||||
onTestingChange: (Boolean) -> Unit,
|
||||
observeInput: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
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()
|
||||
@@ -240,31 +84,17 @@ private fun ControllersBody(
|
||||
|
||||
// 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 per `observeInput` and then read these for the life of that
|
||||
// installation. `testing` and the callback arrive as parameters now, so capturing them plainly
|
||||
// would freeze the values they 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(observeInput) {
|
||||
// Stable probe refs, and a teardown that releases the slot only if WE still hold it — the
|
||||
// rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the
|
||||
// slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving
|
||||
// here would kill the pad navigation the arriving screen had just installed. The same
|
||||
// teardown also runs when this screen hands the pad to its own input test and back.
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ event ->
|
||||
DisposableEffect(Unit) {
|
||||
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
|
||||
@@ -272,34 +102,13 @@ private fun ControllersBody(
|
||||
}
|
||||
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)
|
||||
@@ -315,43 +124,31 @@ private fun ControllersBody(
|
||||
)
|
||||
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
consuming
|
||||
}
|
||||
if (observeInput) {
|
||||
activity?.padKeyProbe = keyProbe
|
||||
activity?.padMotionProbe = motionProbe
|
||||
testing
|
||||
}
|
||||
onDispose {
|
||||
activity?.let { a ->
|
||||
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
|
||||
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
|
||||
}
|
||||
activity?.padKeyProbe = null
|
||||
activity?.padMotionProbe = null
|
||||
}
|
||||
}
|
||||
// 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),
|
||||
) {
|
||||
heading()
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
@@ -376,16 +173,12 @@ private fun ControllersBody(
|
||||
}
|
||||
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 {
|
||||
@@ -406,24 +199,11 @@ private fun ControllersBody(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,19 +212,13 @@ private fun ControllersBody(
|
||||
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)
|
||||
@@ -481,90 +255,6 @@ private fun ControllersBody(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -789,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,
|
||||
@@ -810,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)"
|
||||
@@ -825,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",
|
||||
@@ -907,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) {
|
||||
@@ -1015,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"
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package io.unom.punktfunk
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -19,9 +18,10 @@ 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.width
|
||||
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
|
||||
@@ -31,12 +31,6 @@ 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.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.toggleableState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
@@ -53,6 +47,7 @@ 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
|
||||
@@ -74,20 +69,6 @@ private const val KB_ROWS = 5
|
||||
|
||||
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
|
||||
|
||||
/**
|
||||
* A non-text row of the EDIT form — a switch or a stepped choice, driven like a settings row rather
|
||||
* than opening the keyboard. Add-host mode has none: they all edit properties a host only has once
|
||||
* it is saved.
|
||||
*/
|
||||
private class ExtraRow(
|
||||
val label: String,
|
||||
val value: String,
|
||||
/** Non-null = draw a [ConsoleSwitch] instead of the value text. */
|
||||
val toggled: Boolean?,
|
||||
val adjust: (Int) -> Unit,
|
||||
val activate: () -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GamepadAddHostScreen(
|
||||
onAdd: (name: String, address: String, port: Int) -> Unit,
|
||||
@@ -97,11 +78,6 @@ fun GamepadAddHostScreen(
|
||||
editHost: KnownHost? = null,
|
||||
suggestedMacs: List<String> = emptyList(),
|
||||
onSave: ((KnownHost) -> Unit)? = null,
|
||||
/**
|
||||
* The profile catalog, for the edit form's binding row. Empty (the default) simply omits that
|
||||
* row — which is also what a device with no profiles yet gets.
|
||||
*/
|
||||
profiles: List<StreamProfile> = emptyList(),
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val context = LocalContext.current
|
||||
@@ -113,15 +89,6 @@ fun GamepadAddHostScreen(
|
||||
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(", ") ?: "") }
|
||||
// The two host properties the console could not reach at all until now. `copy` preserved them,
|
||||
// so nothing was ever LOST — but a couch-only user (a TV box has no touch interface to fall
|
||||
// back to) could never decide either one, which the touch edit sheet has always offered.
|
||||
var clipboard by remember(editHost) { mutableStateOf(editHost?.clipboardSync ?: true) }
|
||||
// Filtered through the live catalog, so a binding to a since-deleted profile reads as unset
|
||||
// rather than as a name nothing can resolve — the same guard the touch sheet applies.
|
||||
var boundId by remember(editHost, profiles) {
|
||||
mutableStateOf(editHost?.profileId?.takeIf { id -> profiles.any { it.id == id } })
|
||||
}
|
||||
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
|
||||
fun commit() {
|
||||
if (isEdit && editHost != null && onSave != null) {
|
||||
@@ -131,8 +98,6 @@ fun GamepadAddHostScreen(
|
||||
address = address.trim(),
|
||||
port = port.toIntOrNull() ?: editHost.port,
|
||||
mac = KnownHostStore.parseMacs(mac),
|
||||
clipboardSync = clipboard,
|
||||
profileId = boundId,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
@@ -170,43 +135,7 @@ fun GamepadAddHostScreen(
|
||||
add(Field("port", "Port", port, "9777"))
|
||||
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
|
||||
}
|
||||
// The switch/choice rows, between the text fields and the action. Only in EDIT mode: both edit
|
||||
// properties a host only has once it has been saved.
|
||||
val extras = buildList {
|
||||
if (isEdit) {
|
||||
add(
|
||||
ExtraRow(
|
||||
label = "Shared clipboard",
|
||||
value = if (clipboard) "On" else "Off",
|
||||
toggled = clipboard,
|
||||
// Directional = state-targeted, so holding a direction can't oscillate — the
|
||||
// same rule the settings toggles and the pin picker use.
|
||||
adjust = { d -> clipboard = d > 0 },
|
||||
activate = { clipboard = !clipboard },
|
||||
),
|
||||
)
|
||||
if (profiles.isNotEmpty()) {
|
||||
// "Default settings" is the absence of a binding, not a profile, so it leads the
|
||||
// ring as a null rather than being faked as an entry in the catalog.
|
||||
val options = listOf<StreamProfile?>(null) + profiles
|
||||
val idx = options.indexOfFirst { it?.id == boundId }.coerceAtLeast(0)
|
||||
fun stepTo(delta: Int) {
|
||||
val n = ((idx + delta) % options.size + options.size) % options.size
|
||||
boundId = options[n]?.id
|
||||
}
|
||||
add(
|
||||
ExtraRow(
|
||||
label = "Profile",
|
||||
value = options[idx]?.name ?: "Default settings",
|
||||
toggled = null,
|
||||
adjust = { d -> stepTo(d) },
|
||||
activate = { stepTo(1) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val actionIndex = fields.size + extras.size // the Save/Add action sits after everything
|
||||
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 }
|
||||
@@ -223,13 +152,11 @@ fun GamepadAddHostScreen(
|
||||
"address" -> c != ' '
|
||||
else -> true
|
||||
}
|
||||
/** The focused row's extra, or null when the cursor is on a text field or the action. */
|
||||
fun focusedExtra(): ExtraRow? = extras.getOrNull(focus - fields.size)
|
||||
fun activateField() {
|
||||
when {
|
||||
focus == actionIndex -> if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||
focus < fields.size -> openKeyboard(fields[focus].id)
|
||||
else -> focusedExtra()?.activate()
|
||||
if (focus == actionIndex) {
|
||||
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
|
||||
} else {
|
||||
openKeyboard(fields[focus].id)
|
||||
}
|
||||
}
|
||||
fun pressKey() {
|
||||
@@ -252,11 +179,7 @@ fun GamepadAddHostScreen(
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < actionIndex) focus++
|
||||
// Left/right step the switch and the profile ring, exactly as they step a
|
||||
// settings row. On a text field or the action they still do nothing — there is
|
||||
// no value there to walk.
|
||||
NavDir.LEFT -> focusedExtra()?.adjust(-1)
|
||||
NavDir.RIGHT -> focusedExtra()?.adjust(1)
|
||||
else -> {}
|
||||
}
|
||||
} else {
|
||||
when (dir) {
|
||||
@@ -291,17 +214,15 @@ fun GamepadAddHostScreen(
|
||||
// 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().consoleSafeArea().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
|
||||
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().widthIn(max = 620.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
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) } }
|
||||
extras.forEachIndexed { i, e -> ExtraRowView(e, focused = false) { onFieldClick(fields.size + i) } }
|
||||
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
|
||||
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
|
||||
}
|
||||
@@ -315,11 +236,9 @@ fun GamepadAddHostScreen(
|
||||
} 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().consoleSafeArea().padding(horizontal = ConsoleEdgeInset)) {
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
|
||||
Column(
|
||||
// Same 620 dp cap as the settings rows: a field stretched across a wide
|
||||
// landscape phone is a ribbon, not an input.
|
||||
Modifier.weight(1f).widthIn(max = 620.dp).verticalScroll(rememberScrollState()),
|
||||
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ConsoleHeader(title, horizontalInset = false)
|
||||
@@ -332,11 +251,6 @@ fun GamepadAddHostScreen(
|
||||
)
|
||||
}
|
||||
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
|
||||
extras.forEachIndexed { i, e ->
|
||||
ExtraRowView(e, focused = focus == fields.size + i && editing == null) {
|
||||
onFieldClick(fields.size + i)
|
||||
}
|
||||
}
|
||||
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
|
||||
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
|
||||
}
|
||||
@@ -354,7 +268,7 @@ fun GamepadAddHostScreen(
|
||||
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
@@ -401,7 +315,7 @@ private fun TvAddHostForm(
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.consoleSafeArea()
|
||||
.systemBarsPadding()
|
||||
.padding(horizontal = 56.dp, vertical = 36.dp)
|
||||
.widthIn(max = 720.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
@@ -452,18 +366,14 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row]
|
||||
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
|
||||
// The caret keeps its slot and only fades, like the settings rows' chevrons. Appending it on
|
||||
// `editing` shoved the whole value left the instant the keyboard opened — the same
|
||||
// layout-moves-under-focus bug the settings detail line had, one screen over.
|
||||
val caretAlpha by animateFloatAsState(
|
||||
if (editing) 1f else 0f,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "caret",
|
||||
)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.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,
|
||||
@@ -477,61 +387,7 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(" |", color = ink.accent, modifier = Modifier.graphicsLayer { alpha = caretAlpha })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch or stepped-choice row of the edit form. Deliberately the settings screen's row in
|
||||
* miniature — same glass, same end-aligned value slot, same `ConsoleSwitch` — because it IS a
|
||||
* settings row: it edits a stored property with left/right, and a user who has met one has met
|
||||
* both.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = if (row.toggled != null) Role.Switch else Role.Button
|
||||
contentDescription = "${row.label}, ${row.value}"
|
||||
row.toggled?.let {
|
||||
toggleableState = if (it) ToggleableState.On else ToggleableState.Off
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (row.toggled != null) {
|
||||
ConsoleSwitch(on = row.toggled, focused = focused)
|
||||
} else {
|
||||
Text(
|
||||
row.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(if (focused) 1f else 0.6f),
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (editing) Text(" |", color = ink.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,15 +395,19 @@ private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) {
|
||||
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),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
tween(160),
|
||||
label = "addLabel",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.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,
|
||||
@@ -570,16 +430,14 @@ private fun KeyboardGrid(
|
||||
onKey: (Int, Int) -> Unit,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = ConsoleShape.Keyboard
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val gap = if (compact) 5.dp else 7.dp
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 640.dp)
|
||||
.clip(shape)
|
||||
// Palette glass, lifted a touch above a row's: the keyboard is a slab the keys sit on,
|
||||
// and a hardcoded white wash was the one surface a pale palette couldn't recolour.
|
||||
.background(ink.glass.copy(alpha = (ink.glass.alpha * 1.5f).coerceAtMost(1f)))
|
||||
.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),
|
||||
@@ -609,13 +467,11 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier:
|
||||
tween(90),
|
||||
label = "keyBg",
|
||||
)
|
||||
// `onAccent`, not black: a pale palette's accent can be light enough that black-on-it is the
|
||||
// unreadable combination, and the palette already resolved which way that goes.
|
||||
val fg by animateColorAsState(if (focused) ink.onAccent else ink.fg, tween(90), label = "keyFg")
|
||||
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(ConsoleShape.Keycap)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(bg)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
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
|
||||
@@ -16,6 +18,7 @@ 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
|
||||
@@ -42,8 +45,8 @@ 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.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -104,13 +107,18 @@ fun GamepadDialog(
|
||||
// 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
|
||||
ConsoleModal {
|
||||
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)
|
||||
.consoleCard()
|
||||
.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),
|
||||
) {
|
||||
@@ -142,11 +150,7 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
|
||||
// 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 focus by animateFloatAsState(
|
||||
if (focused) 1f else 0f,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
label = "btnFocus",
|
||||
)
|
||||
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 {
|
||||
@@ -154,30 +158,32 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab
|
||||
primary -> ink.accent(0.20f)
|
||||
else -> ink.glass
|
||||
},
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
tween(160),
|
||||
label = "btnBg",
|
||||
)
|
||||
val fg by animateColorAsState(
|
||||
when {
|
||||
!enabled -> ink.fg(0.35f)
|
||||
// On the accent, not on the field — a pale palette's accent decides this, not the ink.
|
||||
focused -> ink.onAccent
|
||||
focused -> ink.fg
|
||||
primary -> ink.accent
|
||||
else -> ink.fg(0.85f)
|
||||
},
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
tween(160),
|
||||
label = "btnFg",
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
ink.fg(if (focused) 0.3f else 0.08f),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
Color.White.copy(alpha = if (focused) 0.3f else 0.08f),
|
||||
tween(160),
|
||||
label = "btnBorder",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.consoleGlass(ConsoleShape.Row, ConsoleFocusVisuals(scale, bg, borderColor, focus))
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape)
|
||||
.background(bg)
|
||||
.border(1.dp, borderColor, shape)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
@@ -299,13 +305,18 @@ fun GamepadPinHostsDialog(
|
||||
},
|
||||
)
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
ConsoleModal {
|
||||
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)
|
||||
.consoleCard()
|
||||
.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),
|
||||
) {
|
||||
@@ -357,11 +368,15 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
|
||||
// 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)
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
@@ -383,6 +398,157 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -396,8 +562,7 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
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 context = LocalContext.current
|
||||
val name = remember(context) { deviceName(context) }
|
||||
val name = remember { Build.MODEL ?: "Android" }
|
||||
|
||||
fun pair() {
|
||||
val id = identity ?: return
|
||||
@@ -433,10 +598,11 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
)
|
||||
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
ConsoleModal {
|
||||
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)
|
||||
.consoleCard()
|
||||
.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,
|
||||
@@ -450,7 +616,7 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
|
||||
}
|
||||
err?.let { Text(it, color = ink.danger, style = MaterialTheme.typography.bodyMedium) }
|
||||
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
|
||||
DialogButton(
|
||||
label = if (pairing) "Pairing…" else "Pair",
|
||||
focused = slot == 4 && !pairing,
|
||||
@@ -472,12 +638,6 @@ private fun PinSlot(value: Int, focused: Boolean) {
|
||||
.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,
|
||||
)
|
||||
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
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
|
||||
@@ -19,11 +17,11 @@ 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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
@@ -35,7 +33,6 @@ 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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -47,7 +44,6 @@ 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.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -59,7 +55,6 @@ import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.cos
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
|
||||
@@ -67,12 +62,6 @@ import kotlinx.coroutines.launch
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* How far a fully off-centre card turns away from the viewer, in radians (~48°). Never rendered as
|
||||
* a rotation — see the projection note at the call site.
|
||||
*/
|
||||
private const val CARD_TURN_RAD = 0.838f
|
||||
|
||||
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
|
||||
class HomeTile(
|
||||
val id: String,
|
||||
@@ -86,27 +75,15 @@ class HomeTile(
|
||||
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) belong
|
||||
* to the host's own tile, and this one offers only Unpin. The library is NOT one of those —
|
||||
* it is a way to start this card (a connect with a title picked first), so a pinned tile opens
|
||||
* its own shelf and every launch off it carries this profile.
|
||||
* 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,
|
||||
/**
|
||||
* The profile a press will actually connect with — the host's binding, or the pin's own
|
||||
* profile. Rendered as a chip on the card rather than appended to the subtitle: on a PIN card
|
||||
* the profile is the entire reason the card exists, and a card that only whispers it in grey
|
||||
* body text can't say that. Matches the Apple client's tile.
|
||||
*/
|
||||
val profileName: String? = null,
|
||||
/** The profile's `#RRGGBB` chip colour, if it set one. */
|
||||
val profileAccent: Color? = null,
|
||||
val activate: () -> Unit,
|
||||
) {
|
||||
// Any SAVED host offers the library (matches Apple), pinned cards included — 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
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,16 +118,6 @@ fun GamepadHome(
|
||||
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
|
||||
val current = tiles.getOrNull(navTarget)
|
||||
|
||||
// Bumped on every confirm — the centred card dips under the press and springs back, so A reads
|
||||
// as a button being pushed rather than as a screen simply changing.
|
||||
var pressToken by remember { mutableIntStateOf(0) }
|
||||
val press = remember { Animatable(1f) }
|
||||
LaunchedEffect(pressToken) {
|
||||
if (pressToken == 0) return@LaunchedEffect
|
||||
press.animateTo(0.97f, ConsoleMotion.ease(70))
|
||||
press.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMedium))
|
||||
}
|
||||
|
||||
GamepadNavEffect(
|
||||
active = navActive && tiles.isNotEmpty(),
|
||||
onMove = { dir ->
|
||||
@@ -160,8 +127,7 @@ fun GamepadHome(
|
||||
scope.launch { pagerState.animateScrollToPage(target) }
|
||||
}
|
||||
},
|
||||
// A / D-pad-center → Connect
|
||||
onActivate = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) },
|
||||
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
|
||||
onSecondary = { // Y (gamepad) → Library
|
||||
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
|
||||
},
|
||||
@@ -179,9 +145,9 @@ fun GamepadHome(
|
||||
// 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 = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
|
||||
val optionsAction: () -> Unit = { current?.let(onOptions) }
|
||||
val arrowTint = PadGlyph.Arrow
|
||||
val arrowTint = Color(0xFF9A93C7)
|
||||
val hints = buildList {
|
||||
if (padIsGamepad) {
|
||||
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
|
||||
@@ -211,12 +177,7 @@ fun GamepadHome(
|
||||
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
|
||||
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
|
||||
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
|
||||
// The carousel deliberately IGNORES the safe area (first on-glass verdict): only the
|
||||
// CENTRED card matters, and it sits mid-screen; the fanned neighbours running under
|
||||
// the hole punch is ambience, while insetting the pager CLIPPED them at the cutout
|
||||
// edge — cards visibly cut off is worse than cards behind a camera. The title and the
|
||||
// legend keep their insets; they are content.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(cardWidth),
|
||||
@@ -228,35 +189,17 @@ fun GamepadHome(
|
||||
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.
|
||||
// Signed, because which SIDE a card fans to decides which edge it turns on.
|
||||
val signed = (page - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||
val offset = signed.absoluteValue.coerceIn(0f, 1f)
|
||||
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
|
||||
.absoluteValue.coerceIn(0f, 1f)
|
||||
GamepadHostTile(
|
||||
tile = tile,
|
||||
centred = offset < 0.5f,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
// The press dip applies to the CENTRED card only — it is the one
|
||||
// the button acted on, and a whole carousel flinching would read
|
||||
// as the screen moving rather than a card being pressed.
|
||||
val s = lerp(1f, 0.86f, offset) * lerp(press.value, 1f, offset)
|
||||
val s = lerp(1f, 0.86f, offset)
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
alpha = lerp(1f, 0.5f, offset)
|
||||
}
|
||||
.graphicsLayer {
|
||||
// The neighbours TURN away, projected rather than rendered in 3D.
|
||||
// `cos(angle)` as a horizontal squeeze IS the orthographic
|
||||
// projection of a Y-axis rotation, and hinging it on the edge the
|
||||
// card fans from is what carries the direction the rotation's sign
|
||||
// would have. The Apple client arrived here the hard way (see
|
||||
// GamepadCarousel.swift): a real `rotation3DEffect` renders the
|
||||
// card through an offscreen pass and flashed as the strip settled.
|
||||
// Affine transforms don't.
|
||||
scaleX = cos(CARD_TURN_RAD * offset)
|
||||
transformOrigin =
|
||||
TransformOrigin(if (signed > 0f) 0f else 1f, 0.5f)
|
||||
}
|
||||
// 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)
|
||||
@@ -266,7 +209,6 @@ fun GamepadHome(
|
||||
indication = null,
|
||||
) {
|
||||
if (page == navTarget) {
|
||||
pressToken++
|
||||
onActivate(tile)
|
||||
} else {
|
||||
navTarget = page
|
||||
@@ -281,28 +223,20 @@ fun GamepadHome(
|
||||
// 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().consoleSafeArea()
|
||||
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
|
||||
.padding(end = ConsoleEdgeInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
// The TITLE has priority (unweighted, so it is measured at its full width first) and the
|
||||
// chip takes what is left, ellipsizing its device name. The other way round — which is
|
||||
// what a weighted header gave — a talkative controller name ("Xbox Wireless Controller")
|
||||
// ate a 360 dp portrait phone's title down to "Selec…".
|
||||
ConsoleHeader("Select a Host")
|
||||
if (controllerName != null) {
|
||||
ControllerStatusChip(controllerName, Modifier.weight(1f, fill = false))
|
||||
}
|
||||
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 system bars (the nav-bar inset made the bottom gap look oversized) but never
|
||||
// the cutout — reverse-landscape parks the punch on this very corner.
|
||||
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(hints, hazeState = hazeState)
|
||||
@@ -310,31 +244,22 @@ fun GamepadHome(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One glass landscape console tile — bigger and bolder than the touch grid's HostCard, and cut from
|
||||
* the same [Modifier.consoleGlass] every console surface is, so a card and a settings row catch the
|
||||
* light the same way. [centred] is the carousel's own focus: the tile the pad is pointing at, which
|
||||
* earns the lift and the accent bloom.
|
||||
*/
|
||||
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
|
||||
@Composable
|
||||
private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier = Modifier) {
|
||||
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val visuals = animateConsoleFocus(active = centred)
|
||||
// A SAVED host wears the palette's accent; a discovered one (or the Add tile) stays neutral
|
||||
// glass, so "already yours" reads before you get to the label.
|
||||
val fill = if (tile.filled) ink.accent(0.20f) else ink.glass
|
||||
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()
|
||||
// The carousel already drives its own scale; the glass must not fight it with a second.
|
||||
.consoleGlass(
|
||||
ConsoleShape.Tile,
|
||||
ConsoleFocusVisuals(1f, fill, ink.fg(0.16f), visuals.focus),
|
||||
// A DASHED edge on anything not yet saved — a host found on the network, and the
|
||||
// Add tile. It is the touch grid's own convention and the Apple client's, and it
|
||||
// says "not yours yet" before the subtitle has to.
|
||||
dashed = !tile.filled,
|
||||
)
|
||||
.clip(shape)
|
||||
.background(wash)
|
||||
.border(1.dp, ink.fg(0.16f), shape)
|
||||
.padding(22.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
@@ -366,64 +291,12 @@ private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (tile.profileName != null) {
|
||||
ConsoleProfileChip(
|
||||
name = tile.profileName,
|
||||
accent = tile.profileAccent,
|
||||
// On a PIN card the profile is why the card exists; on a bound host's own card it
|
||||
// is a note about what a press will do. Same chip, two weights.
|
||||
prominent = tile.pinnedProfileId != null,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
tile.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.55f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile a card connects with, worn as a tinted capsule. The console counterpart of the touch
|
||||
* grid's own chip (`HostComponents.kt`) — same shape and the same quiet/prominent split, but inked
|
||||
* from the console palette rather than `MaterialTheme`, since it sits on the aurora.
|
||||
*
|
||||
* A profile that set no accent falls back to the palette's, not to the touch theme's primary: on a
|
||||
* moss or copper field the brand violet would be the one foreign colour on the card.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConsoleProfileChip(
|
||||
name: String,
|
||||
accent: Color?,
|
||||
prominent: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val tint = accent ?: ink.accent
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(ConsoleShape.Pill)
|
||||
.background(tint.copy(alpha = if (prominent) 0.24f else 0.12f))
|
||||
.padding(horizontal = 9.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
name,
|
||||
style = if (prominent) {
|
||||
MaterialTheme.typography.labelLarge
|
||||
} else {
|
||||
MaterialTheme.typography.labelMedium
|
||||
},
|
||||
fontWeight = if (prominent) FontWeight.Bold else FontWeight.SemiBold,
|
||||
color = tint,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -432,13 +305,10 @@ private fun ConsoleProfileChip(
|
||||
private fun MonogramBadge(tile: HomeTile) {
|
||||
val ink = LocalGamepadInk.current
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
// Lit from the top like every other console surface — and the unsaved badge takes the palette's
|
||||
// own accent at low opacity rather than the brand violet, which on a copper or moss field was
|
||||
// the one square of the wrong hue on the screen.
|
||||
val fill = if (tile.filled) {
|
||||
Brush.verticalGradient(listOf(ink.accent.copy(alpha = 0.92f), ink.accent))
|
||||
Brush.verticalGradient(listOf(ink.accent, ink.accent))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(ink.accent(0.20f), ink.accent(0.14f)))
|
||||
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(52.dp).clip(shape).background(fill),
|
||||
|
||||
@@ -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
|
||||
@@ -102,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 }
|
||||
@@ -146,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)
|
||||
}
|
||||
@@ -176,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)
|
||||
@@ -208,15 +196,12 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -244,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),
|
||||
@@ -145,11 +84,7 @@ class GamepadPalette(
|
||||
// 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",
|
||||
"oled", "OLED",
|
||||
listOf(
|
||||
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
|
||||
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
|
||||
|
||||
@@ -3,19 +3,20 @@ 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.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
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
|
||||
@@ -24,28 +25,15 @@ 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.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -58,23 +46,13 @@ 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.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.toggleableState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
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.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
@@ -125,19 +103,6 @@ internal class GpRow(
|
||||
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)
|
||||
/**
|
||||
* What A does on a non-adjustable row, for the legend. It was the literal "Pin to hosts" in the
|
||||
* hint bar back when a profile row was the only kind of row that acted rather than stepped; a
|
||||
* row that opens the Controllers view was then advertised as pinning something.
|
||||
*/
|
||||
val actionHint: String = "Open",
|
||||
/**
|
||||
* A choice row's full option list + where [value] sits in it — what the [ConsoleOptionBand]
|
||||
* drum turns through. Null (with [selectedIndex] -1) on everything that is not a stepped
|
||||
* choice: toggles are a switch, and the flat rows keep the quiet text slip.
|
||||
*/
|
||||
val options: List<String>? = null,
|
||||
val selectedIndex: Int = -1,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -149,28 +114,12 @@ internal class GpRow(
|
||||
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
|
||||
rows.getOrNull(index)?.takeIf { it.enabled }
|
||||
|
||||
/**
|
||||
* Where the cursor was when a row opened a SUB-SCREEN. The shell holds it across the trip (this
|
||||
* screen's own state does not outlive it) and hands it back, so Back from the Controllers view lands
|
||||
* on the row that opened it rather than on the first row of the first section.
|
||||
*
|
||||
* The row is remembered by ID, not by index: a tab's length follows the hardware and the profile
|
||||
* catalog, and a remembered index is the stale-pointer bug the tab-switch clamp already exists for.
|
||||
*/
|
||||
data class GpSettingsPlace(val tab: GpTab, val rowId: String)
|
||||
|
||||
@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
|
||||
/** Open the connected-controllers view / the open-source notices — the shell pushes them. */
|
||||
onOpenControllers: () -> Unit = {},
|
||||
onOpenLicenses: () -> Unit = {},
|
||||
/** Where a return from one of those lands; null = a fresh entry, which starts at the top. */
|
||||
resume: GpSettingsPlace? = null,
|
||||
onPlace: (GpSettingsPlace) -> Unit = {},
|
||||
) {
|
||||
var s by remember { mutableStateOf(initial) }
|
||||
fun update(next: Settings) { s = next; onChange(next) }
|
||||
@@ -213,46 +162,24 @@ fun GamepadSettingsScreen(
|
||||
// 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) }
|
||||
// The installed version, for the About row — the console is the ONLY interface on a TV box, so
|
||||
// the identity the touch About page states has to be reachable from here too.
|
||||
val appVersion = remember {
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull().orEmpty()
|
||||
}
|
||||
val allRows = buildSettingsRows(
|
||||
s, hasBodyVibrator, hasGyroscope, av1Capable,
|
||||
appVersion = appVersion,
|
||||
openControllers = onOpenControllers,
|
||||
openLicenses = onOpenLicenses,
|
||||
update = ::update,
|
||||
) + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// Which section is showing, and where each one's focus was when it was last left — a detour
|
||||
// into another tab shouldn't lose your place.
|
||||
var tab by remember { mutableStateOf(resume?.tab ?: GpTab.STREAM) }
|
||||
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 }
|
||||
// Entry focus: the row a sub-screen was opened from, if we are coming back from one. Resolved
|
||||
// ONCE, against the first row list — after that the cursor belongs to this screen.
|
||||
var focus by remember {
|
||||
mutableIntStateOf(rows.indexOfFirst { it.id == resume?.rowId }.coerceAtLeast(0))
|
||||
}
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
|
||||
|
||||
// Which way the section last moved (+1 forward / -1 back) — the row list slides in from that
|
||||
// side, so stepping sections reads as travelling along a strip rather than teleporting.
|
||||
var tabDir by remember { mutableIntStateOf(1) }
|
||||
|
||||
// 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
|
||||
tabDir = if (next.ordinal > tab.ordinal) 1 else -1
|
||||
tab = next
|
||||
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
|
||||
// outlive the row it pointed at.
|
||||
@@ -261,45 +188,15 @@ fun GamepadSettingsScreen(
|
||||
}
|
||||
fun stepTab(delta: Int) {
|
||||
val all = GpTab.entries
|
||||
val next = all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size]
|
||||
selectTab(next)
|
||||
// A wrap (last → first) is still a step in the direction you pressed, whatever the ordinals
|
||||
// say — selectTab's ordinal compare would read it backwards.
|
||||
tabDir = delta
|
||||
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) }
|
||||
// Bumped on every ACCEPTED step of the focused row (the chevron ticks) and every REFUSED one
|
||||
// (the value gives a little and springs back). A press always gets an answer, even "no".
|
||||
var stepToken by remember { mutableIntStateOf(0) }
|
||||
var refusalToken by remember { mutableIntStateOf(0) }
|
||||
val haptics = rememberConsoleHaptics()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
// Act on a row, publishing where the cursor was FIRST. A row that opens a sub-screen unmounts
|
||||
// this one on the spot, so the place has to be out of here before its `activate` runs; every
|
||||
// activation route (pad, tap, the legend's own A cell) goes through this one door so none of
|
||||
// them can be the one that forgets.
|
||||
fun activate(row: GpRow) {
|
||||
onPlace(GpSettingsPlace(tab, row.id))
|
||||
adjustDir = 1
|
||||
row.activate()
|
||||
}
|
||||
|
||||
// Step the focused row's value, answering a refusal rather than swallowing it.
|
||||
fun step(delta: Int) {
|
||||
adjustDir = delta
|
||||
val row = liveRow(rows, focus)
|
||||
if (row != null && row.adjust(delta)) {
|
||||
stepToken++
|
||||
} else {
|
||||
refusalToken++
|
||||
haptics.boundary()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
|
||||
@@ -312,233 +209,89 @@ fun GamepadSettingsScreen(
|
||||
// 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 step(-1)
|
||||
NavDir.RIGHT -> if (tabFocused) stepTab(1) else step(1)
|
||||
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 liveRow(rows, focus)?.let { activate(it) }
|
||||
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) },
|
||||
)
|
||||
val animated = animationsEnabled()
|
||||
val hazeState = remember { HazeState() }
|
||||
val ink = LocalGamepadInk.current
|
||||
// The list runs to the PHYSICAL bottom edge (see the column's insets below), so the legend
|
||||
// zone's clearance has to carry the bottom bar inset itself. Landscape's zone is only the
|
||||
// pill — its detail lives in the side pane — so it clears less.
|
||||
val bottomInset = with(LocalDensity.current) {
|
||||
WindowInsets.systemBars.getBottom(this).toDp()
|
||||
}
|
||||
val legendClearance = (if (landscape) 92.dp else ConsoleLegendClearance) + bottomInset
|
||||
val legendClearancePx = with(LocalDensity.current) { legendClearance.roundToPx() }
|
||||
// The drum's fixed stage: a portrait phone is the one place the full width starves the row's
|
||||
// label, so it alone narrows it — the Apple band makes the same single exception. 132, not the
|
||||
// 156 of the first cut: at 156 the LABELS truncated ("Resoluti…"), and a clipped label loses
|
||||
// meaning where a drum value only loses its tail into the edge fade.
|
||||
val bandWidth = if (landscape) 220.dp else 132.dp
|
||||
|
||||
/**
|
||||
* One section's rows as a scrolling pane. A composable local rather than inline because the
|
||||
* tab transition composes TWO of these at once (incoming and outgoing), and each needs its own
|
||||
* [LazyListState] — Compose refuses one state attached to two lists, which is why the previous
|
||||
* cut animated a single list's contents and read as the same fade in every direction.
|
||||
*/
|
||||
val tabPane: @Composable (GpTab, Modifier) -> Unit = { paneTab, paneModifier ->
|
||||
val paneRows = if (paneTab == tab) rows else allRows.filter { it.tab == paneTab }
|
||||
val paneFocus = if (paneTab == tab) focus else (tabFocus[paneTab] ?: 0)
|
||||
// Seeded at the restored cursor, so re-entering a section lands where it was left without
|
||||
// a visible catch-up scroll on the first frame.
|
||||
val paneListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = paneFocus.coerceIn(0, paneRows.lastIndex.coerceAtLeast(0)),
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen. Only the
|
||||
// LIVE pane tracks the cursor; the outgoing one is a photograph on its way out.
|
||||
if (paneTab == tab) {
|
||||
LaunchedEffect(focus) {
|
||||
runCatching {
|
||||
val info = paneListState.layoutInfo
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == focus }
|
||||
val offScreen = item == null ||
|
||||
item.offset < info.viewportStartOffset ||
|
||||
// The SAME clearance the list pads its bottom with, rather than a literal
|
||||
// that has to be remembered when the legend zone grows.
|
||||
item.offset + item.size > info.viewportEndOffset - legendClearancePx
|
||||
if (offScreen) paneListState.animateScrollToItem(focus)
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
LazyColumn(
|
||||
state = paneListState,
|
||||
// Capped at the Apple client's 620 row width: a landscape phone is WIDER than it is
|
||||
// useful, and a settings row stretched across 900 dp reads as a ribbon, not a control.
|
||||
// Start-aligned (not centred) so the rows and the side detail pane split the screen
|
||||
// rather than both crowding the middle.
|
||||
modifier = paneModifier.widthIn(max = 620.dp + ConsoleEdgeInset * 2),
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
top = 8.dp,
|
||||
// Clears the whole floating legend ZONE, bottom bar included — the list itself
|
||||
// runs to the screen edge now.
|
||||
bottom = legendClearance,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(paneRows, key = { _, r -> r.id }) { index, row ->
|
||||
val rowFocused = paneTab == tab && index == focus && !tabFocused
|
||||
}
|
||||
|
||||
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 = rowFocused,
|
||||
focused = index == focus && !tabFocused,
|
||||
adjustDir = adjustDir,
|
||||
// Only the focused row can be stepped, so only it needs to answer one.
|
||||
stepToken = if (rowFocused) stepToken else 0,
|
||||
refusalToken = if (rowFocused) refusalToken else 0,
|
||||
bandWidth = bandWidth,
|
||||
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) activate(row)
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The section switcher with its directional content — shared by both orientations below. */
|
||||
val tabbedContent: @Composable (Modifier) -> Unit = { contentModifier ->
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
modifier = contentModifier,
|
||||
transitionSpec = {
|
||||
if (!animated) {
|
||||
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
|
||||
fadeOut(tween(ConsoleMotion.REDUCED_MS))
|
||||
} else {
|
||||
// DIRECTION-driven, with a real exit: the incoming section slides in from the
|
||||
// side the press pointed at while the outgoing leaves the other way — paging
|
||||
// along a strip. The previous cut slid a single list's contents 24 dp under an
|
||||
// 85 % fade, which read as the same crossfade whichever shoulder was pressed.
|
||||
val dir = tabDir
|
||||
(
|
||||
slideInHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { it / 6 * dir } +
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.TAB_MS))
|
||||
) togetherWith (
|
||||
slideOutHorizontally(ConsoleMotion.ease(ConsoleMotion.TAB_MS)) { -it / 6 * dir } +
|
||||
fadeOut(ConsoleMotion.ease(ConsoleMotion.TAB_MS))
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "settingsTab",
|
||||
) { t ->
|
||||
tabPane(t, Modifier.fillMaxHeight())
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The backdrop stays full-bleed — it is ambience. The CHROME (strip, rows' start
|
||||
// edge) takes the safe area on the sides and top only: the LIST deliberately runs to
|
||||
// the physical bottom of the screen, with the bottom inset folded into its
|
||||
// contentPadding, so scrolled rows glide off the edge instead of being guillotined at
|
||||
// an invisible inset line 24 px above it (third on-glass verdict).
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.systemBars.union(WindowInsets.displayCutout)
|
||||
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
|
||||
),
|
||||
) {
|
||||
// The strip is PINNED while the rows scroll under it: it is this screen's primary
|
||||
// navigation, and a switcher you have to scroll back up to find isn't one.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ConsoleTabStrip(
|
||||
titles = GpTab.entries.map { it.title },
|
||||
selected = GpTab.entries.indexOf(tab),
|
||||
onSelect = { tabFocused = false; selectTab(GpTab.entries[it]) },
|
||||
modifier = Modifier.weight(1f),
|
||||
focused = tabFocused,
|
||||
)
|
||||
// The base-layer marker, where a full "Default settings" heading used to eat a
|
||||
// headline row on EVERY tab. The honesty it carried stays: this screen edits
|
||||
// the defaults only — the console honours a host's profile but doesn't edit
|
||||
// profiles (design §5.4) — and this quiet chip at the strip's end says so
|
||||
// without a second heading repeating the tab pill's own word.
|
||||
Text(
|
||||
"Defaults",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ink.fg(0.45f),
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 10.dp, end = ConsoleEdgeInset),
|
||||
)
|
||||
}
|
||||
if (landscape) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
tabbedContent(Modifier.weight(0.6f))
|
||||
// The focused row's description, in the width a wide phone wastes — beside
|
||||
// the rows instead of floating over the list's tail (the portrait band).
|
||||
// Presentation only: the row already merges this text into its own
|
||||
// announcement, so the pane is hidden from a screen reader like the band.
|
||||
val focusedRow = rows.getOrNull(focus)
|
||||
AnimatedContent(
|
||||
targetState = if (tabFocused) null else focusedRow,
|
||||
transitionSpec = {
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) togetherWith
|
||||
fadeOut(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS))
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.semantics { hideFromAccessibility() },
|
||||
label = "sideDetail",
|
||||
) { r ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(start = 6.dp, end = ConsoleEdgeInset, top = 22.dp),
|
||||
) {
|
||||
if (r != null && r.detail.isNotBlank()) {
|
||||
Text(
|
||||
r.label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
r.detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = ink.fg(0.6f),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tabbedContent(Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The floating legend ZONE: the focused row's description above, the controls pill below,
|
||||
// both frosted over whatever scrolls behind them. It is an OVERLAY, so nothing in it can
|
||||
// ever displace the list — which is the whole reason the detail moved here out of the row.
|
||||
// In landscape it ignores the system bars so it hugs the corner instead of the nav-bar
|
||||
// inset, but it still takes the display cutout (reverse-landscape parks the punch here).
|
||||
// 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)
|
||||
.consoleLegendInsets(landscape)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
// The legend follows the focused row (the desktop console's hints() does the same):
|
||||
@@ -553,49 +306,31 @@ fun GamepadSettingsScreen(
|
||||
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
val sections = listOfNotNull(
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Section", onClick = { stepTab(1) })
|
||||
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
|
||||
.takeIf { padIsGamepad },
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Portrait only: landscape's description lives in the side pane, where the wide
|
||||
// aspect has room for it — a band AND a pane would say the same thing twice.
|
||||
if (!landscape) {
|
||||
ConsoleDetailBand(
|
||||
// On the strip there is no row to describe, and the pills already name the
|
||||
// sections — a stale row's description there would describe the wrong thing.
|
||||
text = if (tabFocused) "" else focused?.detail.orEmpty(),
|
||||
key = if (tabFocused) "__strip" else focused?.id,
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
GamepadHintBar(
|
||||
if (tabFocused) listOf(
|
||||
GamepadHint('↔', PadGlyph.Arrow, "Section"),
|
||||
PadGlyph.hint('A', "Open") { tabFocused = false },
|
||||
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),
|
||||
) else sections + when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
// What A does here follows the ROW: it opens the pin picker on a profile,
|
||||
// the connected-controllers view on that one, the notices on the About row.
|
||||
// It was the literal "Pin to hosts" while profiles were the only such rows.
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', focused.actionHint) { activate(focused) },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', PadGlyph.Arrow, "Adjust"),
|
||||
// Tappable too (touch hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") {
|
||||
rows.getOrNull(focus)?.let { activate(it) }
|
||||
},
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
)
|
||||
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
|
||||
@@ -612,64 +347,24 @@ fun GamepadSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One settings row. Its geometry NEVER changes with focus — that is the whole design of it.
|
||||
*
|
||||
* It used to unfold its description in place, which meant every D-pad step shrank one row and grew
|
||||
* another, shifting the entire list under the cursor and moving the keep-focus-visible scroll's
|
||||
* target out from under it mid-animation. The description now lives in the screen's floating
|
||||
* [ConsoleDetailBand], which is an overlay and cannot displace anything. Focus changes colour,
|
||||
* lift and bloom here; it does not change size.
|
||||
*
|
||||
* The value gets the same treatment sideways: a fixed minimum slot, end-aligned, with the size
|
||||
* transform snapped so the slot's WIDTH never animates. Stepping a choice used to widen and narrow
|
||||
* that slot on every press, walking the ‹ chevron back and forth. Tabular figures finish the job —
|
||||
* without them `1920 × 1080 → 2560 × 1440` changes width on the digits alone.
|
||||
*/
|
||||
@Composable
|
||||
private fun SettingRowView(
|
||||
row: GpRow,
|
||||
focused: Boolean,
|
||||
adjustDir: Int,
|
||||
stepToken: Int,
|
||||
refusalToken: Int,
|
||||
/** The option drum's fixed stage — sized by the SCREEN (orientation decides how much a row can spare). */
|
||||
bandWidth: Dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
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,
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
tween(160),
|
||||
label = "chevrons",
|
||||
)
|
||||
val valueColor by animateColorAsState(
|
||||
ink.fg(if (focused) 1f else 0.6f),
|
||||
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
|
||||
tween(160),
|
||||
label = "valueColor",
|
||||
)
|
||||
// A press always gets an answer. Accepted: the chevron on the pressed side ticks outward and
|
||||
// springs back. Refused: the whole value slot gives 4 dp toward the press and springs back —
|
||||
// the "door is locked" motion, so a limit reads as a limit instead of as a dropped input.
|
||||
val chevronKick = remember { Animatable(0f) }
|
||||
LaunchedEffect(stepToken) {
|
||||
if (stepToken == 0) return@LaunchedEffect
|
||||
chevronKick.snapTo(2f * adjustDir)
|
||||
chevronKick.animateTo(0f, spring(dampingRatio = 0.45f, stiffness = 900f))
|
||||
}
|
||||
val refusal = remember { Animatable(0f) }
|
||||
LaunchedEffect(refusalToken) {
|
||||
if (refusalToken == 0) return@LaunchedEffect
|
||||
refusal.animateTo(
|
||||
ConsoleMotion.REFUSAL_NUDGE.value * adjustDir,
|
||||
ConsoleMotion.ease(ConsoleMotion.REFUSAL_MS / 2),
|
||||
)
|
||||
refusal.animateTo(0f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium))
|
||||
}
|
||||
Column {
|
||||
if (row.header != null) {
|
||||
Text(
|
||||
@@ -680,127 +375,74 @@ private fun SettingRowView(
|
||||
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.consoleGlass(ConsoleShape.Row, visuals)
|
||||
.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,
|
||||
)
|
||||
// ONE announcement per row, not five leaves read in layout order. Three things had
|
||||
// to be gathered to make it true:
|
||||
// * the VALUE of a toggle row existed nowhere in the tree — the switch replaces
|
||||
// the value text (see below), so `row.value` ("On"/"Off") was drawn by nothing;
|
||||
// * the DESCRIPTION lives in the floating band at the far bottom of the screen,
|
||||
// which is the right place to LOOK and the wrong place to be read — so it is
|
||||
// merged here, where the row it explains is;
|
||||
// * `enabled` was a colour and nothing else.
|
||||
// A row therefore announces "Refresh rate, 120 Hz, Frame rate the host renders and
|
||||
// streams at" — which is what the screen already means, said once.
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = if (row.toggled != null) Role.Switch else Role.Button
|
||||
contentDescription = listOfNotNull(
|
||||
row.label,
|
||||
row.value.takeIf { it.isNotBlank() },
|
||||
row.detail.takeIf { it.isNotBlank() },
|
||||
).joinToString(", ")
|
||||
row.toggled?.let {
|
||||
toggleableState = if (it) ToggleableState.On else ToggleableState.Off
|
||||
}
|
||||
if (!row.enabled) disabled()
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
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
|
||||
// the detail band can still explain what would go here.
|
||||
color = ink.fg(if (row.enabled) 1f else 0.45f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// Takes the slack rather than a Spacer doing it, so a long label ellipsizes into
|
||||
// the room it actually has instead of shoving the value slot off the row.
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.offset { IntOffset(refusal.value.dp.roundToPx(), 0) },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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 {
|
||||
Icon(
|
||||
Icons.Filled.ChevronLeft,
|
||||
// Decoration: it says "this value steps", which the row's Switch/Button
|
||||
// role already says. Left in the tree it is read out on every focused row.
|
||||
contentDescription = null,
|
||||
tint = ink.fg,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.semantics { hideFromAccessibility() }
|
||||
.graphicsLayer { alpha = chevronAlpha }
|
||||
.offset { IntOffset(minOf(chevronKick.value, 0f).dp.roundToPx(), 0) },
|
||||
)
|
||||
if (row.options != null && row.selectedIndex in row.options.indices) {
|
||||
// The drum — see ConsoleOptionBand. Its width is FIXED by the row, so a
|
||||
// step can never reflow the chevrons, and the tabular-figures concern
|
||||
// dissolves with it: nothing about the row's layout depends on the label.
|
||||
ConsoleOptionBand(
|
||||
options = row.options,
|
||||
selection = row.selectedIndex,
|
||||
focused = focused,
|
||||
width = bandWidth,
|
||||
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,
|
||||
)
|
||||
} else {
|
||||
// The flat rows (profile pin counts, the empty-catalog placeholder) keep
|
||||
// the quiet slip: the changed string slides in following the motion.
|
||||
AnimatedContent(
|
||||
targetState = row.value,
|
||||
transitionSpec = {
|
||||
val dir = adjustDir
|
||||
(
|
||||
slideInHorizontally(
|
||||
ConsoleMotion.ease(ConsoleMotion.VALUE_MS),
|
||||
) { w -> w / 2 * dir } +
|
||||
fadeIn(ConsoleMotion.ease(ConsoleMotion.VALUE_MS))
|
||||
) togetherWith (
|
||||
slideOutHorizontally(
|
||||
ConsoleMotion.ease(ConsoleMotion.VALUE_OUT_MS),
|
||||
) { w -> -w / 2 * dir } +
|
||||
fadeOut(ConsoleMotion.ease(100))
|
||||
) using SizeTransform(clip = false) { _, _ -> snap() }
|
||||
},
|
||||
label = "value",
|
||||
) { value ->
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = valueColor,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
Icons.Filled.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = ink.fg,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.semantics { hideFromAccessibility() }
|
||||
.graphicsLayer { alpha = chevronAlpha }
|
||||
.offset { IntOffset(maxOf(chevronKick.value, 0f).dp.roundToPx(), 0) },
|
||||
)
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -808,17 +450,12 @@ private fun SettingRowView(
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this
|
||||
* phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see
|
||||
* `codecOptionsFor`). [appVersion] is the installed version the About row states, and
|
||||
* [openControllers] / [openLicenses] are the two rows that navigate rather than set anything.
|
||||
* Every row declares its [GpTab]; the screen shows one tab at a time. */
|
||||
* `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
hasGyroscope: Boolean,
|
||||
av1Capable: Boolean,
|
||||
appVersion: String = "",
|
||||
openControllers: () -> Unit = {},
|
||||
openLicenses: () -> Unit = {},
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
@@ -843,8 +480,6 @@ internal fun buildSettingsRows(
|
||||
val i = if (idx < 0) 0 else (idx + 1) % options.size
|
||||
options.getOrNull(i)?.let { write(it.first) }
|
||||
},
|
||||
options = options.map { it.second },
|
||||
selectedIndex = idx,
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
@@ -998,28 +633,6 @@ internal fun buildSettingsRows(
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
// The diagnostics view — same screen the touch settings reach, same words for it. It was
|
||||
// reachable from touch ONLY, which on a TV box means not at all: there is no touch interface
|
||||
// to fall back to there, and "my controller does nothing" is the support case it answers.
|
||||
//
|
||||
// Deliberately NOT gated on the master forwarding switch its neighbours all follow: this is
|
||||
// the row you reach for when forwarding looks broken, and a diagnostic that dims itself when
|
||||
// the thing it diagnoses is off is worse than none.
|
||||
//
|
||||
// No value: this row navigates, it doesn't hold a setting (a count read here would be a
|
||||
// snapshot, and a stale "none detected" is worse than no number at all — the screen it opens
|
||||
// watches hot-plug live).
|
||||
GpRow(
|
||||
id = "controllers",
|
||||
tab = GpTab.CONTROLLER,
|
||||
header = "Diagnostics",
|
||||
label = "Connected controllers",
|
||||
value = "",
|
||||
detail = "What the app detects, with a live input test.",
|
||||
adjust = { false },
|
||||
activate = openControllers,
|
||||
adjustable = false,
|
||||
),
|
||||
|
||||
// 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
|
||||
@@ -1034,7 +647,7 @@ internal fun buildSettingsRows(
|
||||
choice(
|
||||
"hud", GpTab.INTERFACE, null, "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"Select + X on a pad, or a 3-finger tap, cycles the tiers live.",
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
@@ -1067,22 +680,6 @@ internal fun buildSettingsRows(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) + listOf(
|
||||
// About closes the Interface section, the way the touch settings' last category does. The
|
||||
// notices are a licence obligation and were reachable from touch only — on a TV box that is
|
||||
// nowhere. The version rides in the VALUE slot rather than as a second, inert row: it is the
|
||||
// identity half of an About page, and the screen this opens states it again at the top.
|
||||
GpRow(
|
||||
id = "licenses",
|
||||
tab = GpTab.INTERFACE,
|
||||
header = "About",
|
||||
label = "Open-source licenses",
|
||||
value = appVersion,
|
||||
detail = "Third-party notices and credits.",
|
||||
adjust = { false },
|
||||
activate = openLicenses,
|
||||
adjustable = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1097,7 +694,7 @@ internal fun buildSettingsRows(
|
||||
* 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).
|
||||
*/
|
||||
internal fun buildProfileRows(
|
||||
private fun buildProfileRows(
|
||||
profiles: List<StreamProfile>,
|
||||
savedHosts: List<KnownHost>,
|
||||
tv: Boolean,
|
||||
@@ -1144,7 +741,6 @@ internal fun buildProfileRows(
|
||||
adjust = { false },
|
||||
activate = { openPinPicker(p) },
|
||||
adjustable = false,
|
||||
actionHint = "Pin to hosts",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
|
||||
/**
|
||||
* The console home's tiles, in carousel order: every saved host with its pinned host+profile cards
|
||||
* immediately behind it, then the hosts seen on the network but not yet saved, then Add Host.
|
||||
*
|
||||
* Pure, and deliberately not a composable. The half of this that can be WRONG is the ordering and
|
||||
* what a tile claims — a pin drifting away from the host it belongs to, a discovered host offered a
|
||||
* second time next to the saved record it already is, a chip naming a profile the press won't
|
||||
* actually use. None of that needs a display to be checked, and `HomeTilesTest` checks it without
|
||||
* one; the console home itself needs the live JNI core to compose at all.
|
||||
*
|
||||
* [isOnline] and [pinsFor] arrive as lambdas rather than as the discovery lists and the profile
|
||||
* store behind them: "online" means advertising on mDNS OR answering a QUIC probe (the routed
|
||||
* Tailscale/VPN case), which is a rule belonging to the screen that does the probing, not to a list
|
||||
* builder.
|
||||
*/
|
||||
internal fun buildHomeTiles(
|
||||
savedHosts: List<KnownHost>,
|
||||
/** The live catalog — resolves each host's binding into the name and colour its chip wears. */
|
||||
profiles: List<StreamProfile>,
|
||||
pinsFor: (KnownHost) -> List<StreamProfile>,
|
||||
/** Already de-duped against [savedHosts] by the caller: a saved host is not also "discovered". */
|
||||
discoveredUnsaved: List<DiscoveredHost>,
|
||||
isOnline: (KnownHost) -> Boolean,
|
||||
/**
|
||||
* Dial a saved host. The second argument is `connect`'s one-off profile reference: null on a
|
||||
* host's own tile (follow whatever the host is bound to), the pinned profile's id on a pin tile.
|
||||
*/
|
||||
onConnect: (KnownHost, String?) -> Unit,
|
||||
onConnectDiscovered: (DiscoveredHost) -> Unit,
|
||||
onAddHost: () -> Unit,
|
||||
): List<HomeTile> = buildList {
|
||||
savedHosts.forEach { kh ->
|
||||
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
|
||||
add(
|
||||
HomeTile(
|
||||
id = "saved-${kh.id}",
|
||||
title = kh.name,
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = isOnline(kh),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
// 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. It rides in
|
||||
// the card's own chip now rather than as a "· Name" tail on the address, which is
|
||||
// where it read as an afterthought.
|
||||
profileName = bound?.name,
|
||||
profileAccent = accentColor(bound?.accent),
|
||||
activate = { onConnect(kh, null) },
|
||||
),
|
||||
)
|
||||
// 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).
|
||||
pinsFor(kh).forEach { p ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "pin-${kh.id}-${p.id}",
|
||||
title = kh.name,
|
||||
// The address, like every other card — the PROFILE is what makes this card
|
||||
// different, and it now says so in the chip instead of standing in for the
|
||||
// subtitle, which left a pin card unable to say where it pointed.
|
||||
subtitle = "${kh.address}:${kh.port}",
|
||||
filled = true,
|
||||
online = isOnline(kh),
|
||||
paired = kh.paired,
|
||||
knownHost = kh,
|
||||
pinnedProfileId = p.id,
|
||||
profileName = p.name,
|
||||
profileAccent = accentColor(p.accent),
|
||||
activate = { onConnect(kh, p.id) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
discoveredUnsaved.forEach { dh ->
|
||||
add(
|
||||
HomeTile(
|
||||
id = "disc-${dh.host}:${dh.port}",
|
||||
title = dh.name,
|
||||
subtitle = "${dh.host}:${dh.port}",
|
||||
online = true,
|
||||
activate = { onConnectDiscovered(dh) },
|
||||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
HomeTile(
|
||||
id = "add",
|
||||
title = "Add Host",
|
||||
subtitle = "Register a host by address",
|
||||
isAdd = true,
|
||||
activate = onAddHost,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -81,8 +82,8 @@ suspend fun connectToHost(
|
||||
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,
|
||||
|
||||
@@ -15,12 +15,13 @@ 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.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -41,11 +42,6 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
@@ -58,7 +54,7 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.unom.punktfunk.components.launcherIcon
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
import io.unom.punktfunk.kit.library.LibraryResult
|
||||
@@ -93,13 +89,6 @@ fun LibraryScreen(
|
||||
onLaunched: (ActiveSession) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
/**
|
||||
* The profile this shelf launches with, when it was opened from a PINNED host+profile card
|
||||
* (design §5.2a) rather than the host's own tile: a one-off, exactly like the card's plain
|
||||
* connect. Null = the host's tile, and the host's binding decides — the same rule
|
||||
* [ProfileStore.resolveFor] applies to every other connect.
|
||||
*/
|
||||
pinnedProfileId: String? = null,
|
||||
) {
|
||||
val ink = LocalGamepadInk.current
|
||||
BackHandler(onBack = onBack)
|
||||
@@ -110,25 +99,15 @@ fun LibraryScreen(
|
||||
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
||||
// A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice.
|
||||
var launching by remember { mutableStateOf(false) }
|
||||
// The profile every launch off this shelf runs with, resolved ONCE per shelf by the same rule
|
||||
// the host-list connect uses: this card's pin as the one-off, else the host's binding, else the
|
||||
// globals. Resolved here rather than per launch so a profile edited mid-browse cannot make two
|
||||
// titles on one shelf stream differently.
|
||||
val profile = remember(host.id, pinnedProfileId) {
|
||||
ProfileStore(context).resolveFor(host, pinnedProfileId)
|
||||
}
|
||||
val streamSettings = remember(settings, profile) { settings.effectiveFor(profile) }
|
||||
|
||||
// Keyed on the mgmt port too: a discovery tick can learn it after this screen is composed, and
|
||||
// the fetch must redo itself against the real port rather than stay on a stale 47990 failure.
|
||||
LaunchedEffect(host.address, host.port, host.fpHex, host.effectiveMgmtPort) {
|
||||
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||
state = LibState.Loading
|
||||
state = withContext(Dispatchers.IO) {
|
||||
val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull()
|
||||
?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.")
|
||||
when (val res = LibraryClient.fetch(
|
||||
address = host.address,
|
||||
mgmtPort = host.effectiveMgmtPort,
|
||||
mgmtPort = DEFAULT_MGMT_PORT,
|
||||
certPem = id.certPem,
|
||||
keyPem = id.privateKeyPem,
|
||||
fpHex = host.fpHex,
|
||||
@@ -148,17 +127,8 @@ fun LibraryScreen(
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
// A pinned card's shelf says so, in the card's own `host · profile` shape: what a
|
||||
// launch here will use is a property of the shelf, not something to remember from
|
||||
// the tile two screens back.
|
||||
ConsoleHeader(
|
||||
if (pinnedProfileId != null && profile != null) {
|
||||
"${host.name} · ${profile.name} — Library"
|
||||
} else {
|
||||
"${host.name} — Library"
|
||||
},
|
||||
)
|
||||
Column(Modifier.fillMaxSize().systemBarsPadding()) {
|
||||
ConsoleHeader("${host.name} — Library")
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
when (val s = state) {
|
||||
is LibState.Loading -> LoadingState()
|
||||
@@ -170,7 +140,7 @@ fun LibraryScreen(
|
||||
// Dial the host over the same pinned mTLS trust, booting straight
|
||||
// into this title (the host resolves `launch` = its library id).
|
||||
val handle = connectToHost(
|
||||
context, streamSettings, s.identity,
|
||||
context, settings, s.identity,
|
||||
host.address, host.port, host.fpHex, launch = game.id,
|
||||
)
|
||||
launching = false
|
||||
@@ -178,14 +148,11 @@ fun LibraryScreen(
|
||||
onLaunched(
|
||||
ActiveSession(
|
||||
handle,
|
||||
streamSettings,
|
||||
settings,
|
||||
host.clipboardSync,
|
||||
profileName = profile?.name,
|
||||
hostId = host.id,
|
||||
// Where to come back to when this game exits —
|
||||
// this shelf, pin and all, not the host's default one.
|
||||
// Where to come back to when this game exits.
|
||||
launchedFromLibrary = true,
|
||||
libraryProfileId = pinnedProfileId,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -204,7 +171,7 @@ fun LibraryScreen(
|
||||
// Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs.
|
||||
if (launching) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(ink.modalScrim),
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
@@ -220,7 +187,7 @@ fun LibraryScreen(
|
||||
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
@@ -255,10 +222,8 @@ private fun MessageState(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
// Internal (not private): the screenshot harness composes the real coverflow with mock games —
|
||||
// the library screen itself can't be shot, its state comes off the network.
|
||||
@Composable
|
||||
internal fun Coverflow(
|
||||
private fun Coverflow(
|
||||
games: List<GameEntry>,
|
||||
loader: ImageLoader,
|
||||
navActive: Boolean,
|
||||
@@ -297,18 +262,10 @@ internal fun Coverflow(
|
||||
Text(
|
||||
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
// The palette's ink, not white: on a pale field this heading was white on
|
||||
// near-white and simply wasn't there.
|
||||
color = ink.fg(0.45f),
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
letterSpacing = 2.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// A live region: this heading is the ONLY signal that the cursor has
|
||||
// crossed from the launchers into the games, and a coverflow gives a
|
||||
// reader no other way to notice — it is one strip, not two lists.
|
||||
.semantics { liveRegion = LiveRegionMode.Polite }
|
||||
.padding(bottom = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
@@ -330,22 +287,10 @@ internal fun Coverflow(
|
||||
.width(coverWidth)
|
||||
.height(coverHeight)
|
||||
// Touch: tap the centred cover to launch it; tap a neighbour to bring it centre.
|
||||
// The label says which of the two a press does, because from the poster
|
||||
// alone they are indistinguishable — and the CENTRED one is the only one A
|
||||
// acts on, which nothing else in the tree says.
|
||||
.clickable(
|
||||
onClickLabel = if (page == pagerState.currentPage) {
|
||||
"Launch ${games[page].title}"
|
||||
} else {
|
||||
"Bring ${games[page].title} to the centre"
|
||||
},
|
||||
) {
|
||||
.clickable {
|
||||
if (page == pagerState.currentPage) onLaunch(games[page])
|
||||
else scope.launch { pagerState.animateScrollToPage(page) }
|
||||
}
|
||||
.semantics {
|
||||
if (page == pagerState.currentPage) selected = true
|
||||
}
|
||||
.graphicsLayer {
|
||||
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
|
||||
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
|
||||
@@ -404,14 +349,11 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
val ink = LocalGamepadInk.current
|
||||
val candidates = game.art.posterCandidates
|
||||
var idx by remember(game.id) { mutableStateOf(0) }
|
||||
val shape = ConsoleShape.Poster
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
// The ground a cover sits on while its art loads (and the permanent one for a launcher
|
||||
// entry, which rarely has art). Palette-derived rather than a fixed indigo, so a poster
|
||||
// wall on a pale field isn't a grid of dark holes.
|
||||
.background(LocalGamepadPalette.current.groundColor)
|
||||
.background(Color(0xFF241F3D))
|
||||
.border(1.dp, ink.fg(0.12f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -425,53 +367,27 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||
)
|
||||
} else {
|
||||
// A launcher ships no poster by design, so its brand mark IS the poster — drawn big and
|
||||
// centred, tinted like the text it replaces. Falling back to the launcher's name says
|
||||
// "opens Steam" for a mark we don't ship; the title would read as "a game whose cover
|
||||
// failed to load".
|
||||
val mark = launcherIcon(game.iconToken)
|
||||
if (mark != null) {
|
||||
Icon(
|
||||
imageVector = mark,
|
||||
contentDescription = game.title,
|
||||
tint = ink.fg(0.75f),
|
||||
modifier = Modifier.fillMaxSize(0.45f),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
|
||||
// would read as "a game whose cover failed to load".
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
Text(
|
||||
game.storeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
// A launcher's badge is brand-filled, so it reads on the ACCENT; a game's sits on
|
||||
// a plain dark wash over its own art.
|
||||
color = if (game.isLauncher) ink.onAccent else Color.White,
|
||||
color = ink.fg,
|
||||
modifier = Modifier
|
||||
// A bare store name read out after the title says nothing about WHY it is
|
||||
// there; the poster's own description already carries the title.
|
||||
.semantics {
|
||||
contentDescription = if (game.isLauncher) {
|
||||
"Opens ${game.storeLabel}"
|
||||
} else {
|
||||
"From ${game.storeLabel}"
|
||||
}
|
||||
}
|
||||
.clip(ConsoleShape.Pill)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(
|
||||
// The console's palette accent, not `MaterialTheme.colorScheme.primary` —
|
||||
// that is the TOUCH theme's colour (Material You, seeded from the user's
|
||||
// wallpaper), which had nothing to do with the field this poster sits on.
|
||||
if (game.isLauncher) ink.accent
|
||||
if (game.isLauncher) MaterialTheme.colorScheme.primary
|
||||
else Color.Black.copy(alpha = 0.5f),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -158,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.
|
||||
@@ -196,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
|
||||
@@ -337,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
|
||||
@@ -366,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,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
|
||||
@@ -610,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)) {
|
||||
@@ -626,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
|
||||
@@ -651,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)
|
||||
@@ -681,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 }
|
||||
@@ -773,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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -577,7 +577,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 {
|
||||
|
||||
@@ -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
|
||||
@@ -70,7 +75,6 @@ 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
|
||||
@@ -103,20 +107,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,
|
||||
@@ -136,12 +126,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) {
|
||||
@@ -162,74 +152,16 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
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
|
||||
@@ -252,11 +184,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) {
|
||||
@@ -286,62 +213,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.",
|
||||
@@ -349,7 +236,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
|
||||
@@ -472,9 +362,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
|
||||
@@ -484,9 +371,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).
|
||||
// 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).
|
||||
// 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 }
|
||||
@@ -497,11 +384,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
|
||||
@@ -545,32 +427,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
|
||||
@@ -640,12 +505,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// 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
|
||||
}
|
||||
router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) }
|
||||
// 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
|
||||
@@ -691,11 +551,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)
|
||||
@@ -788,7 +645,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
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
|
||||
@@ -799,7 +655,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()
|
||||
@@ -917,13 +772,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
|
||||
.roundToInt(),
|
||||
)
|
||||
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) {
|
||||
@@ -987,22 +837,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.
|
||||
@@ -1013,47 +847,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.
|
||||
@@ -1078,45 +871,40 @@ 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.
|
||||
// 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)) }
|
||||
// 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.
|
||||
@@ -1153,8 +941,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.
|
||||
*/
|
||||
@@ -1170,25 +997,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
|
||||
@@ -1245,33 +1053,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()
|
||||
}
|
||||
}
|
||||
@@ -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,107 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The console route to the two sub-screens, driven through the REAL settings screen — the rows
|
||||
* themselves are pinned by `ConsoleSubScreenRowsTest`; what needs the Compose runtime is the trip:
|
||||
* that a press on the row reaches the shell, and that coming back lands where you left rather than
|
||||
* at the top of the first section (the shell's `AnimatedContent` discards a screen's state the
|
||||
* moment it stops being the target, so the place has to travel out and back).
|
||||
*
|
||||
* Rows are activated by TAP for the same reason `GamepadSettingsLayoutTest` does it: the pad path
|
||||
* needs a `MainActivity` for its probes, and both routes end in the same `activate`.
|
||||
*
|
||||
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 while
|
||||
* the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class ConsoleSubScreenRoutesTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun openingTheControllersRowNavigatesAndReportsWhereItWas() {
|
||||
var opened = 0
|
||||
var place: GpSettingsPlace? = null
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(
|
||||
initial = Settings(),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
onOpenControllers = { opened++ },
|
||||
// Entering as if we had just come back from it, which is also what puts the cursor
|
||||
// on the row — so a single tap ACTIVATES rather than merely focusing.
|
||||
resume = GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
|
||||
onPlace = { place = it },
|
||||
)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Connected controllers").performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
assertEquals("the console never reached the diagnostics screen", 1, opened)
|
||||
assertEquals(
|
||||
"the place has to leave before the row does — this screen is gone the next frame",
|
||||
GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
|
||||
place,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Back from a sub-screen lands on the section it was opened from, with the row on screen. The
|
||||
* cursor is restored by row ID rather than index, so it survives a section whose length follows
|
||||
* the hardware.
|
||||
*/
|
||||
@Test
|
||||
fun comingBackFromTheNoticesLandsOnTheRowThatOpenedThem() {
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(
|
||||
initial = Settings(),
|
||||
onChange = {},
|
||||
onBack = {},
|
||||
resume = GpSettingsPlace(GpTab.INTERFACE, "licenses"),
|
||||
)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
|
||||
// Not back at the top of the first section — "Resolution" leads the Stream tab, which is
|
||||
// where a screen that forgot its place would be.
|
||||
compose.onNodeWithText("Resolution").assertDoesNotExist()
|
||||
// And the legend describes THIS row's A. It said the literal "Pin to hosts" on every
|
||||
// non-adjustable row back when profiles were the only ones.
|
||||
compose.onNodeWithText("Open").assertIsDisplayed()
|
||||
compose.onNodeWithText("Pin to hosts").assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The notices screen stands on its own on the console's field: no Scaffold or Surface above it
|
||||
* (the shell has neither), its own backdrop, and a legend that says how to leave. Composing it
|
||||
* is most of the assertion — a screen that only ever ran inside the touch Scaffold takes its
|
||||
* content colour from one.
|
||||
*/
|
||||
@Test
|
||||
fun theConsoleNoticesScreenStandsOnItsOwn() {
|
||||
compose.setContent { ConsoleLicensesScreen(onBack = {}) }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
|
||||
compose.onNodeWithText("Scroll").assertIsDisplayed()
|
||||
compose.onNodeWithText("Close").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The console's route to the two screens that were reachable from touch only — the
|
||||
* connected-controllers diagnostics and the open-source notices.
|
||||
*
|
||||
* "Touch only" reads as a minor gap on a phone and is a dead end on a TV box, where the console IS
|
||||
* the interface: there is no touch UI to fall back to, so a screen with no console row could not be
|
||||
* opened at all. These pin the rows themselves; `ConsoleSubScreenRoutesTest` drives the real screen.
|
||||
*/
|
||||
class ConsoleSubScreenRowsTest {
|
||||
|
||||
private fun rows(
|
||||
forwarding: Boolean = true,
|
||||
version: String = "1.2.3",
|
||||
controllers: () -> Unit = {},
|
||||
licenses: () -> Unit = {},
|
||||
): List<GpRow> = buildSettingsRows(
|
||||
Settings(gamepadForwarding = forwarding),
|
||||
hasBodyVibrator = true,
|
||||
hasGyroscope = true,
|
||||
av1Capable = true,
|
||||
appVersion = version,
|
||||
openControllers = controllers,
|
||||
openLicenses = licenses,
|
||||
) {}
|
||||
|
||||
private fun row(rows: List<GpRow>, id: String): GpRow = rows.first { it.id == id }
|
||||
|
||||
@Test
|
||||
fun `the controllers row opens the diagnostics view from the controller section`() {
|
||||
var opened = 0
|
||||
val r = row(rows(controllers = { opened++ }), "controllers")
|
||||
assertEquals(GpTab.CONTROLLER, r.tab)
|
||||
assertEquals("Connected controllers", r.label)
|
||||
r.activate()
|
||||
assertEquals(1, opened)
|
||||
}
|
||||
|
||||
/**
|
||||
* It must NOT follow the master forwarding switch, unlike every other row in its section: the
|
||||
* screen it opens is what you reach for precisely when forwarding looks broken, and a diagnostic
|
||||
* that dims itself when the thing it diagnoses is off is worse than no diagnostic.
|
||||
*/
|
||||
@Test
|
||||
fun `the controllers row stays live with forwarding off`() {
|
||||
val off = rows(forwarding = false)
|
||||
assertTrue(row(off, "controllers").enabled)
|
||||
assertNotNull(liveRow(off, off.indexOfFirst { it.id == "controllers" }))
|
||||
// Its neighbours in the section still dim, so this is a deliberate exemption and not a
|
||||
// forgotten `enabled =`.
|
||||
assertFalse(row(off, "sc2").enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the about row opens the notices and states the installed version`() {
|
||||
var opened = 0
|
||||
val r = row(rows(version = "0.27.0", licenses = { opened++ }), "licenses")
|
||||
assertEquals(GpTab.INTERFACE, r.tab)
|
||||
assertEquals("About", r.header)
|
||||
// The version rides in the value slot — on a TV this row is the whole About page.
|
||||
assertEquals("0.27.0", r.value)
|
||||
r.activate()
|
||||
assertEquals(1, opened)
|
||||
}
|
||||
|
||||
/** Both navigate; neither holds a value, so left/right must be refused rather than silently eaten. */
|
||||
@Test
|
||||
fun `neither row steps a value`() {
|
||||
val all = rows()
|
||||
for (id in listOf("controllers", "licenses")) {
|
||||
val r = row(all, id)
|
||||
assertFalse("$id should draw no chevrons", r.adjustable)
|
||||
assertFalse("$id must refuse a step", r.adjust(1))
|
||||
assertFalse("$id must refuse a step", r.adjust(-1))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The legend follows the ROW. It used to say the literal "Pin to hosts" on every non-adjustable
|
||||
* row, because a profile row was the only kind there was — so the moment another one existed,
|
||||
* A on it was advertised as pinning something.
|
||||
*/
|
||||
@Test
|
||||
fun `an action row advertises what A actually does`() {
|
||||
val all = rows()
|
||||
assertEquals("Open", row(all, "controllers").actionHint)
|
||||
assertEquals("Open", row(all, "licenses").actionHint)
|
||||
val profiles = buildProfileRows(listOf(newProfile("Work")), emptyList(), tv = false) {}
|
||||
assertEquals("Pin to hosts", profiles.first().actionHint)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scroll geometry both console sub-screens share. A wall of text has no focusable rows for
|
||||
* Compose to keep visible, so these screens move the scroll state themselves — and how far one
|
||||
* press travels is the whole of their feel.
|
||||
*/
|
||||
@Test
|
||||
fun `a page overlaps what you were reading and a step is shorter still`() {
|
||||
val viewport = 1000f
|
||||
val page = consoleScrollDelta(viewport, page = true, dir = 1)
|
||||
val step = consoleScrollDelta(viewport, page = false, dir = 1)
|
||||
assertTrue("a page that skips a whole screenful loses your place", page < viewport)
|
||||
assertTrue("a page has to be worth pressing", page > viewport / 2f)
|
||||
assertTrue("a D-pad step must be shorter than a shoulder page", step > 0f && step < page)
|
||||
assertEquals("the other direction is the other way", -page, consoleScrollDelta(viewport, true, -1), 0.001f)
|
||||
// Before the first layout there is no viewport: a press then moves nothing, rather than
|
||||
// scrolling by a fraction of zero and reading as a dead button on the way in.
|
||||
assertEquals(0f, consoleScrollDelta(0f, page = true, dir = 1), 0f)
|
||||
}
|
||||
}
|
||||
@@ -1,168 +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 section names, in order. The desktop console carries one tab this client does not —
|
||||
* Input, which holds touch mode, mouse, invert-scroll and shortcuts: desktop-host settings
|
||||
* with nothing to set on a phone or a TV. The vectors flag it `desktop_only` rather than
|
||||
* leaving it out, so neither side has to red the other to be right.
|
||||
*/
|
||||
@Test
|
||||
fun tabNamesMatchTheSharedVectors() {
|
||||
val tabs = vectors.getJSONArray("tabs")
|
||||
val want = (0 until tabs.length())
|
||||
.map { tabs.getJSONObject(it) }
|
||||
.filterNot { it.optBoolean("desktop_only", false) }
|
||||
.map { it.getString("name") }
|
||||
assertEquals("console settings tabs", want, GpTab.entries.map { it.title })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.getBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The console settings list must not MOVE under the cursor. This is the regression net for the
|
||||
* layout instability the visual refresh fixed, and it needs the real Compose runtime because the
|
||||
* bug was entirely a layout one — every value the model held was correct throughout.
|
||||
*
|
||||
* What used to happen: the focused row unfolded its description in place
|
||||
* (`AnimatedVisibility` + `expandVertically`), so every step of the cursor shrank one row and grew
|
||||
* another and shifted every row below the focus point — on a list that is simultaneously being
|
||||
* scrolled to keep the focused row visible, whose target therefore moved mid-animation. The
|
||||
* description now renders in the screen's floating `ConsoleDetailBand`, which is an overlay and
|
||||
* cannot displace anything. Sideways, the value's `AnimatedContent` animated its own WIDTH on every
|
||||
* step, walking the ‹ chevron and the label's right edge back and forth.
|
||||
*
|
||||
* Focus is moved by TAP here rather than by pad: the pad path needs a `MainActivity` for its input
|
||||
* probes, and the screen routes both to the same `focus` state — the geometry under test is the
|
||||
* same either way.
|
||||
*
|
||||
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36
|
||||
* while the app compiles against 37.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class GamepadSettingsLayoutTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun settings() {
|
||||
compose.setContent {
|
||||
GamepadSettingsScreen(initial = Settings(), onChange = {}, onBack = {})
|
||||
}
|
||||
}
|
||||
|
||||
/** A Dp compared at hairline tolerance — a rounding difference is not a layout shift. */
|
||||
private fun assertSame(what: String, expected: Dp, actual: Dp) {
|
||||
assertEquals(what, expected.value.toDouble(), actual.value.toDouble(), 0.5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving the cursor down the list leaves every OTHER row exactly where it was. The rows below
|
||||
* the new focus are the ones the old in-row detail pushed around, so they are the assertion
|
||||
* that matters; the row above proves the shrink half.
|
||||
*/
|
||||
@Test
|
||||
fun focusingARowMovesNoOtherRow() {
|
||||
settings()
|
||||
// Entry focus is the first row (Resolution), so "Refresh rate" starts unfocused and
|
||||
// "Compositor" sits below both candidates.
|
||||
val refreshBefore = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
|
||||
val compositorBefore = compose.onNodeWithText("Compositor").getBoundsInRoot()
|
||||
|
||||
// One tap on an unfocused row focuses it (a second would activate it — see the screen).
|
||||
compose.onNodeWithText("Bitrate").performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
val refreshAfter = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
|
||||
val compositorAfter = compose.onNodeWithText("Compositor").getBoundsInRoot()
|
||||
assertSame("row above the cursor moved", refreshBefore.top, refreshAfter.top)
|
||||
assertSame("row below the cursor moved", compositorBefore.top, compositorAfter.top)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stepping a value leaves the row's own geometry alone. The label's right edge is the probe:
|
||||
* it is what the widening value slot used to shove, and it is stable for any value that fits
|
||||
* the slot (which every shipped Bitrate label does).
|
||||
*/
|
||||
@Test
|
||||
fun steppingAValueMovesNoLabel() {
|
||||
settings()
|
||||
compose.onNodeWithText("Bitrate").performClick() // focus it
|
||||
compose.waitForIdle()
|
||||
val labelBefore = compose.onNodeWithText("Bitrate").getBoundsInRoot()
|
||||
|
||||
compose.onNodeWithText("Bitrate").performClick() // now activates → cycles the value
|
||||
compose.waitForIdle()
|
||||
|
||||
val labelAfter = compose.onNodeWithText("Bitrate").getBoundsInRoot()
|
||||
assertSame("label moved sideways under a value step", labelBefore.left, labelAfter.left)
|
||||
assertSame("label moved sideways under a value step", labelBefore.right, labelAfter.right)
|
||||
assertSame("row changed height under a value step", labelBefore.top, labelAfter.top)
|
||||
}
|
||||
|
||||
/**
|
||||
* The focused row's description is on screen — in the floating band, not inside the row. Proves
|
||||
* the detail did not simply get dropped when it left the row: it is still what the cursor
|
||||
* explains itself with.
|
||||
*/
|
||||
@Test
|
||||
fun theFocusedRowsDetailIsShown() {
|
||||
settings()
|
||||
compose.onNodeWithText("Refresh rate").performClick()
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText("Frame rate the host renders and streams at.").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The console home's tile list ([buildHomeTiles]). Pure JVM — the carousel itself needs the live
|
||||
* JNI core to compose, so its ORDER and what each tile claims had no cover at all until now, and
|
||||
* both are exactly the kind of thing that survives a refactor looking fine and behaving wrong.
|
||||
*
|
||||
* Run: `./gradlew :app:testDebugUnitTest --tests 'io.unom.punktfunk.HomeTilesTest'`.
|
||||
*/
|
||||
class HomeTilesTest {
|
||||
private fun host(
|
||||
name: String,
|
||||
address: String,
|
||||
fp: String = "",
|
||||
profileId: String? = null,
|
||||
pins: List<String> = emptyList(),
|
||||
) = KnownHost(
|
||||
address = address,
|
||||
port = 9777,
|
||||
name = name,
|
||||
fpHex = fp,
|
||||
paired = true,
|
||||
id = "id-$name",
|
||||
profileId = profileId,
|
||||
pinnedProfileIds = pins,
|
||||
)
|
||||
|
||||
private fun advert(name: String, address: String, fp: String? = null) = DiscoveredHost(
|
||||
key = "$address:9777",
|
||||
name = name,
|
||||
host = address,
|
||||
port = 9777,
|
||||
fingerprint = fp,
|
||||
)
|
||||
|
||||
private val work = StreamProfile(id = "p-work", name = "Work", accent = "#3B82F6")
|
||||
private val travel = StreamProfile(id = "p-travel", name = "Travel")
|
||||
|
||||
/** The builder with nothing plugged in — every list empty, every callback a no-op. */
|
||||
private fun tiles(
|
||||
savedHosts: List<KnownHost> = emptyList(),
|
||||
profiles: List<StreamProfile> = emptyList(),
|
||||
pins: Map<String, List<StreamProfile>> = emptyMap(),
|
||||
discoveredUnsaved: List<DiscoveredHost> = emptyList(),
|
||||
online: Set<String> = emptySet(),
|
||||
onConnect: (KnownHost, String?) -> Unit = { _, _ -> },
|
||||
onConnectDiscovered: (DiscoveredHost) -> Unit = {},
|
||||
onAddHost: () -> Unit = {},
|
||||
) = buildHomeTiles(
|
||||
savedHosts = savedHosts,
|
||||
profiles = profiles,
|
||||
pinsFor = { kh -> pins[kh.id].orEmpty() },
|
||||
discoveredUnsaved = discoveredUnsaved,
|
||||
isOnline = { it.name in online },
|
||||
onConnect = onConnect,
|
||||
onConnectDiscovered = onConnectDiscovered,
|
||||
onAddHost = onAddHost,
|
||||
)
|
||||
|
||||
/**
|
||||
* A pin belongs to the host above it. Ordering is the whole affordance: on a controller a pin is
|
||||
* reached by walking one tile past its host, and a builder that grouped all the pins at the end
|
||||
* would still LOOK right in a screenshot of any single tile.
|
||||
*/
|
||||
@Test
|
||||
fun pinnedCardsFollowTheirOwnHost() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id, travel.id))
|
||||
val studio = host("studio", "192.168.1.61", pins = listOf(work.id))
|
||||
val ids = tiles(
|
||||
savedHosts = listOf(living, studio),
|
||||
profiles = listOf(work, travel),
|
||||
pins = mapOf(living.id to listOf(work, travel), studio.id to listOf(work)),
|
||||
).map { it.id }
|
||||
assertEquals(
|
||||
listOf(
|
||||
"saved-id-living",
|
||||
"pin-id-living-p-work",
|
||||
"pin-id-living-p-travel",
|
||||
"saved-id-studio",
|
||||
"pin-id-studio-p-work",
|
||||
"add",
|
||||
),
|
||||
ids,
|
||||
)
|
||||
}
|
||||
|
||||
/** Add Host is the last tile, always — including on a device with nothing saved or seen. */
|
||||
@Test
|
||||
fun theAddTileIsAlwaysLast() {
|
||||
val empty = tiles()
|
||||
assertEquals(listOf("add"), empty.map { it.id })
|
||||
assertTrue(empty.single().isAdd)
|
||||
|
||||
val populated = tiles(
|
||||
savedHosts = listOf(host("living", "192.168.1.42")),
|
||||
discoveredUnsaved = listOf(advert("studio", "192.168.1.61")),
|
||||
)
|
||||
assertEquals(listOf("saved-id-living", "disc-192.168.1.61:9777", "add"), populated.map { it.id })
|
||||
assertTrue(populated.last().isAdd)
|
||||
// The Add tile is not a host: no library, no options menu, nothing to wake.
|
||||
assertNull(populated.last().knownHost)
|
||||
}
|
||||
|
||||
/**
|
||||
* A host that is both saved and advertising appears ONCE. The de-dupe is the caller's
|
||||
* ([KnownHost.matches], which the screen applies before handing the list over) — checked here
|
||||
* because the rule that matters is the fingerprint one: a host that came back on a new DHCP
|
||||
* address is the same machine, and matching on address alone would offer it a second time as a
|
||||
* stranger, next to the record that already holds its trust.
|
||||
*/
|
||||
@Test
|
||||
fun aSavedHostSeenOnTheNetworkIsNotListedTwice() {
|
||||
val fp = "ab12cd34"
|
||||
val living = host("living", "192.168.1.42", fp = fp)
|
||||
// Same host, new address after a cold boot, plus a genuine stranger.
|
||||
val adverts = listOf(advert("living", "192.168.1.77", fp = fp), advert("stranger", "192.168.1.99"))
|
||||
val unsaved = adverts.filter { dh -> listOf(living).none { it.matches(dh) } }
|
||||
val ids = tiles(savedHosts = listOf(living), discoveredUnsaved = unsaved).map { it.id }
|
||||
assertEquals(listOf("saved-id-living", "disc-192.168.1.99:9777", "add"), ids)
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip says which profile a press will connect with — the host's binding on its own tile,
|
||||
* the pinned profile on a pin tile. The console cannot EDIT profiles, so this claim is the only
|
||||
* thing standing between a user and a stream with settings they didn't choose.
|
||||
*/
|
||||
@Test
|
||||
fun theChipNamesTheProfileThePressWillUse() {
|
||||
val living = host("living", "192.168.1.42", profileId = work.id, pins = listOf(travel.id))
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work, travel),
|
||||
pins = mapOf(living.id to listOf(travel)),
|
||||
)
|
||||
val own = result[0]
|
||||
assertEquals("Work", own.profileName)
|
||||
assertEquals(Color(0xFF3B82F6), own.profileAccent)
|
||||
assertNull(own.pinnedProfileId)
|
||||
|
||||
val pin = result[1]
|
||||
assertEquals("Travel", pin.profileName)
|
||||
assertEquals(travel.id, pin.pinnedProfileId)
|
||||
// Travel set no accent: a chip with no colour, not a crash and not a stray default.
|
||||
assertNull(pin.profileAccent)
|
||||
|
||||
// A binding whose profile was deleted resolves to nothing — the tile stays silent rather
|
||||
// than naming an id that resolves to nobody.
|
||||
val dangling = tiles(savedHosts = listOf(host("ghost", "10.0.0.5", profileId = "p-gone")))
|
||||
assertNull(dangling[0].profileName)
|
||||
}
|
||||
|
||||
/** Both address and the subtitle: a pin card says where it points, like every other card. */
|
||||
@Test
|
||||
fun everySavedTileSaysWhereItPoints() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id))
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work),
|
||||
pins = mapOf(living.id to listOf(work)),
|
||||
online = setOf("living"),
|
||||
)
|
||||
result.take(2).forEach {
|
||||
assertEquals("192.168.1.42:9777", it.subtitle)
|
||||
assertEquals("living", it.title)
|
||||
assertTrue(it.filled)
|
||||
assertTrue(it.online)
|
||||
assertTrue(it.paired)
|
||||
assertNotNull(it.knownHost)
|
||||
}
|
||||
// Both tiles reach the library (Y): a pin card opens its OWN shelf, whose launches carry
|
||||
// the pinned profile — the library is a way to start a card, not a host-level action.
|
||||
assertTrue(result[0].hasLibrary)
|
||||
assertTrue(result[1].hasLibrary)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a press DOES. A host's own tile dials with no one-off reference so the host's binding is
|
||||
* followed; a pin tile forces its own profile. Passing the pin's id as the binding (or the
|
||||
* other way round) is invisible until someone streams at the wrong bitrate.
|
||||
*/
|
||||
@Test
|
||||
fun activationCarriesTheRightProfileReference() {
|
||||
val living = host("living", "192.168.1.42", pins = listOf(work.id))
|
||||
val dialled = mutableListOf<Pair<String, String?>>()
|
||||
val discovered = mutableListOf<String>()
|
||||
var addOpened = false
|
||||
val result = tiles(
|
||||
savedHosts = listOf(living),
|
||||
profiles = listOf(work),
|
||||
pins = mapOf(living.id to listOf(work)),
|
||||
discoveredUnsaved = listOf(advert("stranger", "192.168.1.99")),
|
||||
onConnect = { kh, oneOff -> dialled += kh.name to oneOff },
|
||||
onConnectDiscovered = { dh -> discovered += dh.host },
|
||||
onAddHost = { addOpened = true },
|
||||
)
|
||||
result.forEach { it.activate() }
|
||||
assertEquals(listOf("living" to null, "living" to work.id), dialled)
|
||||
assertEquals(listOf("192.168.1.99"), discovered)
|
||||
assertTrue(addOpened)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,34 +34,19 @@ class ScreenshotTest {
|
||||
// cursor via an infinite animation that otherwise keeps Compose perpetually "busy", so
|
||||
// setContent's wait-for-idle never returns. Frozen, the capture is also deterministic.
|
||||
|
||||
/**
|
||||
* Full-screen content scenes: the compose root fills the device, so a root capture is the
|
||||
* shot. [statusBar] draws the fake system bar and pushes content below it (see
|
||||
* [ShotStatusFrame]) — off for the immersive surfaces (stream, console shell), which hide
|
||||
* the real bar too.
|
||||
*/
|
||||
private fun shootRoot(
|
||||
name: String,
|
||||
statusBar: Boolean = true,
|
||||
content: @androidx.compose.runtime.Composable () -> Unit,
|
||||
) {
|
||||
/** Full-screen content scenes: the compose root fills the device, so a root capture is the shot. */
|
||||
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.onRoot().captureRoboImage("$out/phone-$name.png")
|
||||
}
|
||||
|
||||
/** Dialog scenes: the AlertDialog is a separate window, so capture the whole screen (all windows). */
|
||||
private fun shootScreen(
|
||||
name: String,
|
||||
statusBar: Boolean = true,
|
||||
content: @androidx.compose.runtime.Composable () -> Unit,
|
||||
) {
|
||||
private fun shootScreen(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme { if (statusBar) ShotStatusFrame(content) else content() } }
|
||||
// 1.6 s, not 0.8: a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s and the
|
||||
// add-host sheet's Connect button was captured half below the frame.
|
||||
compose.mainClock.advanceTimeBy(1600)
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
captureScreenRoboImage("$out/phone-$name.png")
|
||||
}
|
||||
|
||||
@@ -88,25 +73,15 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") // landscape — the stream is immersive
|
||||
fun stream() = shootRoot("stream", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamCompact() = shootRoot("stream-compact", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
fun streamCompact() = shootRoot("stream-compact") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal", statusBar = false) { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
// Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the
|
||||
// aspect is load-bearing.
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerPad() = shootRoot("stream-banner-pad", statusBar = false) { StreamBannerScene(pad = true) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerTouch() = shootRoot("stream-banner-touch", statusBar = false) { StreamBannerScene(pad = false) }
|
||||
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
|
||||
|
||||
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||
@Test
|
||||
@@ -129,90 +104,15 @@ class ScreenshotTest {
|
||||
|
||||
// The console flow is the full-screen aurora takeover (a root capture).
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console", statusBar = false) { ConnectConsoleScene() }
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings", statusBar = false) { ConsoleSettingsScene() }
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
|
||||
@Test
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light", statusBar = false) { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console actually runs in, and a DIFFERENT layout since the
|
||||
* on-glass review: rows capped and left-aligned, the focused row's description in a side pane
|
||||
* on the right instead of the floating band.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleSettingsLandscape() =
|
||||
shootRoot("console-settings-landscape", statusBar = false) { ConsoleSettingsScene() }
|
||||
|
||||
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
|
||||
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
|
||||
// renderings of the same palette can be compared rather than assumed equivalent.
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleHomeLight() = shootRoot("console-home-light", statusBar = false) { ConsoleHomeScene(paletteId = "holo") }
|
||||
|
||||
/**
|
||||
* Landscape — the orientation the console UI actually runs in, and the only one wide enough to
|
||||
* show the carousel's NEIGHBOURS, which is where the projected turn (`CARD_TURN_RAD`) lives.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleHomeLandscape() = shootRoot("console-home-landscape", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
/**
|
||||
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
|
||||
* drifting blobs — an honest approximation rather than an emulation, and the thing this shot
|
||||
* exists to keep honest.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs", statusBar = false) { ConsoleHomeScene() }
|
||||
|
||||
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
|
||||
// pale palette, because the console draws them through a ColorScheme derived from the palette's
|
||||
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
@Test
|
||||
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
|
||||
|
||||
@Test
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The touch presentation, pads connected — landscape, like every store frame: the app is
|
||||
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun controllers() = shootRoot("controllers") { ControllersScene() }
|
||||
|
||||
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleControllersLandscape() =
|
||||
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The library coverflow with a mock shelf — the store's PICK & PLAY frame. Landscape: the
|
||||
* orientation the coverflow actually runs in, and the only one wide enough for neighbours.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun library() = shootRoot("library", statusBar = false) { LibraryScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
|
||||
shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
@@ -234,13 +134,4 @@ class ScreenshotTest {
|
||||
HostsScene()
|
||||
PairDialog()
|
||||
}
|
||||
|
||||
/**
|
||||
* The add-host sheet (separate window → whole-screen capture). Pixel-like geometry, not the
|
||||
* default 360×800dp: same 1080×2400 px, but at 420 dpi the extra dp headroom is what lets the
|
||||
* sheet's Connect button — the row that carries the resolution promise — fit in frame.
|
||||
*/
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w411dp-h915dp-420dpi")
|
||||
fun addHost() = shootScreen("add-host") { AddHostScene() }
|
||||
}
|
||||
|
||||
@@ -1,35 +1,14 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BlendMode
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
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.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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BatteryFull
|
||||
import androidx.compose.material.icons.filled.SignalCellular4Bar
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
@@ -53,32 +32,9 @@ import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import io.unom.punktfunk.GamepadHome
|
||||
import io.unom.punktfunk.GamepadInk
|
||||
import io.unom.punktfunk.GamepadPalette
|
||||
import coil.ImageLoader
|
||||
import coil.test.FakeImageLoaderEngine
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.AddHostSheet
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleHeader
|
||||
import io.unom.punktfunk.ConsoleLegendInset
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.ControllersScreen
|
||||
import io.unom.punktfunk.Coverflow
|
||||
import io.unom.punktfunk.GamepadAuroraBackground
|
||||
import io.unom.punktfunk.GamepadHintBar
|
||||
import io.unom.punktfunk.PadGlyph
|
||||
import io.unom.punktfunk.PadInfo
|
||||
import io.unom.punktfunk.consoleLegendInsets
|
||||
import io.unom.punktfunk.consoleSafeArea
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.library.Artwork
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.HomeTile
|
||||
import io.unom.punktfunk.LocalGamepadInk
|
||||
import io.unom.punktfunk.LocalGamepadPalette
|
||||
import io.unom.punktfunk.Settings
|
||||
@@ -87,11 +43,10 @@ import io.unom.punktfunk.SettingsCategory
|
||||
import io.unom.punktfunk.SettingsScreen
|
||||
import io.unom.punktfunk.StatsOverlay
|
||||
import io.unom.punktfunk.StatsVerbosity
|
||||
import io.unom.punktfunk.StreamStartBanner
|
||||
import io.unom.punktfunk.ProfileEditorFields
|
||||
import io.unom.punktfunk.ProfileStore
|
||||
import io.unom.punktfunk.SettingsOverlay
|
||||
import io.unom.punktfunk.SpeedTestPrompt
|
||||
import io.unom.punktfunk.SpeedTestDialog
|
||||
import io.unom.punktfunk.SpeedTestPhase
|
||||
import io.unom.punktfunk.SpeedTestTarget
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
@@ -110,51 +65,6 @@ internal fun ShotTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = BrandDark, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Robolectric has no system UI, so every capture was missing the status bar and the content sat
|
||||
* where the bar belongs — on the Pixel render the app title collided with the camera punch-hole.
|
||||
* This frame draws a plausible bar (time left, radios right, the CENTRE left empty for the hole)
|
||||
* and pushes the scene below it, the same geometry real insets produce. The height mirrors a
|
||||
* Pixel's tall bar as measured off a real 1344×2992 capture (~145 px ≈ 40 dp).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ShotStatusFrame(content: @Composable () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(40.dp).padding(horizontal = 28.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"21:47",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Wifi, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(15.dp),
|
||||
)
|
||||
Icon(
|
||||
Icons.Filled.SignalCellular4Bar, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Icon(
|
||||
Icons.Filled.BatteryFull, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
private data class MockHost(
|
||||
val name: String,
|
||||
val address: String,
|
||||
@@ -332,8 +242,7 @@ internal fun SettingsProfileScene() {
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeedTestScene() {
|
||||
SpeedTestPrompt(
|
||||
gamepadUi = false,
|
||||
SpeedTestDialog(
|
||||
hostName = "Living Room PC",
|
||||
target = SpeedTestTarget.Ask(newProfile("Game")),
|
||||
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
|
||||
@@ -518,455 +427,6 @@ internal fun ConnectConsoleScene() =
|
||||
* stand in for it: this is a different screen with different navigation, and the strip is the part
|
||||
* a layout regression would eat first.
|
||||
*/
|
||||
/**
|
||||
* The start-of-stream banner over the same synthetic "streamed frame" — the real
|
||||
* [StreamStartBanner] at full opacity, since the caller owns the 6 s timer and a shot must not race
|
||||
* it. Two variants because the WORDS are the point: the banner names pad chords or touch gestures
|
||||
* depending on what the session actually has, and a screenshot is the only place the two can be
|
||||
* compared side by side.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StreamBannerScene(pad: Boolean) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B)),
|
||||
),
|
||||
),
|
||||
) {
|
||||
StreamStartBanner(
|
||||
text = if (pad) {
|
||||
"Hold Select + Start + L1 + R1 to leave · Select + Y mic · Select + X stats"
|
||||
} else {
|
||||
"Back leaves the stream · three-finger tap for stats"
|
||||
},
|
||||
alpha = 1f,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The console HOME — the host carousel over the living backdrop, which is the screen the aurora is
|
||||
* most of. Worth its own shot for exactly that reason: on API 33+ the field is the real bicubic
|
||||
* MESH (`GamepadAurora`'s AGSL port of the desktop console's shader) and below it the four-blob
|
||||
* fallback, and the two are only comparable side by side. The scene composes [GamepadHome]
|
||||
* directly with mock tiles — it needs no JNI core and no session, unlike the ConnectScreen that
|
||||
* normally feeds it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleHomeScene(paletteId: String = "violet") {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
val tiles = listOf(
|
||||
HomeTile(
|
||||
id = "living", title = "Living Room PC", subtitle = "192.168.1.42 · Paired",
|
||||
filled = true, online = true, paired = true, activate = {},
|
||||
),
|
||||
HomeTile(
|
||||
id = "studio", title = "studio-deck", subtitle = "192.168.1.61 · Discovered",
|
||||
online = true, activate = {},
|
||||
),
|
||||
HomeTile(id = "add", title = "Add Host", subtitle = "By address", isAdd = true, activate = {}),
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
) {
|
||||
GamepadHome(
|
||||
tiles = tiles,
|
||||
libraryEnabled = true,
|
||||
controllerName = "Xbox Wireless Controller",
|
||||
navActive = false,
|
||||
onActivate = {},
|
||||
onOpenLibrary = {},
|
||||
onOpenSettings = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two screens the console could not reach at all until WP8.3 — the open-source notices and the
|
||||
* connected-controllers view — in their console presentation.
|
||||
*
|
||||
* Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the
|
||||
* console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* before this could catch light-grey body text stranded on a pastel field.
|
||||
*
|
||||
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] — the
|
||||
* deterministic connected-pads state the store listing needs.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
// Robolectric enumerates no input devices, so the shot injects the two pads the store
|
||||
// listing talks about — the empty "no controller detected" state proves the palette but
|
||||
// sells nothing.
|
||||
ConsoleControllersScreen(
|
||||
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
|
||||
* [Surface]: the activity provides the dark ground in the app, and without one here the content
|
||||
* color falls back to black-on-white while the cards stay dark.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScene() =
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
ControllersScreen(gamepadSetting = 0, onBack = {}, padsOverride = shotPads())
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Add a host" bottom sheet over the host grid — the store's onboarding frame. State is
|
||||
* hoisted in production (ConnectScreen), so the scene passes a filled-in form directly; the
|
||||
* mode label mirrors what a paired 120 Hz phone shows on the connect button.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddHostScene() {
|
||||
HostsScene()
|
||||
AddHostSheet(
|
||||
hostName = "Living Room PC", onHostNameChange = {},
|
||||
host = "192.168.1.42", onHostChange = {},
|
||||
port = "9777", onPortChange = {},
|
||||
connecting = false, modeLabel = "2992×1344@120",
|
||||
onDismiss = {}, onConnect = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
|
||||
/** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */
|
||||
internal fun shotPads() = listOf(
|
||||
PadInfo(
|
||||
name = "DualSense Wireless Controller",
|
||||
detail = "054C:0CE6 · gamepad · joystick",
|
||||
forwarded = true, controllerNumber = 1,
|
||||
resolvedPref = Gamepad.PREF_DUALSENSE, canRumble = true,
|
||||
),
|
||||
PadInfo(
|
||||
name = "Xbox Wireless Controller",
|
||||
detail = "045E:0B13 · gamepad · joystick",
|
||||
forwarded = true, controllerNumber = 2,
|
||||
resolvedPref = Gamepad.PREF_XBOXONE, canRumble = true,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Publish the palette locals `App` would normally provide. A scene that calls a console screen
|
||||
* directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently
|
||||
* prove nothing at all.
|
||||
*/
|
||||
/**
|
||||
* The game-library coverflow (the real [Coverflow] over the real console chrome) with a mock shelf.
|
||||
* The library screen itself can't be shot — its state comes off the network — so the scene rebuilds
|
||||
* the same shell [io.unom.punktfunk.LibraryScreen] draws around it: aurora, header, floating hint
|
||||
* bar. Cover art is answered synchronously by coil-test's [FakeImageLoaderEngine] with generated
|
||||
* posters, so the frozen animation clock never races an async load.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LibraryScene(paletteId: String = "violet") = ConsolePalette(paletteId) {
|
||||
val context = LocalContext.current
|
||||
val loader = remember { shotLibraryLoader(context) }
|
||||
val games = remember { shotGames() }
|
||||
val hazeState = remember { HazeState() }
|
||||
val landscape =
|
||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ConsoleHeader("Living Room PC — Library")
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Coverflow(games, loader, navActive = false, onLaunch = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
listOf(PadGlyph.hint('A', "Launch"), PadGlyph.hint('B', "Close")),
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A believable shelf: four titles with art plus the Steam launcher entry (brand-mark tile). */
|
||||
private fun shotGames() = listOf(
|
||||
GameEntry("custom:aurora", "custom", "Aurora Drift", Artwork("shot://art/aurora", null, null)),
|
||||
GameEntry("steam:starfall", "steam", "Starfall Vale", Artwork("shot://art/starfall", null, null)),
|
||||
GameEntry("heroic:neon", "heroic", "Neon Circuit", Artwork("shot://art/neon", null, null)),
|
||||
GameEntry("gog:ember", "gog", "Ember Peaks", Artwork("shot://art/ember", null, null)),
|
||||
GameEntry("steam:launcher", "steam", "Steam", Artwork(null, null, null), role = "launcher", icon = "steam"),
|
||||
)
|
||||
|
||||
private fun shotLibraryLoader(context: Context): ImageLoader {
|
||||
val engine = FakeImageLoaderEngine.Builder()
|
||||
.intercept("shot://art/aurora", poster(context, "AURORA DRIFT", ::drawAurora))
|
||||
.intercept("shot://art/starfall", poster(context, "STARFALL VALE", ::drawStarfall))
|
||||
.intercept("shot://art/neon", poster(context, "NEON CIRCUIT", ::drawNeon))
|
||||
.intercept("shot://art/ember", poster(context, "EMBER PEAKS", ::drawEmber))
|
||||
.default(ColorDrawable(0xFF221E44.toInt()))
|
||||
.build()
|
||||
return ImageLoader.Builder(context).components { add(engine) }.build()
|
||||
}
|
||||
|
||||
// The four shelf posters, drawn procedurally at capture time — the same designs the Apple
|
||||
// harness draws with CoreGraphics (`ShotPosterArt.swift`), so both listings show the same shelf.
|
||||
// All geometry below is in a 600×900, y-UP space (matching the CG source); `posterY()` flips it.
|
||||
|
||||
private const val POSTER_W = 600
|
||||
private const val POSTER_H = 900
|
||||
|
||||
private fun posterY(v: Float) = POSTER_H - v
|
||||
|
||||
/** Deterministic LCG (same constants and seeds as the Swift twin) so every capture is identical. */
|
||||
private class ShotRand(var state: ULong) {
|
||||
fun next(): Float {
|
||||
state = state * 6364136223846793005UL + 1442695040888963407UL
|
||||
return (state shr 33).toFloat() / (1L shl 31).toFloat()
|
||||
}
|
||||
fun range(lo: Float, hi: Float) = lo + next() * (hi - lo)
|
||||
}
|
||||
|
||||
private fun poster(context: Context, title: String, draw: (Canvas) -> Unit): Drawable {
|
||||
val bmp = Bitmap.createBitmap(POSTER_W, POSTER_H, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
draw(canvas)
|
||||
posterTitle(canvas, title)
|
||||
return BitmapDrawable(context.resources, bmp)
|
||||
}
|
||||
|
||||
/** Vertical gradient over the full canvas; stops bottom-to-top as (location, color). */
|
||||
private fun sky(canvas: Canvas, stops: List<Pair<Float, Int>>) {
|
||||
canvas.drawRect(
|
||||
0f, 0f, POSTER_W.toFloat(), POSTER_H.toFloat(),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = LinearGradient(
|
||||
0f, POSTER_H.toFloat(), 0f, 0f,
|
||||
stops.map { it.second }.toIntArray(),
|
||||
stops.map { it.first }.toFloatArray(),
|
||||
Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun glowDot(canvas: Canvas, x: Float, y: Float, radius: Float, color: Int) {
|
||||
canvas.drawCircle(
|
||||
x, posterY(y), radius,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = RadialGradient(
|
||||
x, posterY(y), radius, color, color and 0x00FFFFFF, Shader.TileMode.CLAMP,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun shotAlpha(color: Int, a: Float) = (color and 0x00FFFFFF) or ((a * 255).toInt() shl 24)
|
||||
|
||||
/** Three strokes, wide-and-faint to thin-and-bright, in screen blend — the cheap neon glow. */
|
||||
private fun glowStroke(canvas: Canvas, path: Path, width: Float, color: Int) {
|
||||
for ((mult, a) in listOf(2.6f to 0.12f, 1.3f to 0.28f, 0.55f to 0.85f)) {
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
strokeWidth = width * mult
|
||||
this.color = shotAlpha(color, a)
|
||||
blendMode = BlendMode.SCREEN
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun posterTitle(canvas: Canvas, title: String) {
|
||||
sky(canvas, listOf(0f to shotAlpha(0x000000, 0.55f), 0.22f to shotAlpha(0x000000, 0f)))
|
||||
canvas.drawText(
|
||||
title, POSTER_W / 2f, posterY(72f),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = shotAlpha(0xFFFFFF, 0.94f)
|
||||
textSize = 46f
|
||||
letterSpacing = 5f / 46f
|
||||
typeface = Typeface.create("sans-serif-condensed", Typeface.BOLD)
|
||||
textAlign = Paint.Align.CENTER
|
||||
setShadowLayer(8f, 0f, 2f, shotAlpha(0x000000, 0.6f))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawAurora(canvas: Canvas) {
|
||||
sky(canvas, listOf(0f to 0xFF221E5C.toInt(), 0.45f to 0xFF141040.toInt(), 1f to 0xFF0B0830.toInt()))
|
||||
val rng = ShotRand(11UL)
|
||||
repeat(48) {
|
||||
val x = rng.range(0f, 600f)
|
||||
val y = rng.range(300f, 890f)
|
||||
val r = rng.range(1.4f, 3.2f)
|
||||
glowDot(canvas, x, y, r, shotAlpha(0xFFFFFF, rng.range(0.25f, 0.8f)))
|
||||
}
|
||||
data class Ribbon(
|
||||
val base: Float, val amp: Float, val freq: Float,
|
||||
val phase: Float, val w: Float, val c: Int,
|
||||
)
|
||||
for (r in listOf(
|
||||
Ribbon(700f, 55f, 1.15f, 0.4f, 30f, 0xFF6656F2.toInt()),
|
||||
Ribbon(615f, 70f, 1.4f, 2.2f, 24f, 0xFF8F7BFF.toInt()),
|
||||
Ribbon(530f, 45f, 0.95f, 4.1f, 18f, 0xFF35D0C5.toInt()),
|
||||
)) {
|
||||
val path = Path()
|
||||
for (i in 0..60) {
|
||||
val t = i / 60f
|
||||
val x = t * 600f
|
||||
val y = r.base + r.amp * kotlin.math.sin(t * Math.PI.toFloat() * r.freq + r.phase) + 40f * t
|
||||
if (i == 0) path.moveTo(x, posterY(y)) else path.lineTo(x, posterY(y))
|
||||
}
|
||||
glowStroke(canvas, path, r.w, r.c)
|
||||
}
|
||||
// A low ridge grounds the scene — without it the poster's bottom half is bare sky.
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF191345.toInt(), 212f, 30f),
|
||||
Triple(0xFF0E0A2E.toInt(), 148f, 38f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
|
||||
for (i in 1..9) {
|
||||
val x = i / 9f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawStarfall(canvas: Canvas) {
|
||||
sky(
|
||||
canvas,
|
||||
listOf(
|
||||
0f to 0xFF2A0C24.toInt(), 0.35f to 0xFF7A2B58.toInt(),
|
||||
0.8f to 0xFFE86FA8.toInt(), 1f to 0xFFF7A8C8.toInt(),
|
||||
),
|
||||
)
|
||||
val rng = ShotRand(23UL)
|
||||
repeat(6) {
|
||||
val hx = rng.range(60f, 560f)
|
||||
val hy = rng.range(420f, 840f)
|
||||
val len = rng.range(90f, 170f)
|
||||
val dx = kotlin.math.cos(2.15f)
|
||||
val dy = kotlin.math.sin(2.15f)
|
||||
val path = Path()
|
||||
path.moveTo(hx, posterY(hy))
|
||||
path.lineTo(hx + dx * len, posterY(hy + dy * len))
|
||||
glowStroke(canvas, path, 4f, 0xFFFFE3EF.toInt())
|
||||
glowDot(canvas, hx, hy, 11f, shotAlpha(0xFFFFFF, 0.9f))
|
||||
}
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF3A1430.toInt(), 300f, 26f),
|
||||
Triple(0xFF1D0818.toInt(), 216f, 34f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline))
|
||||
for (i in 1..8) {
|
||||
val x = i / 8f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawNeon(canvas: Canvas) {
|
||||
sky(canvas, listOf(0f to 0xFF0A2A33.toInt(), 1f to 0xFF04161C.toInt()))
|
||||
val rng = ShotRand(7UL)
|
||||
val ring = Path().apply {
|
||||
addOval(300f - 105f, posterY(560f) - 105f, 300f + 105f, posterY(560f) + 105f, Path.Direction.CW)
|
||||
}
|
||||
glowStroke(canvas, ring, 10f, 0xFF35D0C5.toInt())
|
||||
val gateX = listOf(-105f, 105f, 0f, 0f)
|
||||
val gateY = listOf(0f, 0f, -105f, 105f)
|
||||
for (i in 0 until 9) {
|
||||
var px: Float
|
||||
var py: Float
|
||||
if (i < 4) {
|
||||
px = 300f + gateX[i]
|
||||
py = 560f + gateY[i]
|
||||
} else {
|
||||
px = 40f * kotlin.math.round(rng.range(1f, 14f))
|
||||
py = 40f * kotlin.math.round(rng.range(1f, 21f))
|
||||
}
|
||||
val path = Path()
|
||||
path.moveTo(px, posterY(py))
|
||||
var horizontal = rng.next() > 0.5f
|
||||
repeat(rng.range(3f, 6f).toInt()) {
|
||||
val step = 40f * kotlin.math.round(rng.range(1f, 4f)) * (if (rng.next() > 0.5f) 1f else -1f)
|
||||
if (horizontal) px = (px + step).coerceIn(20f, 580f) else py = (py + step).coerceIn(20f, 880f)
|
||||
path.lineTo(px, posterY(py))
|
||||
horizontal = !horizontal
|
||||
}
|
||||
val color = if (rng.next() > 0.6f) 0xFF7FE8DE.toInt() else 0xFF35D0C5.toInt()
|
||||
glowStroke(canvas, path, 5f, color)
|
||||
glowDot(canvas, px, py, 12f, shotAlpha(color, 0.9f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawEmber(canvas: Canvas) {
|
||||
sky(
|
||||
canvas,
|
||||
listOf(
|
||||
0f to 0xFF200A04.toInt(), 0.3f to 0xFF7A2E12.toInt(),
|
||||
0.42f to 0xFFEF8F4B.toInt(), 1f to 0xFF2A0E06.toInt(),
|
||||
),
|
||||
)
|
||||
glowDot(canvas, 300f, 385f, 160f, shotAlpha(0xFFC37A, 0.85f))
|
||||
val rng = ShotRand(41UL)
|
||||
for ((fill, baseline, rough) in listOf(
|
||||
Triple(0xFF5A2410.toInt(), 340f, 42f),
|
||||
Triple(0xFF401708.toInt(), 255f, 56f),
|
||||
Triple(0xFF200A04.toInt(), 165f, 48f),
|
||||
)) {
|
||||
val path = Path()
|
||||
path.moveTo(0f, posterY(0f))
|
||||
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
|
||||
for (i in 1..10) {
|
||||
val x = i / 10f * 600f
|
||||
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
|
||||
}
|
||||
path.lineTo(600f, posterY(0f))
|
||||
path.close()
|
||||
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
|
||||
}
|
||||
repeat(20) {
|
||||
val x = rng.range(30f, 570f)
|
||||
val y = rng.range(180f, 620f)
|
||||
val r = rng.range(2.5f, 6f)
|
||||
glowDot(canvas, x, y, r, shotAlpha(0xFFB067, rng.range(0.35f, 0.9f)))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
val palette = GamepadPalette.named(paletteId)
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides palette,
|
||||
LocalGamepadInk provides GamepadInk.of(palette),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleSettingsScene(paletteId: String = "violet") {
|
||||
// The scene calls the screen directly, so it has to publish the palette locals `App` would
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package io.unom.punktfunk.screenshots
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The same Roborazzi harness as ScreenshotTest, at Android TV geometry: 960×540dp in the
|
||||
* `television` UI mode at xhdpi (2.0×) = 1920×1080 px — the Play Store's 16:9 TV screenshot size,
|
||||
* captured 1:1 with no resampling. Only the screens that exist on a TV are shot here: the
|
||||
* gamepad-console shell (what LEANBACK_LAUNCHER opens into) and the in-stream view. Files are
|
||||
* prefixed `tv-` so the artifact separates the form factors.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class TvScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val out = "build/outputs/roborazzi"
|
||||
|
||||
private fun shootRoot(name: String, content: @androidx.compose.runtime.Composable () -> Unit) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { ShotTheme(content) }
|
||||
compose.mainClock.advanceTimeBy(800)
|
||||
compose.onRoot().captureRoboImage("$out/tv-$name.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stream() = shootRoot("stream") { StreamScene(io.unom.punktfunk.StatsVerbosity.COMPACT) }
|
||||
|
||||
@Test
|
||||
fun streamDetailed() =
|
||||
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
|
||||
/** The library coverflow at TV geometry — the store's PICK & PLAY frame for the TV listing. */
|
||||
@Test
|
||||
fun library() = shootRoot("library") { LibraryScene() }
|
||||
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
}
|
||||
@@ -9,8 +9,7 @@ tolerates it being raw JSON *or* base64-encoded JSON.
|
||||
Usage (upload a new build):
|
||||
SERVICE_ACCOUNT_JSON='<raw-or-base64 SA key>' \
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
--aab path/to/app-release.aab --track beta --also-track alpha \
|
||||
--status completed [--no-commit]
|
||||
--aab path/to/app-release.aab --track internal --status completed [--no-commit]
|
||||
|
||||
Usage (promote a build that is already on Play, no rebuild):
|
||||
python3 play-upload.py --package io.unom.punktfunk \
|
||||
@@ -165,9 +164,6 @@ def main():
|
||||
ap.add_argument("--promote-from", metavar="TRACK",
|
||||
help="with --promote: assert the code is on TRACK, then clear TRACK")
|
||||
ap.add_argument("--track", default="internal")
|
||||
ap.add_argument("--also-track", action="append", default=[], metavar="TRACK",
|
||||
help="assign the same versionCode to this track too, in the same edit "
|
||||
"(repeatable). Canary uses it to feed open + closed testing at once.")
|
||||
ap.add_argument("--status", default="completed")
|
||||
ap.add_argument("--user-fraction", type=float,
|
||||
help="staged rollout fraction, 0<f<1; required by --status inProgress")
|
||||
@@ -187,11 +183,6 @@ def main():
|
||||
sys.exit(f"ERROR: --user-fraction must be strictly between 0 and 1 (got {a.user_fraction})")
|
||||
if a.aab and not os.path.isfile(a.aab):
|
||||
sys.exit(f"ERROR: AAB not found: {a.aab}")
|
||||
for t in a.also_track:
|
||||
# `--also-track <promote-from>` would assign and clear the same track in one edit;
|
||||
# whichever PUT lands second silently wins. Refuse the ambiguity instead.
|
||||
if t in (a.track, a.promote_from):
|
||||
sys.exit(f"ERROR: --also-track {t} duplicates --track/--promote-from")
|
||||
|
||||
notes = load_release_notes(a.release_notes_file, a.release_notes_language) \
|
||||
if a.release_notes_file else None
|
||||
@@ -218,11 +209,6 @@ def main():
|
||||
put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={a.track} status={a.status}"
|
||||
+ (f" userFraction={a.user_fraction}" if a.user_fraction is not None else ""))
|
||||
# Same edit, so one commit (and one Play review) covers every track the code lands on —
|
||||
# the tracks can never disagree about which canary is current.
|
||||
for t in a.also_track:
|
||||
put_track(app, edit, tok, t, [vc], a.status, a.user_fraction, notes)
|
||||
print(f"assigned versionCode={vc} -> track={t} status={a.status}")
|
||||
# Same edit as the assignment above, so the code is never active on both tracks at once.
|
||||
if a.promote_from:
|
||||
put_track(app, edit, tok, a.promote_from, [], a.status)
|
||||
|
||||
@@ -105,12 +105,9 @@ class DeviceGyro(
|
||||
for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i])
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
// The write gate, per sample: sends must be on at all (the forwarding
|
||||
// preference AND the session's GAMEPAD grant — an AccessUpdate can revoke it
|
||||
// mid-session), pad 0 must exist (motion never creates a pad) and must not be
|
||||
// a capture link's (its own IMU is streaming).
|
||||
val write = router.sendsEnabled() && router.padPresent(0) &&
|
||||
!router.padHasOwnMotion(0)
|
||||
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
|
||||
// and must not be a capture link's (its own IMU is streaming).
|
||||
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
|
||||
if (!write) {
|
||||
// Stand-down edge: never leave the last angular velocity latched host-side.
|
||||
if (wasWriting) {
|
||||
|
||||
@@ -195,29 +195,6 @@ object Gamepad {
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? = pads().firstOrNull()
|
||||
|
||||
/**
|
||||
* True when a Steam Controller 2 is attached as an ORDINARY [InputDevice] — which, for a pad
|
||||
* this client wants to capture, means an uncaptured one still in lizard mode.
|
||||
*
|
||||
* Deliberately not filtered by [isPad]: lizard mode emulates a keyboard and mouse, so an SC2
|
||||
* is never a gamepad source and every other pad-shaped query in the client steps right past
|
||||
* it. That is also why this is worth having — a wired or Puck SC2 is found by enumerating USB
|
||||
* (no permission needed), but a BLE-paired one is invisible until `BLUETOOTH_CONNECT` is
|
||||
* granted, and asking for Bluetooth on the chance that someone might own one is not something
|
||||
* to put in front of every user. This is the permission-free signal that the pad is genuinely
|
||||
* there, so the request can be made to the people it helps and to nobody else.
|
||||
*
|
||||
* A false negative is survivable by design (the Controllers screen offers the grant outright),
|
||||
* so this matches only the identities we know rather than reaching for every Valve device — a
|
||||
* Steam Deck's own controller and a classic Steam Controller are not SC2s and must not
|
||||
* conjure a Bluetooth prompt.
|
||||
*/
|
||||
fun sc2InputDevicePresent(): Boolean =
|
||||
InputDevice.getDeviceIds().asSequence().mapNotNull { InputDevice.getDevice(it) }.any {
|
||||
it.vendorId == VID_VALVE &&
|
||||
(it.productId in PID_STEAMCONTROLLER2 || it.productId in PID_STEAMCONTROLLER2_PUCK)
|
||||
}
|
||||
|
||||
/**
|
||||
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
|
||||
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
|
||||
@@ -253,46 +230,6 @@ object Gamepad {
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The BTN_* bit for one key event from a SOURCE_GAMEPAD device — [buttonBit] plus the
|
||||
* Select-family button of every pad that carries no `BUTTON_SELECT` scancode at all.
|
||||
*
|
||||
* Plenty of controllers deliver that button as the plain `KEYCODE_BACK` a remote's Back uses,
|
||||
* with no `BUTTON_SELECT` behind it: it is the Android-TV shape, where every input device is
|
||||
* expected to offer Back, and a pad reaches it whether the vendor prints "Back" on the button
|
||||
* (NVIDIA's SHIELD controller) or "Select"/"View" (most pads in an Android mode). Which one is
|
||||
* on the couch cannot be told from here, and does not need to be — the keycode is what routes.
|
||||
*
|
||||
* Read through [buttonBit] alone that button mapped to nothing, so it fell out of the
|
||||
* streaming branch unconsumed and reached the activity's back stack, which is the
|
||||
* deliberate-quit exit: ONE press of Select dropped the session and the host logged a client
|
||||
* quit. `KEYCODE_BACK` is in fact the ONLY keycode that can get there from a pad — a mapped
|
||||
* button is consumed here, anything with a VK is consumed on the keycode path, volume/power go
|
||||
* to the system, and a FLAG_FALLBACK BACK is swallowed — which is what identifies this as the
|
||||
* cause of such a report without knowing the hardware.
|
||||
*
|
||||
* It also meant such a pad could not produce [BTN_BACK] at all, so every shortcut built on
|
||||
* Select — the emergency exit chord this client's own start banner advertises, the mic mute,
|
||||
* the stats tier — was unreachable on exactly the devices whose users have no keyboard.
|
||||
*
|
||||
* A pad that DOES carry `BUTTON_SELECT` is unaffected in both directions: it never had the
|
||||
* bug, and this changes nothing for it.
|
||||
*
|
||||
* FLAG_FALLBACK events are excluded: those are the synthetic BACK the framework raises after
|
||||
* an unconsumed `BUTTON_*` press (a pad reporting L2/R2 as keys, say), not a button anyone
|
||||
* touched, and forwarding one would put a phantom Select on the wire. `MainActivity` drops
|
||||
* them on the keycode path for the same reason.
|
||||
*
|
||||
* Callers must gate on `SOURCE_GAMEPAD` before asking, exactly as [buttonBit]'s `KEYCODE_DPAD_*`
|
||||
* rows require: a remote's or keyboard's BACK shares this keycode and has to keep leaving the
|
||||
* stream — for a device with no pad on it, Back IS the documented way out.
|
||||
*/
|
||||
fun padButtonBit(keyCode: Int, flags: Int): Int = when {
|
||||
keyCode != KeyEvent.KEYCODE_BACK -> buttonBit(keyCode)
|
||||
flags and KeyEvent.FLAG_FALLBACK != 0 -> 0
|
||||
else -> BTN_BACK
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
|
||||
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
|
||||
|
||||
@@ -46,12 +46,12 @@ class GamepadRouter(
|
||||
* as well would give the host two pads for one pair of hands.
|
||||
*
|
||||
* Off still opens slots and tracks held state; it only stops the wire sends. That is
|
||||
* deliberate: the exit, mic and stats chords are read off the same slots, and a couch that lost
|
||||
* its quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
|
||||
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
|
||||
* claimed by keeping a slot — the Android input stack shares controllers — unlike the USB
|
||||
* capture links, which `StreamScreen` does not start at all while this is off.
|
||||
*/
|
||||
forwarding: Boolean = true,
|
||||
private val forwarding: Boolean = true,
|
||||
/**
|
||||
* Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on
|
||||
* Android, where the press reaches the app on most devices; `local` exists for
|
||||
@@ -70,25 +70,6 @@ class GamepadRouter(
|
||||
private val guideGesture: Boolean = false,
|
||||
) {
|
||||
|
||||
/** The ctor's forwarding preference, fixed for the session — one term of [forwarding]. */
|
||||
private val forwardingSetting = forwarding
|
||||
|
||||
/**
|
||||
* Whether this session's access includes the GAMEPAD grant ([SessionAccess.GAMEPAD]) —
|
||||
* seeded from the Welcome and kept live by `StreamScreen`'s access poll (an `AccessUpdate`
|
||||
* can revoke or restore it mid-session, latest-wins). Gates exactly what the forwarding
|
||||
* preference gates: the wire sends, never the slots — the exit/mic/stats chords must keep
|
||||
* working on a Controller-less access level too (they are local controls that happen to be
|
||||
* read off pad buttons). The host enforces regardless; this stops the client paying to send
|
||||
* events that will be dropped. Volatile: the sensor and USB-capture threads read it per
|
||||
* sample through [forwarding].
|
||||
*/
|
||||
@Volatile
|
||||
var gamepadGranted: Boolean = true
|
||||
|
||||
/** Send on the wire at all — the forwarding preference AND the session's GAMEPAD grant. */
|
||||
private val forwarding: Boolean get() = forwardingSetting && gamepadGranted
|
||||
|
||||
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
|
||||
private class Slot(
|
||||
val index: Int,
|
||||
@@ -168,20 +149,6 @@ class GamepadRouter(
|
||||
*/
|
||||
var onMicChord: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) each time the stats chord ([STATS_CHORD], Select + X) is COMPLETED on a
|
||||
* pad — one verbosity tier of the in-stream statistics overlay per completion. It exists
|
||||
* because a controller in both hands has no other way to the numbers: the three-finger tap
|
||||
* needs a touchscreen AND one of the pointer touch models, so a TV or a gamepad-only session
|
||||
* has none. `StreamScreen` wires it to the live tier cycle.
|
||||
*
|
||||
* Fires immediately and once per chord like [onMicChord], and like it the buttons still go to
|
||||
* the host — the chord adds a local meaning to them rather than swallowing them. The Apple
|
||||
* client's `GamepadCapture.statsChord` is the same two buttons; a shortcut that differs per
|
||||
* platform is worse than no shortcut.
|
||||
*/
|
||||
var onStatsChord: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in
|
||||
* a session whose virtual pad has no motion plane — its motion is not being sent, because every
|
||||
@@ -237,7 +204,7 @@ class GamepadRouter(
|
||||
/**
|
||||
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
|
||||
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
|
||||
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
|
||||
* the mic-mute chord ([MIC_CHORD]).
|
||||
*/
|
||||
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
|
||||
// Raw system buttons stay local under the "local" policy — no wire send and no held
|
||||
@@ -265,11 +232,13 @@ class GamepadRouter(
|
||||
slot.held = slot.held or bit
|
||||
// Full chord now held on this pad → start the hold countdown (idempotent while held).
|
||||
if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit()
|
||||
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
|
||||
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
|
||||
// face button. Both leave the press on the wire: the game still gets its buttons.
|
||||
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
|
||||
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
|
||||
// Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press
|
||||
// (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member
|
||||
// that leaves the whole chord held. Any other button pressed while Select + Y are down
|
||||
// fails the middle test, so the toggle happens once per chord, not once per press.
|
||||
if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) {
|
||||
onMicChord?.invoke()
|
||||
}
|
||||
} else {
|
||||
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
|
||||
if (!owned && send && forwarding) {
|
||||
@@ -399,13 +368,6 @@ class GamepadRouter(
|
||||
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
|
||||
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
|
||||
|
||||
/**
|
||||
* Whether wire sends are on at all — the forwarding preference AND the session's GAMEPAD
|
||||
* grant. For the writers that ride the pad planes from OUTSIDE this router (the phone-gyro
|
||||
* mirror), which must stand down with it. Read from the sensor thread.
|
||||
*/
|
||||
fun sendsEnabled(): Boolean = forwarding
|
||||
|
||||
/**
|
||||
* Whether wire pad [pad]'s motion already comes from the controller's OWN IMU — either a
|
||||
* capture-link slot ([ExternalPad] — USB DualSense / SC2; synthetic ids are negative
|
||||
@@ -666,11 +628,7 @@ class GamepadRouter(
|
||||
return null
|
||||
}
|
||||
|
||||
// `internal` rather than private: the chord masks and [completesChord] are the only part of
|
||||
// this router a JVM unit test can reach — everything else needs an InputManager, a main Looper
|
||||
// and live InputDevices behind it — and until `GamepadChordTest` there was nothing pinning the
|
||||
// chords at all. Still invisible to :app, which is what private bought.
|
||||
internal companion object {
|
||||
private companion object {
|
||||
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
|
||||
const val MAX_PADS = 16
|
||||
|
||||
@@ -692,28 +650,6 @@ class GamepadRouter(
|
||||
*/
|
||||
const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y
|
||||
|
||||
/**
|
||||
* Stats-overlay chord: Select + X, one verbosity tier per completion. X keeps both
|
||||
* properties [MIC_CHORD]'s Y has — it is none of [EXIT_CHORD]'s four buttons, so no way of
|
||||
* reaching the exit chord passes through this one on the way (and vice versa), and Select
|
||||
* is a menu button rather than a twitch action. Byte-for-byte the Apple client's
|
||||
* `GamepadCapture.statsChord`, which was modelled on [MIC_CHORD] in the first place and
|
||||
* leaves Y free for the mic chord to land there in turn — the two clients converge on one
|
||||
* pad vocabulary from both ends.
|
||||
*/
|
||||
const val STATS_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_X
|
||||
|
||||
/**
|
||||
* Whether pressing [bit] on a pad that held [wasHeld] beforehand COMPLETED [chord]: a
|
||||
* genuine press (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a
|
||||
* chord member that leaves the whole chord held (`wasHeld or bit` is the slot's held set
|
||||
* the instant after the press). Any other button pressed while the chord is already down
|
||||
* fails the middle test, so a chord fires once per chord, not once per press — and lifting
|
||||
* any member re-arms it, since the next press of that member is a fresh completion.
|
||||
*/
|
||||
internal fun completesChord(wasHeld: Int, bit: Int, chord: Int): Boolean =
|
||||
wasHeld and bit == 0 && bit and chord != 0 && (wasHeld or bit) and chord == chord
|
||||
|
||||
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
|
||||
const val EXTERNAL_ID_BASE = -1000
|
||||
|
||||
|
||||
@@ -99,17 +99,6 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeEndReason(handle: Long): Int
|
||||
|
||||
/**
|
||||
* The session's live access state as `[grants, remainingSecs, updateSeq]`, or `null` on a `0`
|
||||
* handle. `grants` is a [SessionAccess] bitmask; `remainingSecs` counts down to the access
|
||||
* expiry (`0` = permanent); `updateSeq` increments once per `AccessUpdate` the host sent
|
||||
* (latest-wins — the state IS the fold, this counter is how a poller tells a fresh T−5 m /
|
||||
* T−1 m warning arrived and owes a toast). Seeded from the Welcome's access advert; an old
|
||||
* host — or an old native lib — reads as full control, permanent, exactly what such a host
|
||||
* enforces. Poll ~1 Hz alongside [nativeSessionEnded]. Cheap; safe on the UI thread.
|
||||
*/
|
||||
external fun nativeAccessState(handle: Long): IntArray?
|
||||
|
||||
/**
|
||||
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
|
||||
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
||||
@@ -313,17 +302,12 @@ object NativeBridge {
|
||||
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
|
||||
|
||||
/**
|
||||
* Start host→client audio: Opus decode → jitter ring → AAudio, all in Rust.
|
||||
* Start host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust.
|
||||
* [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the
|
||||
* HAL's game-audio routing. No-op if already started. Best-effort — a failure leaves video
|
||||
* streaming.
|
||||
*
|
||||
* [isTv] steers the AAudio open ladder: a TV box starts at Shared rather than betting the
|
||||
* audio plane on an Exclusive/MMAP path whose routing we cannot verify from inside the
|
||||
* process. Passed from `FEATURE_LEANBACK` (same source as [nativeStartVideo]) because the
|
||||
* native side's own `ro.build.characteristics` check is not answered by every TV device.
|
||||
*/
|
||||
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean, isTv: Boolean)
|
||||
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean)
|
||||
|
||||
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
|
||||
external fun nativeStopAudio(handle: Long)
|
||||
@@ -488,16 +472,6 @@ object NativeBridge {
|
||||
// cross only when the host pastes (a "fetch:" event answered by nativeClipServeText). Host
|
||||
// copies arrive as "offer:" events, fetched eagerly into the system clipboard.
|
||||
|
||||
/**
|
||||
* The management-API port the host reported in this session's `Welcome` — where its game
|
||||
* library is served — or 0 if it advertised none (older host, or no management API).
|
||||
*
|
||||
* Persist it on the host record: unlike the mDNS `mgmt` TXT, this arrives over the connection
|
||||
* we have already authenticated, so it is what makes a host that moved off 47990 browsable
|
||||
* over a VPN, a routed subnet, or when it was added by address.
|
||||
*/
|
||||
external fun nativeHostMgmtPort(handle: Long): Int
|
||||
|
||||
/** Whether the host advertised a working shared-clipboard service (HOST_CAP_CLIPBOARD). */
|
||||
external fun nativeClipSupported(handle: Long): Boolean
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
@@ -10,8 +9,6 @@ import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -50,33 +47,16 @@ class Sc2BleLink(
|
||||
|
||||
@Volatile private var state = State.IDLE
|
||||
|
||||
/**
|
||||
* Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here).
|
||||
*
|
||||
* Gates on [permissionGranted] itself rather than trusting callers to: without the permission
|
||||
* `bondedDevices` throws, and the `runCatching` below turns that into an empty list —
|
||||
* indistinguishable from "no controller is paired". A capture that never engaged for want of a
|
||||
* permission nobody had asked for is exactly the silence this logs its way out of.
|
||||
*/
|
||||
fun pairedControllers(): List<BluetoothDevice> {
|
||||
if (!permissionGranted(context)) {
|
||||
Log.i(TAG, "BLE controllers not enumerated: $CONNECT_PERMISSION not granted")
|
||||
return emptyList()
|
||||
/** Bonded devices that look like a Steam Controller (name heuristic — BLE exposes no PID here). */
|
||||
fun pairedControllers(): List<BluetoothDevice> = runCatching {
|
||||
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
|
||||
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
|
||||
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
|
||||
}
|
||||
return runCatching {
|
||||
manager.adapter?.bondedDevices.orEmpty().filter { dev ->
|
||||
val n = runCatching { dev.name }.getOrNull() ?: return@filter false
|
||||
NAME_HINTS.any { n.contains(it, ignoreCase = true) }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Connect to the bonded controller at [address]. Reports start flowing once READY. */
|
||||
fun start(address: String): Boolean {
|
||||
if (!permissionGranted(context)) {
|
||||
Log.i(TAG, "BLE capture not started: $CONNECT_PERMISSION not granted")
|
||||
return false
|
||||
}
|
||||
val adapter = manager.adapter ?: return false
|
||||
if (!adapter.isEnabled) return false
|
||||
val device = runCatching { adapter.getRemoteDevice(address) }.getOrNull() ?: return false
|
||||
@@ -242,50 +222,20 @@ class Sc2BleLink(
|
||||
return s.substring(0, 8).toLongOrNull(16)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Sc2BleLink"
|
||||
private companion object {
|
||||
const val TAG = "Sc2BleLink"
|
||||
|
||||
private val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
|
||||
private const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
|
||||
private const val NOTIFY_LOW = 0x100f6c75L
|
||||
private const val NOTIFY_HIGH = 0x100f6c7aL
|
||||
private const val WRITE_LOW = 0x100f6cb5L
|
||||
private const val WRITE_HIGH = 0x100f6cbeL
|
||||
private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
val VALVE_SERVICE: UUID = UUID.fromString("100f6c32-1735-4313-b402-38567131e5f3")
|
||||
const val VALVE_UUID_TAIL = "-1735-4313-b402-38567131e5f3"
|
||||
const val NOTIFY_LOW = 0x100f6c75L
|
||||
const val NOTIFY_HIGH = 0x100f6c7aL
|
||||
const val WRITE_LOW = 0x100f6cb5L
|
||||
const val WRITE_HIGH = 0x100f6cbeL
|
||||
val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private val NAME_HINTS =
|
||||
listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
|
||||
val NAME_HINTS = listOf("Steam Ctrl", "Steam Controller", "SteamController", "Valve")
|
||||
|
||||
/** Enough for a state payload (45 B) + ATT header with margin. */
|
||||
private const val DESIRED_MTU = 100
|
||||
|
||||
/**
|
||||
* The runtime permission this transport needs, or null where the platform grants Bluetooth
|
||||
* at install time.
|
||||
*
|
||||
* From API 31 both operations a capture makes — reading the bonded list and `connectGatt`
|
||||
* — sit behind the runtime `BLUETOOTH_CONNECT`. Below it the manifest's legacy `BLUETOOTH`
|
||||
* (normal-level, granted on install) covers exactly those two, and `BLUETOOTH_CONNECT` is
|
||||
* not a permission that platform version knows: `checkSelfPermission` answers DENIED for
|
||||
* it and a request is refused without a dialog. Gating on it unconditionally is therefore
|
||||
* not merely redundant on old releases — it is a permanent refusal, which is what this
|
||||
* null arm exists to avoid.
|
||||
*/
|
||||
val CONNECT_PERMISSION: String? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
Manifest.permission.BLUETOOTH_CONNECT
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a BLE capture may run: [CONNECT_PERMISSION] held, or not required on this
|
||||
* release. Callers that can offer the user a grant ask this first, so the offer appears
|
||||
* only when it would change something.
|
||||
*/
|
||||
fun permissionGranted(context: Context): Boolean {
|
||||
val permission = CONNECT_PERMISSION ?: return true
|
||||
return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
const val DESIRED_MTU = 100
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Per-client access grants — the Kotlin mirror of `punktfunk_core::quic::access` (bit-for-bit;
|
||||
* `design/per-client-access.md` §3), read per session via [NativeBridge.nativeAccessState].
|
||||
*
|
||||
* The host is the only enforcer: everything gated on these bits client-side is courtesy UX over
|
||||
* the same vocabulary — don't capture what can't land (a keyboard that silently does nothing is
|
||||
* the failure mode this exists to prevent), and say what this session is (the stream's Access
|
||||
* chip). The user-facing word is **"Access"**; the preset labels are *derived* from the mask,
|
||||
* never stored, so they can't drift from what the host actually granted.
|
||||
*/
|
||||
object SessionAccess {
|
||||
/** Controller input — gamepad events, rich pad input, pad audio, rumble return. */
|
||||
const val GAMEPAD = 1 shl 0
|
||||
|
||||
/** Pointing input — mouse rel/abs + buttons, scroll, touch, and the pen plane. */
|
||||
const val POINTER = 1 shl 1
|
||||
|
||||
/** Key input — key down/up and IME-committed text. */
|
||||
const val KEYBOARD = 1 shl 2
|
||||
|
||||
/** Shared clipboard (ANDed into the host's clipboard policy). */
|
||||
const val CLIPBOARD = 1 shl 3
|
||||
|
||||
/** Mic injection — the uplink plane + the per-session mic attach. */
|
||||
const val MIC = 1 shl 4
|
||||
|
||||
/** Library launch (`Hello.launch`). */
|
||||
const val LAUNCH = 1 shl 5
|
||||
|
||||
/** Every defined grant — full control, and what an old host's Welcome decodes to. */
|
||||
const val ALL = GAMEPAD or POINTER or KEYBOARD or CLIPBOARD or MIC or LAUNCH
|
||||
|
||||
/**
|
||||
* The preset name a mask displays as — §3.2's rule: three levels people actually reason
|
||||
* about, "Custom" for any other combination, never a raw bit list.
|
||||
*/
|
||||
fun label(grants: Int): String = when (grants and ALL) {
|
||||
ALL -> "Full control"
|
||||
GAMEPAD -> "Controller only"
|
||||
0 -> "View only"
|
||||
else -> "Custom"
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact time-left wording for the Access chip ("1 h 58 m", "12 m", "45 s") — hours and
|
||||
* minutes once the span has them, bare seconds only under a minute (the final countdown).
|
||||
*/
|
||||
fun remainingLabel(secs: Int): String {
|
||||
val h = secs / 3600
|
||||
val m = (secs % 3600) / 60
|
||||
return when {
|
||||
h > 0 && m > 0 -> "$h h $m m"
|
||||
h > 0 -> "$h h"
|
||||
m > 0 -> "$m m"
|
||||
else -> "${secs.coerceAtLeast(0)} s"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,16 +19,13 @@ data class DiscoveredHost(
|
||||
val pairingRequired: Boolean = false,
|
||||
val mac: List<String> = emptyList(), // TXT "mac" (wake-capable NIC MAC(s), for Wake-on-LAN)
|
||||
val os: String = "", // TXT "os" (OS-identity chain, e.g. "linux/fedora/bazzite"); "" on older hosts
|
||||
// TXT "mgmt" — the management-API port the library is served on, distinct from `port` (the
|
||||
// native QUIC plane). null on an older host / older native lib, meaning "assume 47990".
|
||||
val mgmtPort: Int? = null,
|
||||
)
|
||||
|
||||
/** Field separator the native browse uses inside one record (ASCII Unit Separator). */
|
||||
private const val FIELD_SEP = '\u001F'
|
||||
|
||||
/**
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac␟os␟mgmt`),
|
||||
* Parse one record from [NativeBridge.nativeDiscoveryPoll] (`key␟name␟addr␟port␟fp␟pair␟mac␟os`),
|
||||
* or null if it's malformed. Fields past the 6th are optional — an older native lib omits them
|
||||
* (`mac` 7th, `os` 8th). Pure — unit-tested without Android (see ParseRecordTest). The native side
|
||||
* already applied the protocol gate and address selection, so this is just field marshaling.
|
||||
@@ -49,9 +46,6 @@ fun parseHostRecord(record: String): DiscoveredHost? {
|
||||
mac = if (f.size > 6) f[6].split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
else emptyList(),
|
||||
os = if (f.size > 7) sanitizeOsChain(f[7]) else "",
|
||||
// 9th field, absent on an older native lib. `0` (and anything out of range) means "not
|
||||
// advertised" → null, and the caller falls back to 47990.
|
||||
mgmtPort = if (f.size > 8) f[8].toIntOrNull()?.takeIf { it in 1..65535 } else null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,9 +44,6 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
||||
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
|
||||
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
|
||||
* game rather than break the decode (design D4).
|
||||
*
|
||||
* [icon] is the token for the entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||
* Null on every older host and on every ordinary title.
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
@@ -54,25 +51,12 @@ data class GameEntry(
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
val icon: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* The brand-icon token, re-validated rather than taken on trust.
|
||||
*
|
||||
* The host checks the shape on the way in, so this only fires for a host older than that
|
||||
* check or one that isn't ours. It costs a scan of a short string and means no consumer has
|
||||
* to wonder what it is about to look up.
|
||||
*/
|
||||
val iconToken: String? get() = icon?.takeIf { t ->
|
||||
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
|
||||
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for the store badge — the same table the other clients use
|
||||
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
|
||||
@@ -167,7 +151,6 @@ object LibraryClient {
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
role = str(o, "role"),
|
||||
icon = str(o, "icon"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,16 +32,6 @@ data class KnownHost(
|
||||
* first learned (or forever, against an older host).
|
||||
*/
|
||||
val os: String = "",
|
||||
/**
|
||||
* The host's management-API port (mDNS `mgmt` TXT), where the game library is served — NOT
|
||||
* [port], which is the native QUIC plane. Learned while online and kept for the same reason as
|
||||
* [mac] and [os], except this one is load-bearing: a host that moved its mgmt port off 47990
|
||||
* (the supported way to share a machine with a Sunshine fork, whose web UI owns that port)
|
||||
* served its library only while mDNS was reachable, because the advert was the sole place the
|
||||
* real port ever existed. `null` until learned — resolve with [effectiveMgmtPort].
|
||||
* Mirrors the Apple client's `StoredHost.mgmtPort` and the Rust `KnownHost.mgmt_port`.
|
||||
*/
|
||||
val mgmtPort: Int? = null,
|
||||
/** Stable record identity — see the class doc. Minted here for a genuinely new record. */
|
||||
val id: String = newRecordId(),
|
||||
/**
|
||||
@@ -64,16 +54,7 @@ data class KnownHost(
|
||||
* that no longer exist are dropped when the cards are rendered.
|
||||
*/
|
||||
val pinnedProfileIds: List<String> = emptyList(),
|
||||
) {
|
||||
/**
|
||||
* Where this host's management API actually is: the port learned from its advert, else 47990.
|
||||
* The twin of the Apple client's `StoredHost.effectiveMgmtPort` and the Rust
|
||||
* `KnownHost::effective_mgmt_port`. Resolve through this — the constant is the FALLBACK, not
|
||||
* the answer.
|
||||
*/
|
||||
val effectiveMgmtPort: Int
|
||||
get() = mgmtPort ?: io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Persists trusted hosts — the pinned-fingerprint store *and* the saved-hosts list — keyed by
|
||||
@@ -149,17 +130,6 @@ class KnownHostStore(context: Context) {
|
||||
save(h.copy(os = os))
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn/refresh a saved host's management-API port from its live advert — same contract as
|
||||
* [learnMac]. This is the one that keeps a moved mgmt port working once mDNS isn't reachable.
|
||||
*/
|
||||
fun learnMgmtPort(address: String, port: Int, mgmtPort: Int) {
|
||||
if (mgmtPort <= 0) return
|
||||
val h = get(address, port) ?: return
|
||||
if (h.mgmtPort == mgmtPort) return
|
||||
save(h.copy(mgmtPort = mgmtPort))
|
||||
}
|
||||
|
||||
/** Forget [host] (the next connect re-pairs / re-TOFUs). */
|
||||
fun remove(host: KnownHost) {
|
||||
prefs.edit().remove(host.id).apply()
|
||||
@@ -210,10 +180,6 @@ class KnownHostStore(context: Context) {
|
||||
paired = j.optBoolean("paired", false),
|
||||
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||
os = j.optString("os", ""),
|
||||
// 0 (or absent) = never learned. `optInt` cannot express "missing", hence the sentinel
|
||||
// rather than a bare default — a record written before this field existed must decode
|
||||
// to null and fall back to 47990, not to port 0.
|
||||
mgmtPort = j.optInt("mgmt", 0).takeIf { it > 0 },
|
||||
// A record without an id can only be one this build wrote before the migration ran, or
|
||||
// a hand-edited file; minting here keeps the parse total rather than dropping a host.
|
||||
id = j.optString("id", "").ifEmpty { newRecordId() },
|
||||
@@ -300,7 +266,6 @@ class KnownHostStore(context: Context) {
|
||||
.put("paired", host.paired)
|
||||
.put("mac", host.mac.joinToString(","))
|
||||
.put("os", host.os)
|
||||
.put("mgmt", host.mgmtPort ?: 0)
|
||||
.put("clip", host.clipboardSync)
|
||||
.put("profile", host.profileId ?: "")
|
||||
.put("pins", JSONArray(host.pinnedProfileIds))
|
||||
|
||||