Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5eea458b4 | ||
|
|
7f141eb9a4 | ||
|
|
06086de328 | ||
|
|
7b91afd721 | ||
|
|
0ed4b51104 | ||
|
|
3197a4e887 | ||
|
|
df74dd5aee | ||
|
|
b2020396c9 | ||
|
|
b20184462d | ||
|
|
537a1852ed | ||
|
|
5819cf054b | ||
|
|
62624c1daf | ||
|
|
7f6d1622ee | ||
|
|
5c1db4662f |
@@ -184,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:
|
||||
|
||||
@@ -280,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.
|
||||
|
||||
@@ -15,17 +15,6 @@
|
||||
# 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=[…]).
|
||||
@@ -55,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:
|
||||
@@ -109,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
|
||||
@@ -195,254 +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 (.cargo/config.toml:11-13 says so), and that config carries `--cfg aes_armv8` /
|
||||
# `--cfg polyval_armv8` for aarch64 — worth a measured ~3x decrypt-throughput cliff if
|
||||
# dropped. Harmless here because this job pins ubuntu-24.04/x86_64; fatal on mac-mini-1.
|
||||
# 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."
|
||||
|
||||
@@ -111,31 +111,9 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -146,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.)
|
||||
#
|
||||
@@ -187,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)
|
||||
|
||||
@@ -310,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
|
||||
@@ -326,93 +320,6 @@ jobs:
|
||||
run: |
|
||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||
|
||||
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
|
||||
# capability metadata — the postinst applies them — so this reads the postinst that will
|
||||
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
|
||||
# from exactly that postinst and killed every KDE desktop session while every board stayed
|
||||
# green: host must carry NOTHING, worker exactly cap_sys_nice=ep. `--self-test` first so a
|
||||
# guard that can no longer fail takes the job down instead of waving the release through.
|
||||
- name: Assert the capability matrix (host .deb)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
|
||||
|
||||
# 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/**') }}
|
||||
|
||||
- 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: |
|
||||
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:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -440,26 +347,6 @@ jobs:
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# 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')
|
||||
run: |
|
||||
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
|
||||
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -103,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 /
|
||||
@@ -159,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 }}
|
||||
@@ -224,89 +206,13 @@ jobs:
|
||||
dnf -y install dnf-plugins-core meson ninja-build glslc || true
|
||||
dnf builddep -y gamescope || true
|
||||
dnf -y install xorg-x11-server-Xwayland-devel || true
|
||||
# NOT best-effort: build-punktfunk-gamescope.sh appends `-static-libstdc++` to LDFLAGS
|
||||
# (so the binary still starts on SteamOS's older libstdc++ — see its comment), and
|
||||
# without the static library meson's very FIRST sanity check dies with
|
||||
# "cannot find -lstdc++ / have you installed the static version", so nothing builds at
|
||||
# all. That is what happened on the v0.26.0 tag: both Fedora bases warned and skipped,
|
||||
# the job stayed green, and the release shipped with no gamescope RPM while the notes
|
||||
# said it had one. A rename here must be LOUD, hence no `|| true`.
|
||||
dnf -y install libstdc++-static
|
||||
# The rest of the Arch package's makedepends that Fedora's older packaged gamescope does
|
||||
# not necessarily pull. Best-effort: unlike the static runtime, meson finds fallbacks or
|
||||
# does without, and a name that moves between Fedora releases should not fail the job.
|
||||
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
|
||||
# would skip the sysext build, the sysext feed, AND the release attach below, so a
|
||||
# missing gamescope would also withhold the punktfunk RPMs and the .raw images that
|
||||
# built perfectly well. deb.yml learned that the expensive way on v0.26.0.
|
||||
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
# The same binary, as an ordinary RPM. The sysext below is the Atomic/Bazzite delivery; this
|
||||
# is the one a traditional Fedora-family box (Nobara, plain Fedora) can actually install —
|
||||
# until it existed those users had no packaged route to the patched build at all, and a stock
|
||||
# gamescope tells every game its display is 60 Hz whatever the client negotiated.
|
||||
#
|
||||
# Same best-effort rule as the build above: no binary, no package, and the host stays on its
|
||||
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
|
||||
- name: Package punktfunk-gamescope as an RPM
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/gamescope/build-gamescope-rpm.sh \
|
||||
--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 }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for rpm in dist/punktfunk-gamescope-*.rpm; do
|
||||
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
|
||||
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
|
||||
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
|
||||
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
|
||||
echo "uploading $rpm"
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||
done
|
||||
|
||||
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||
@@ -330,19 +236,6 @@ jobs:
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
# Read the capability matrix back OUT of the image that is about to be published — the one
|
||||
# channel where getting it wrong is unrepairable, because a merged sysext's /usr is read-only
|
||||
# squashfs and the only fix is a new image plus a feed republish. 0.26.0-1's Bazzite breakage
|
||||
# was confirmed exactly this way, after the fact, by mounting the published .raw and running
|
||||
# getcap on it. Doing it here means the .raw never reaches the feed.
|
||||
#
|
||||
# The script proves its own reader first (cap a file, squash it, unsquash it, read it back)
|
||||
# so a runner that cannot see file capabilities FAILS the leg instead of blessing the image.
|
||||
- name: Assert the capability matrix (sysext image)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh \
|
||||
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
|
||||
|
||||
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
|
||||
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
|
||||
# anything, sitting on the same registry as the images they describe.
|
||||
@@ -383,26 +276,3 @@ jobs:
|
||||
for raw in dist-sysext/*.raw; do
|
||||
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
|
||||
done
|
||||
|
||||
# A release must not be able to make a claim its own CI silently dropped — v0.26.0's notes
|
||||
# said the patched gamescope was dnf-installable while both Fedora bases had skipped it on a
|
||||
# `::warning::` (missing libstdc++-static, which the -static-libstdc++ link needs).
|
||||
#
|
||||
# ⚠ LAST step on purpose, matching deb.yml: failing at the build step instead would skip the
|
||||
# sysext image, the feed publish AND the attach above, withholding the punktfunk RPMs and
|
||||
# .raw images that built perfectly well. Everything good ships first; the job goes red after.
|
||||
- name: A stable tag must ship the gamescope RPM
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
built=(dist/punktfunk-gamescope-*.rpm)
|
||||
keep=()
|
||||
for r in "${built[@]}"; do
|
||||
case "$r" in *debuginfo*|*debugsource*) continue;; esac
|
||||
keep+=("$r")
|
||||
done
|
||||
if [ ${#keep[@]} -eq 0 ]; then
|
||||
echo "::error::no punktfunk-gamescope RPM was built for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
|
||||
exit 1
|
||||
fi
|
||||
echo "gamescope RPM present: ${keep[*]}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,964 +12,6 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.27.1 — in development
|
||||
|
||||
### GameStream is now opt-in on EVERY route (⚠ packager-visible default change)
|
||||
|
||||
The secure native-only host is the default everywhere; the Moonlight-compat planes (plain-HTTP
|
||||
pairing + the legacy GCM path, security-review #5/#9) are enabled only by an explicit choice:
|
||||
|
||||
- **The shipped systemd user unit** (`scripts/punktfunk-host.service`, installed by deb/RPM/Arch/
|
||||
sysext) runs bare `serve` — `--gamestream` is no longer baked into `ExecStart`. Opt in via the
|
||||
new **`PUNKTFUNK_GAMESTREAM=1`** knob in `host.env` (pf-host-config; equivalent to the flag —
|
||||
either source enables), so no unit editing survives-upgrades dance is needed.
|
||||
⚠ **Upgrade note:** a packaged host that served Moonlight by default becomes native-only until
|
||||
the operator sets the knob (a hand-made `ExecStart` drop-in keeps winning as before).
|
||||
- **NixOS module**: `services.punktfunk.host.gamestream` default flipped `true` → `false`
|
||||
(module-check gained a "default is native-only" assertion); enabling it still opens the
|
||||
GameStream firewall ports.
|
||||
- **Steam Deck installer**: `--gamestream` opts in (was on-by-default with `--no-gamestream`;
|
||||
the old flag is still accepted as explicit-off).
|
||||
- Windows was already opt-in (unchecked installer task) and is unchanged.
|
||||
|
||||
### The ENet control port now exists only while a pairing does (rust-safety WP0)
|
||||
|
||||
`rusty_enet` — a c2rust-style transpile of C ENet, and the host's only pre-auth-reachable unsafe
|
||||
surface — no longer listens unconditionally: UDP 47999 binds when the paired-client list becomes
|
||||
non-empty and is torn down when the last pairing is removed (a live client gets the same
|
||||
TERMINATION+disconnect farewell as a host-side session end). Pairing itself is HTTPS on nvhttp and
|
||||
never touches the port, so a never-paired `--gamestream` host exposes no ENet at all. En route:
|
||||
the management API's unpair endpoint never persisted (`save_paired` was missing), so an unpair
|
||||
lasted only until the next restart — fixed. `rusty_enet` is now pinned `=0.4.0`.
|
||||
|
||||
**Unpair is now a complete revocation, on both planes.** Beyond the persistence fix above, an
|
||||
unpair used to leave the revoked client's LIVE session streaming until the client chose to
|
||||
leave. Now: unpairing a GameStream client whose certificate owns the active launch ends that
|
||||
session (the client gets the standard TERMINATION+disconnect, and unpair-all still closes the
|
||||
ENet port); unpairing a native client deliberately stops its live punktfunk/1 session(s)
|
||||
(matched by certificate fingerprint — anonymous/TOFU sessions are unaffected, they have no
|
||||
pairing to revoke). The unpair endpoint's long-standing docstring caveat ("removes the client
|
||||
from the listing without severing its ability to reconnect") is retired: TLS-level handshakes
|
||||
still complete by design, but authorization is per-request and a live session no longer
|
||||
survives its own revocation.
|
||||
|
||||
### GameStream is now a cargo feature (compile-time isolation — packager-visible)
|
||||
|
||||
The Moonlight-compat planes (nvhttp pairing, RTSP, the ENet control stream, `_nvstream` mDNS,
|
||||
the compat media path) are gated behind a new **`gamestream` cargo feature — default ON**, so
|
||||
every stock package is behaviorally identical (GameStream stays runtime-opt-in via
|
||||
`--gamestream` / `PUNKTFUNK_GAMESTREAM`). Building with
|
||||
`--no-default-features --features pyrowave` produces the **hardened native-only host**:
|
||||
|
||||
- **no `rusty_enet`** — the c2rust-transpiled C ENet stack (158 unsafe sites) is absent from
|
||||
the binary, provably (`cargo tree -i rusty_enet` finds nothing; CI asserts it);
|
||||
- **no `rsa`** — the native planes run on the P-256 identity (above), and the legacy-identity
|
||||
fallback is a pem-only read (rustls/ring serves an existing RSA cert without the crate), so
|
||||
the accepted Marvin advisory (RUSTSEC-2023-0071) no longer applies to native-only builds;
|
||||
- ~6,700 lines of Moonlight protocol code gone; `serve --gamestream` (or the env knob) against
|
||||
such a binary **refuses to start** with a clear error rather than serving less than asked;
|
||||
- the native-only management API (and its OpenAPI document) has no GameStream PIN endpoints
|
||||
(`/api/v1/pair`, `/api/v1/pair/pin`); everything else — including the paired-client list and
|
||||
unpair — is identical, so consoles work unchanged.
|
||||
|
||||
The checked-in `api/openapi.json` remains the default-features document.
|
||||
|
||||
### The identity split — the native planes get their own (P-256) host identity
|
||||
|
||||
One RSA-2048 identity historically served every plane, because Moonlight mandates RSA and the
|
||||
planes grew out of the GameStream host. The native punktfunk/1 QUIC plane and the management API
|
||||
now share a separate **ECDSA P-256** identity (`native-cert.pem`/`native-key.pem`): generated by
|
||||
ring via rcgen, browser-compatible (Ed25519 server certs are not), carrying real SANs
|
||||
(localhost, loopback, the machine hostname — the legacy cert had none), and free of the accepted
|
||||
`rsa`-crate Marvin advisory. The GameStream plane keeps the RSA identity untouched.
|
||||
|
||||
**Migration is pin-preserving by construction**: clients TOFU-pin the leaf-cert SHA-256 at
|
||||
pairing and use that one pin for both QUIC and the mgmt/library API, so the new identity is
|
||||
adopted **only when the native trust store is empty** (fresh installs, or after an explicit
|
||||
unpair-all + restart). An upgraded host with live native pairings keeps presenting the legacy
|
||||
RSA cert those clients pinned, and logs the migration path. Fingerprint pinning is
|
||||
algorithm-agnostic, so existing shipped clients pair against P-256 hosts unchanged.
|
||||
|
||||
Follow-the-identity consumers updated in-tree: the tray's loopback pin and the plugin SDK's
|
||||
mgmt CA now prefer `native-cert.pem` (falling back to `cert.pem`), and the Windows runner ACL
|
||||
grant covers both. ⚠ A plugin bundling an **older** `@punktfunk/host` SDK on a **fresh**
|
||||
(P-256) host trusts the wrong cert — set `PUNKTFUNK_MGMT_CA=<config>/native-cert.pem` in its
|
||||
environment or rebuild against the current SDK.
|
||||
|
||||
### Memory-safety, compiler-enforced (embedder-visible lint tightening)
|
||||
|
||||
`punktfunk-core` now carries `#![deny(unsafe_code)]` crate-wide: everything that parses network
|
||||
bytes is safe Rust by compiler-enforced invariant. The documented `#![allow]` carve-outs are the
|
||||
client surface (`abi`, `client`) and the platform syscall-batching shims under `transport`
|
||||
(`udp/{apple,linux,windows}`, `qos_windows`) — none of which interpret attacker bytes. In
|
||||
`punktfunk-host`, the modules a secure-default host exposes (`native`, `native_pairing`, `mgmt`,
|
||||
`mgmt_token`, `discovery`, `wol`) are `#[forbid(unsafe_code)]`. If you embed `punktfunk-core` and
|
||||
patch it, new unsafe outside the carve-outs is now a compile error.
|
||||
|
||||
### NixOS + KDE — session detection, the other half
|
||||
|
||||
🛑 **v0.27.0's NixOS session-detection fix did not reach a stock NixOS + Plasma 6 box.** It resolved
|
||||
the nixpkgs wrapper decoration through `/proc/<pid>/exe` (below) — and on that exact box the kernel
|
||||
refuses to let us read that link. Reading `/proc/<pid>/exe` is not gated on owning the process: it
|
||||
goes through `cap_ptrace_access_check`, which requires the reader's effective set to be a superset
|
||||
of the target's **permitted** set. NixOS's own Plasma module ships
|
||||
`security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, so KWin holds a capability
|
||||
and the host — which must stay uncapped, because a capability is exactly what makes it
|
||||
unidentifiable to KWin (v0.27.0, above) — gets `EACCES`. The two traps compose: the name *needs*
|
||||
`exe` because nixpkgs wrapped the binary, and `exe` is *denied* because NixOS capped it. Detection
|
||||
went straight back to `ActiveKind::None`, `wayland` to `-`, and every connect to
|
||||
`no usable compositor`. It presents identically to the v0.27.0 bug, which is why a box that had been
|
||||
worked around with a decoy process broke again the moment the decoy was removed.
|
||||
|
||||
Name resolution now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when the kernel refuses `exe`.
|
||||
That reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
|
||||
binary, so `argv[0]` survives the decoration `comm` does not. Measured on Linux 6.x against a capped
|
||||
target, for a file capability and for the ambient form `security.wrappers` uses, identically: the
|
||||
`/proc/<pid>` directory keeps its real owner (so the uid filter was never the problem), `comm` and
|
||||
`cmdline` stay readable, and only `exe` fails. `argv[0]` is consulted **last** and never overrides a
|
||||
readable `exe` — it is the process's own claim about itself, and a same-uid process can set it to
|
||||
anything; the worst a spoof achieves is aiming detection at a backend that then fails its own
|
||||
availability probe. The `comm` fast path is still one read for every ordinary distro.
|
||||
|
||||
Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a
|
||||
*wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe.
|
||||
|
||||
### Game Mode on Nobara — the WSI opt-out never reached the games
|
||||
|
||||
🛑 **v0.27.0's fix for the distro Vulkan WSI layer was clobbered by the session script, so games ran
|
||||
on a black screen** while the host's own log claimed the layer had been disabled. Steam Big Picture
|
||||
came up, showed the right mode, showed the perf overlay — and then every game played sound and took
|
||||
input over a black picture, with no error on either side.
|
||||
|
||||
The layer (`VkLayer_FROG_gamescope_wsi`) ships with the *distro's* gamescope and speaks its
|
||||
`gamescope_swapchain` protocol; ours disagrees, so the compositor rejects the client's
|
||||
`swapchain_feedback` and kills it. v0.27.0 turned the layer off with `ENABLE_GAMESCOPE_WSI=0` on the
|
||||
session unit. `gamescope-session-plus` then runs an unconditional `export ENABLE_GAMESCOPE_WSI=1`
|
||||
near the top of the script — before it launches anything — so the opt-out survived exactly as long
|
||||
as it took the script to start, and every process the session spawned got the layer back. Nothing
|
||||
looked wrong because the casualty is Vulkan clients specifically: Steam's own UI is not one.
|
||||
|
||||
The opt-out is now `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader reads an implicit layer's
|
||||
two manifest knobs in a fixed order: `enable_environment` must equal `"1"` to switch the layer on,
|
||||
and `disable_environment` is then consulted last and wins on **presence alone**, at any value. The
|
||||
session script never mentions that second variable, so it is the one that survives. Both spellings
|
||||
go out, on the transient unit and on the box's own session drop-in.
|
||||
|
||||
### punktfunk-gamescope `+pfhdr6` — a NO_FOCUS window can no longer steal the composite
|
||||
|
||||
🛑 **A mapped-but-unpainted window carrying `GAMESCOPE_NO_FOCUS=1` could win gamescope's focus
|
||||
selection and turn the composite — and the stream fed from it — black while every health signal
|
||||
stayed green.** Bazzite's hhd-ui (Handheld Daemon overlay) sets that atom once at init, stamps
|
||||
Steam's appid, and crash-loops under a headless takeover; each respawn remapped a fullscreen black
|
||||
window that steamcompmgr then chose over Big Picture (observed on a Bazzite box: client stats
|
||||
happily decoding 60 fps at 0.1 Mb/s of black; killing hhd-ui restored the picture instantly). No
|
||||
gamescope — upstream or Bazzite's fork — ever consumed the atom; its setters (hhd-ui, MangoHud)
|
||||
show and hide via the `STEAM_OVERLAY` protocol and rely on never being focusable. Patch 0008 wires
|
||||
`GAMESCOPE_NO_FOCUS` exactly like `GAMESCOPE_EXTERNAL_OVERLAY` (read at map, PropertyNotify-tracked,
|
||||
skipped by both focus-candidate collectors) without touching compositing or `appID`. Banner
|
||||
`+pfhdr5` → `+pfhdr6`; no new capability — the bump is so a field box's banner tells the two
|
||||
behaviors apart.
|
||||
|
||||
### Linux capture — the truncated first attempt no longer latches sticky downgrades
|
||||
|
||||
🛑 **The pipeline retry loop's deliberately short (2.5 s) first-frame attempt could permanently
|
||||
downgrade the whole host process.** On expiry, the portal capturer's timeout diagnosis latched
|
||||
whichever offer it implicated — HDR capture off (per source), the raw-dmabuf offer off, the
|
||||
EGL→CUDA offer off — as if the compositor had refused it, when the budget was truncated by design
|
||||
and a gamescope cold start routinely needs longer before delivering anything. One lost race at
|
||||
connect then pinned every later session to SDR and/or CPU capture until the host restarted. The
|
||||
truncated attempt is now declared provisional end to end
|
||||
(`Capturer::next_frame_within_provisional`): its expiry names the same suspect in the error text
|
||||
but latches nothing; only the full-length attempts that follow hand down negotiation verdicts. The
|
||||
classification is a pure function with tests
|
||||
(`pf_capture::linux::first_frame_timeout_tests`).
|
||||
|
||||
### Windows host — an idle box can sleep again (virtual-mic stream idle-stop)
|
||||
|
||||
🛑 **Installing the host blocked system sleep forever, client connected or not.** The
|
||||
host-lifetime mic pump kept a WASAPI render stream RUNNING on the virtual-mic device
|
||||
(typically the Steam Streaming Microphone), writing silence 24/7 — and any running stream makes
|
||||
the Windows audio stack hold a kernel power request ("An audio stream is currently in use" in
|
||||
`powercfg /requests`, attributed to that device) that vetoes sleep. The render loop now stops
|
||||
the stream (`IAudioClient::Stop`; the client stays initialized and the mic *endpoint* keeps
|
||||
existing for apps to bind) after 10 s of silence-only output and resumes on the next mic frame
|
||||
within one device period — below the jitter buffer's prime depth, so nothing is audible.
|
||||
Streaming sessions still hold the box awake through their own `PowerRequest` assertions, as
|
||||
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
|
||||
third-party virtual audio driver misbehaves while its render side is paused.
|
||||
|
||||
### Windows host — audio no longer costs local-game frame time
|
||||
|
||||
🛑 **The host could tank a locally-played game's frame lows** (field-reported 2026-08-12:
|
||||
Helldivers 2 at 1% lows of 2–5 FPS, cured by uninstalling). Two mechanisms, both fixed:
|
||||
|
||||
- **The minted-endpoint retry storm.** The virtual-mic resolve ran a FULL provisioning pass on
|
||||
every reopen with no cooldown, no in-flight guard, and no give-up — and the pass reached
|
||||
`UpdateDriverForPlugAndPlayDevicesW` even over an already-existing devnode. On a box where
|
||||
minting cannot converge, the pump's reopen backoff (capped 60 s) turned that into a SetupAPI
|
||||
sweep + PnP driver re-bind + default-device writes roughly once a minute, forever — each
|
||||
raising the system-wide device-change broadcast games service by rebuilding their audio
|
||||
graphs. Provisioning now short-circuits to a no-PnP fast path while the minted devices are
|
||||
healthy, waits on an in-flight pass instead of racing a second one, honours the 60 s retry
|
||||
cooldown from the blocking path too, and stops for the host lifetime after five unlatched
|
||||
passes (a service restart re-arms minting).
|
||||
- **Session tuning never reverted.** The first streaming session put the whole host process at
|
||||
HIGH priority class with a 1 ms global timer (`timeBeginPeriod`) and DWM MMCSS, documented as
|
||||
"reverts at process exit" — but the host is a 24/7 service, so after one stream it competed
|
||||
at HIGH priority against whatever the user played locally, forever. The process-wide tuning
|
||||
is now refcounted across the hot stream threads and reverts when the last one exits
|
||||
(= session teardown), the same lifetime the per-thread MMCSS effects already ride.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.26.0 | v0.27.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 17 | **18** | `punktfunk_connection_next_rumble_cmd2` **added**; nothing removed or changed |
|
||||
| Workspace crate dirs | 26 | **27** | `crates/punktfunk-encode-worker` (39 members; two `tools/` crates deliberately *excluded*) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged — three `device_type`s added additively |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| `api/openapi.json` | 0.25.0 | **0.25.0** | unchanged — no management-API edits this release |
|
||||
| gamescope patch level (`+pfhdrN`) | 4 | **5** | 6 patches → 7 (the PipeWire use-after-free); `pkgrel` resets 3 → 1 |
|
||||
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
|
||||
| `@punktfunk/plugin-kit` | 0.4.0 | **0.4.0** | unchanged |
|
||||
|
||||
⚠ **`crates/pf-driver-proto` is no longer byte-identical to the previous release.** It was through
|
||||
both v0.25.0 and v0.26.0, so if you ship the virtual-display driver or the gamepad channel and have
|
||||
been skipping this crate, stop skipping it here. The change is purely additive — three `device_type`
|
||||
constants, no field moved, no size changed.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None** for embedders or the wire. Every embedder, packager and plugin that works against v0.26.0
|
||||
works against v0.27.0 unchanged; the C ABI moves, but by addition only (below).
|
||||
|
||||
Two things change shape for **packagers** and one **default** flips:
|
||||
|
||||
- **A second installed binary**, `punktfunk-encode-worker` — see the section below. It is the only
|
||||
file that may carry `cap_sys_nice=ep`, and it must be a separate file.
|
||||
- **`PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`** on Windows, so an Xbox pad is built as a real
|
||||
HID device rather than the XUSB companion. `=xusb` is the escape hatch.
|
||||
- **NixOS `scripting.autoStart` now defaults ON**, matching every other packaging (detailed below).
|
||||
|
||||
### `punktfunk-encode-worker` — the GPU-priority capability moves off the host
|
||||
|
||||
0.26.0 left the PyroWave priority ladder wired and inert: it needs `CAP_SYS_NICE`, and 0.26.0-1
|
||||
proved the host can never hold one — see **PyroWave on Linux — Wave 2**, PW1, under v0.26.0 below. A
|
||||
capability-carrying process cannot be identified by KWin (`cap_ptrace_access_check` refuses
|
||||
`/proc/<pid>/exe` to a reader whose effective set is not a superset of the target's **permitted**
|
||||
set), so it never gets `zkde_screencast_unstable_v1` and every KDE desktop session dies. Neither
|
||||
`prctl(PR_SET_DUMPABLE, 1)` nor systemd `AmbientCapabilities=` nor a NixOS `security.wrappers` entry
|
||||
changes that — all three land the capability in the same permitted set.
|
||||
|
||||
The capability therefore moves to a process that fronts nothing. **`punktfunk-encode-worker`** is a
|
||||
new workspace member and a new installed binary: it owns the priority-elevated Vulkan device for
|
||||
PyroWave sessions, receives capture dmabufs over a `SOCK_SEQPACKET` pair from its parent, and returns
|
||||
compressed access units. It connects to no compositor, no D-Bus and no network, so its
|
||||
non-dumpability costs nothing and its blast radius is one socket to the host that spawned it.
|
||||
|
||||
🛑 **The invariant, for anyone packaging this:** the worker is a **separate file**. Never a hardlink
|
||||
to `punktfunk-host` and never a subcommand of it — a shared inode shares the file capability, which
|
||||
silently re-creates 0.26.0-1 on every KDE box. `punktfunk-host` carries no capability, on any
|
||||
channel, ever.
|
||||
|
||||
- **The grants are re-targeted, not re-introduced.** Every channel that granted in 0.26.0-1 grants
|
||||
again, at the worker: Arch `.install` (`post_install` **and** `post_upgrade` — a replaced binary is
|
||||
a new inode), RPM `%caps(cap_sys_nice=ep)` in `%files` (never a `%post setcap`; this covers Fedora
|
||||
and Bazzite layering), the Bazzite sysext staging tree pre-`mksquashfs` (which does record
|
||||
`security.capability`), the deb `postinst`, the Deck installer, and NixOS
|
||||
`security.wrappers.punktfunk-encode-worker`. Every #136 host-side removal stays verbatim, including
|
||||
the sysext's host hard-fail.
|
||||
- **The sysext assertion is amended, not removed** — host must be empty (hard fail), worker must
|
||||
carry **exactly** `cap_sys_nice=ep`. A *missing* worker capability is not an error: the grant is
|
||||
best-effort everywhere.
|
||||
- **A new release-CI leg asserts the getcap matrix** on the built Arch package, the deb and the
|
||||
mounted sysext raw. The 0.26.0-1 lesson was "verify the package, never the board"; this is that,
|
||||
mechanized, and it is what would have caught the original break.
|
||||
- **On NixOS the env override is load-bearing**, not a convenience: a file capability cannot live on
|
||||
a read-only store path, so the module wraps the worker and sets `PUNKTFUNK_ENCODE_WORKER` to the
|
||||
wrapper path in the unit. An ambient grant is fine *here* — the worker is not a KWin client. The
|
||||
host's `ExecStart` stays on the plain store path (the #136 fix stands).
|
||||
|
||||
**Fallback ladder — no rung can kill a negotiated session.** Binary not found → spawn failure →
|
||||
handshake timeout → protocol or workspace-version mismatch → socket EOF mid-session all fall back to
|
||||
the **in-process encoder exactly as today**, at default priority, with one warning. Host and worker
|
||||
are different files now, so the version check is load-bearing rather than decorative; they ship
|
||||
lockstep in every channel. The in-process path stays compiled and tested — it is the floor, not dead
|
||||
code. `PYROWAVE_QUEUE_PRIORITY` keeps its 0.26.0 grammar and is now forwarded **explicitly** in the
|
||||
handshake rather than read from the worker's environment, which is sanitized at spawn; one env var
|
||||
still means one thing on both platforms.
|
||||
|
||||
### NixOS — session detection, module defaults, and a CI gate that was never running
|
||||
|
||||
🛑 **The host could not detect any graphical session on NixOS, at all.** The live-session probe
|
||||
matched `/proc/<pid>/comm` exactly against `kwin_wayland` / `gamescope` / `gnome-shell` /
|
||||
`Hyprland`. `comm` is the kernel's name for the **executed file**, truncated to 15 bytes — not
|
||||
`argv[0]` — and nixpkgs wraps essentially every graphical binary: `wrapProgram` moves the real ELF
|
||||
aside to `.<name>-wrapped` and installs a wrapper that `exec -a "$0"`s it. So the kernel reports
|
||||
`.kwin_wayland-w` while `ps` and `pgrep -a` show a perfectly ordinary `kwin_wayland`, because they
|
||||
read argv. Every probe answered `ActiveKind::None` on a running desktop, and nothing downstream
|
||||
could recover: `wayland` logged as `-`, a correct `WAYLAND_DISPLAY` changed nothing, `Auto` returned
|
||||
the *detected* backend so a live KWin already in `available()` was never chosen, and a
|
||||
`PUNKTFUNK_COMPOSITOR` pin turned the miss into a hard error through `pinned_at_a_dead_session`.
|
||||
sway and river survived by accident — nixpkgs' wrapper execs a binary still called `sway`.
|
||||
|
||||
Names are now resolved through `/proc/<pid>/exe`, whose file name is untruncated, with the nixpkgs
|
||||
decoration stripped. Stripping requires **both** the leading `.` and a trailing `-wrapped`, so
|
||||
KWin's own real `kwin_wayland_wrapper` binary keeps its name instead of collapsing into
|
||||
`kwin_wayland` and handing the probe the parent's PID. The `comm` fast path is unchanged for every
|
||||
ordinary distro — one read, no readlink — and no name that matched before can stop matching. Also
|
||||
applied to the foreign-gamescope probe, which had the same defect.
|
||||
|
||||
**Module changes** (`services.punktfunk`):
|
||||
|
||||
- **`host.desktopSession`** *(new, default `false`)* — binds the host to `graphical-session.target`,
|
||||
the declarative form of the `punktfunk-host-desktop-session.conf` drop-in. Without it a
|
||||
Plasma/GNOME restart leaves the host holding a Wayland socket and portal D-Bus connection that
|
||||
died with the old compositor: it still listens, still answers, and every session after that fails
|
||||
at capture. Off by default because an appliance may never reach that target and would be left
|
||||
permanently stopped.
|
||||
- ⚠ **`scripting.autoStart` now defaults ON** *(behaviour change)*, matching the deb `postinst` and
|
||||
RPM `%post`, which both `systemctl --global enable` the runner, and the sysext's baked-in
|
||||
`default.target.wants` symlink. It was opt-in here on the reasoning that the runner is inert until
|
||||
you add automation — untrue since the game-library scanners became plugins, so a NixOS host came
|
||||
up with an empty library and no obvious cause. Opt out with `scripting.autoStart = false` or
|
||||
`systemctl --user mask punktfunk-scripting`.
|
||||
- **Three divergences from the shipped units, ported.** `punktfunk-web` gains
|
||||
`StartLimitIntervalSec=0` (without it, 5 starts / 10 s against `RestartSec=2` gives up permanently
|
||||
after ~10 s — exactly the window before the host's first `serve` writes the mgmt token, so a
|
||||
console enabled before the host's first run stayed dead) and `Restart=always` rather than
|
||||
`on-failure`. `punktfunk-scripting` gains the sandbox the deb/rpm unit has all along
|
||||
(`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=%h /tmp`, restricted address families,
|
||||
`PrivateTmp=no`) — it is the one unit that runs arbitrary operator TypeScript by design, and it
|
||||
had been running strictly less confined on NixOS than anywhere else.
|
||||
- A **warning** when the host is enabled and `xdg.portal.enable` is not.
|
||||
|
||||
🛑 **`nix flake check` does not check `nixosModules`** — worth knowing for anyone maintaining a
|
||||
flake. It forces the value and asserts it is a lambda taking an open attribute set, and stops;
|
||||
nix's source still carries `// FIXME: if we have a 'nixpkgs' input, use it to check the module.`
|
||||
Measured: a module with a nonexistent option, a nonexistent `pkgs` attribute **and** a nonexistent
|
||||
`lib` function passes, printing `checking NixOS module ... all checks passed!`. `nix.yml`'s header
|
||||
claimed that leg covered the module; it never had. `checks.<system>.nixos-module`
|
||||
(`packaging/nix/module-check.nix`) now evaluates it against real nixpkgs across four scenarios and
|
||||
asserts on the rendered units, including a guard that the host's `ExecStart` stays on the plain
|
||||
store path while the encode worker points at the wrapper. Its assertions are pure Nix, so
|
||||
instantiation runs them and the existing `--no-build` leg is enough. `punktfunk-gamescope` gains a
|
||||
`build-gamescope` dispatch input — it is on the critical path of every host build yet nothing
|
||||
compiled it, and it tracks nixpkgs' gamescope, so a `flake.lock` bump is what breaks it.
|
||||
|
||||
### C ABI 17 → 18
|
||||
|
||||
**`punktfunk_connection_next_rumble_cmd2` is new.** The `0xCA` rumble plane carries the two Xbox
|
||||
impulse-trigger motors (v3, below) and `punktfunk_connection_next_rumble_cmd`'s fixed out-params
|
||||
have no room for them:
|
||||
|
||||
```c
|
||||
PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
|
||||
PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
|
||||
uint16_t *left_trigger, uint16_t *right_trigger,
|
||||
uint32_t *backstop_ms, uint32_t timeout_ms);
|
||||
```
|
||||
|
||||
**Added, not widened.** `_cmd` keeps its signature *and* its values bit-identical for handle-only
|
||||
traffic; all four rumble entry points remain exported. An exported parameter list is part of the
|
||||
contract, and growing one in place breaks every out-of-tree embedder at once — with a
|
||||
stack-corruption signature rather than a link error. This follows the existing
|
||||
`next_rumble` → `next_rumble2` precedent.
|
||||
|
||||
⚠ **One behavioural delta on the old symbol**, documented in `abi.rs` and pinned by a test: against
|
||||
a host driving the trigger motors, a `_cmd` caller now receives commands with `low == high == 0`
|
||||
where the demux previously dropped the update entirely. They are idempotent handle stops — the
|
||||
command as a whole is not silent, so redundant-stop suppression cannot fold them. Zero cost today:
|
||||
nothing sources non-zero trigger levels yet.
|
||||
|
||||
**Render trigger levels only on a pad that has trigger motors.** Do not fold them into the handles —
|
||||
impulse-trigger content is continuous, so folding it drones the handle motors flat-out. Query
|
||||
`SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN` or `GCDeviceHaptics.supportedLocalities`.
|
||||
|
||||
🛑 **This delivery path is deliberately built ahead of its producer and nothing here claims
|
||||
otherwise.** Exactly one backend can ever source these levels — the Windows HID Xbox pad's output
|
||||
report `0x03` — because `XINPUT_VIBRATION` and evdev `FF_RUMBLE` both have two members. That
|
||||
producer is reachable only through GameInput, which does not enumerate an `xinputhid`-promoted Xbox
|
||||
pad at all (measured against a real Microsoft Elite, equally invisible there while classic XInput
|
||||
reads it live). The wire, the engine and this entry point are exercised by synthetic levels only.
|
||||
|
||||
### Gamepads
|
||||
|
||||
- **`PUNKTFUNK_GAMEPAD_XBOXELITE = 11`** — a new `GamepadPref` wire byte, appended to
|
||||
`Hello`/`Welcome`. The `Auto` sentinel in the round-trip test moved 11 → 12. An older peer
|
||||
degrades an unknown byte to `Auto`, so this is graceful in both directions.
|
||||
- **`XboxOne` is now a distinct HID identity on Windows** (`045E:02FD`, Bluetooth Xbox One S)
|
||||
through the UMDF minidriver. It used to fold to `Xbox360` there, because the only Windows Xbox
|
||||
backend was the XUSB companion, which presents one fixed 360 identity and cannot vary it.
|
||||
- **Three new `pf_driver_proto::gamepad` device types**, contiguous and sharing one report
|
||||
descriptor byte for byte (they are the same pad in HID terms; the descriptor is the report
|
||||
*shape*, the identity is what the OS keys mappings off):
|
||||
|
||||
| const | value | identity |
|
||||
|---|---|---|
|
||||
| `DEVTYPE_XBOX` | 4 | `045E:0B13` Xbox Wireless Controller |
|
||||
| `DEVTYPE_XBOX_ONE_S` | 5 | `045E:02FD` Xbox Wireless Controller (One S) |
|
||||
| `DEVTYPE_XBOX_ELITE` | 6 | `045E:0B22` Xbox Elite Wireless Controller Series 2 |
|
||||
|
||||
⚠ The Xbox input report is **not** 64 bytes like its siblings — it is `XBOX_INPUT_REPORT_LEN`
|
||||
(16). The driver serves per-identity report lengths, because hidclass sizes its buffer from the
|
||||
descriptor and refuses an over-long source.
|
||||
- ⚠ **Elite paddles are not implemented.** `BTN_PADDLE1..4` still fold or drop exactly as on the
|
||||
other Xbox classes. `DualSenseEdge` remains the only virtual pad with native back-button slots.
|
||||
- **All three Xbox identities install `pfGamepadXbox`**, their own DDInstall section, which attaches
|
||||
the `xinputhid` bus filter. Merging it back into the shared `pfGamepad` section is a one-line edit
|
||||
that looks like tidying and would hand a DualSense, DualShock 4, Edge and Steam Deck to
|
||||
Microsoft's Xbox translator. `only_the_xbox_identity_installs_the_xinputhid_section` asserts the
|
||||
split in both directions.
|
||||
|
||||
**What actually promotes the pad — two registry values, and the pairing is the whole finding.**
|
||||
`UpperFilters=xinputhid` is a `.HW` AddReg (hardware key); `DevicePropertyFlags=1` is a DDInstall
|
||||
AddReg (software key). A one-value A/B on real hardware: removing `DevicePropertyFlags` alone
|
||||
reverts everything — no `IG_00`, no XUSB interface, no XInput, no WGI entry — while `UpperFilters`
|
||||
alone is completely inert. `1` = `BusDevice`, which Microsoft's own comment glosses as "a focused
|
||||
bus filter driver for the IG_ problem". **This retracts an earlier in-tree conclusion that the
|
||||
filter should never ship**: it was never broken, it had simply never been switched on.
|
||||
⚠ Microsoft's allow-list contains `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — neither `02FD`
|
||||
nor `0B22` is on it, and promotion happens anyway, because it comes from our own AddReg.
|
||||
|
||||
### Wire (no version change)
|
||||
|
||||
**The `0xCA` rumble datagram gains a v3 form**, `PUNKTFUNK_RUMBLE_V3_LEN = 14`:
|
||||
|
||||
```
|
||||
v1 7 B: [0xCA][u16 pad][u16 low][u16 high]
|
||||
v2 10 B: … [u8 seq][u16 ttl_ms]
|
||||
v3 14 B: … [u16 left_trigger][u16 right_trigger]
|
||||
```
|
||||
|
||||
v3 is built *from* v2's bytes, so the prefix relationship is structural rather than a convention two
|
||||
encoders must keep agreeing on, and every reader gates with `>=`. All four levels share one `seq`
|
||||
and one TTL deliberately: they are one statement of the pad's feedback at one instant, so the entire
|
||||
v2 apparatus — renewal cadence, stop burst, the client's seq gate, the lease clamp — governs the
|
||||
triggers with no new code. The new `RumbleUpdate` fields are plain `u16`, not `Option`: on a
|
||||
level-triggered plane "absent" must mean zero, because "absent → keep the previous value" is the
|
||||
stuck-rumble bug in a new costume.
|
||||
|
||||
⚠ **The two trigger `enable`-mask bits remain conjecture.** Bits 2/3 (the handles) are measured;
|
||||
bits 0/1 are inferred from field order and nothing else. No test asserts them. XInput cannot settle
|
||||
this; it has two motors.
|
||||
|
||||
### Packaging
|
||||
|
||||
- **gamescope pin `8c676c39` → `5fb8dce4`** (3.16.25-1 → 3.16.25-11), all six patches rebased, plus
|
||||
a **seventh**: the PipeWire use-after-free that aborted a session on every connect. The marker
|
||||
moves `+pfhdr4` → **`+pfhdr5`**, so `pkgrel` resets to 1.
|
||||
- **Patch 0001 offers `xBGR_210LE` before `xRGB_210LE`.** ⚠ Deliberately *not* done by calling
|
||||
upstream's `vulkan_get_rgb10_capture_format()` — that symbol landed after 3.16.25 and would break
|
||||
`packaging/nix/gamescope.nix` with an opaque C++ error instead of a patch conflict.
|
||||
- **Every `punktfunk-gamescope` RPM ever published was unsigned.** `Sign RPMs` runs right after
|
||||
`Build RPM`, while the gamescope RPM is built ~90 steps later behind its own cache, so it missed
|
||||
the signing pass entirely — and the repo file we ship carries `gpgcheck=1`. A second pass signs it
|
||||
before publish, fail-closed on a tag.
|
||||
- ⚠ **The v0.26.0 gamescope gate failed the job at the *build* step**, which in `deb.yml` runs before
|
||||
both the apt publish and the release attach — so a missing *extra* withheld the host `.deb` itself,
|
||||
and the `.deb` published on v0.26.0 still carries the `CAP_SYS_NICE` grant. `rpm.yml` had the
|
||||
identical latent bug. Both now warn at build/package time and gate as the **last** step of the job.
|
||||
- **`driver uninstall --audio`** — a third Inno `[UninstallRun]` entry that removes the MEDIA-class
|
||||
devnodes the host mints at runtime. Marker-matched, never name-matched: our instances are
|
||||
name-identical to Steam's, and a `ROOT\` enumeration guard means a marker-shaped value on a real
|
||||
sound card can never cost the user their hardware.
|
||||
- **The sysext `post_merge` step re-runs when already current, plus a new `reapply` verb.** A sysext
|
||||
upgrade is driven by the script from the **old** image, so a `post_merge` step added in a release
|
||||
is executed by nobody, permanently, on exactly the installs that need it.
|
||||
|
||||
### Host
|
||||
|
||||
- **HDR capture offers `xBGR_210LE` before `xRGB_210LE`.** gamescope's capture textures are
|
||||
mappable, hence linear-tiled, and NVIDIA does not implement linear-tiled STORAGE for
|
||||
`A2R10G10B10_UNORM_PACK32` — so `imageStore` lands in XBGR order while the buffer is still
|
||||
*labelled* `XRGB2101010`. Every mapping on both ends audits clean because the label was right and
|
||||
only the content was wrong. Fixed host-side because the deployed gamescope cannot self-correct.
|
||||
- **One NVENC open failure no longer kills every session on the box**, and the 10-bit capability
|
||||
probe no longer wedges a direct-SDK host process-wide with `NV_ENC_ERR_INVALID_VERSION`.
|
||||
- **`/api/v1/local/summary` reports the resolution the session actually got**, not the negotiated
|
||||
one it was seeded with.
|
||||
|
||||
### Workspace
|
||||
|
||||
`crates/punktfunk-encode-worker` joins as a member (above). Two bring-your-own-hardware measurement
|
||||
tools are added and **excluded** in the root manifest, so `cargo build --workspace` and CI never see
|
||||
them: `tools/hid-descriptor-dump` (dumps and decodes a real HID report descriptor; pulls `hidapi`)
|
||||
and `tools/win-input-matrix` (asks each Windows input API what it can see — ⚠ `wake_wgi()` is not
|
||||
optional there: both WGI collections return a cache a console app has never started filling, so
|
||||
without subscribing first they come back empty with real controllers attached).
|
||||
|
||||
### Host and client environment variables
|
||||
|
||||
- **`PUNKTFUNK_XBOX_BACKEND`** *(new, host, Windows)* — `hid` (the new **default**) or `xusb` (the
|
||||
escape hatch). The HID pad is now a superset of the XUSB companion: it keeps classic XInput while
|
||||
gaining Steam, SDL, RawInput, DirectInput, `joy.cpl` and WGI, plus rumble, which XUSB could not
|
||||
source at all. The escape hatch stays because promotion leans on Microsoft's inbox
|
||||
`xinputhid.inf`; if a servicing update changes it, one env var restores the old behaviour with no
|
||||
reinstall. An unrecognised value takes the **default**, not the opt-out, so a typo cannot silently
|
||||
drop a user onto the path with no HID collection.
|
||||
- **`PUNKTFUNK_GAMESCOPE_BIND`** *(new, host, Linux)* — unset = auto, `0` = never, `1` = force.
|
||||
Governs whether the host binds the patched gamescope over the distribution's `/usr/bin/gamescope`
|
||||
inside a session's mount namespace.
|
||||
- **`PUNKTFUNK_ENCODE_WORKER`** *(new, host, Linux)* — where to find the encode worker. Resolution
|
||||
order: this variable → alongside `/proc/self/exe` → `PATH`. `off` forces the in-process encoder,
|
||||
the debug escape hatch that makes the A/B a one-line change. Load-bearing on NixOS (above).
|
||||
- **`PYROWAVE_QUEUE_PRIORITY`** *(unchanged grammar, new consumer)* — the *intent*, forwarded to the
|
||||
worker; the granted class comes back in the handshake and the host logs it centrally, so the
|
||||
in-process INERT warning does not double-fire. When the worker is uncapped as well — an operator
|
||||
stripped it, or the filesystem cannot store the capability — the same INERT wording fires, now
|
||||
naming the worker binary rather than the host.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `docs-site` **Running as a service → GPU scheduling priority** rewritten around the split: the
|
||||
worker carries the capability, the host never does, and `setcap` on `punktfunk-host` is called out
|
||||
as the thing an operator must never do, with the `zkde_screencast_unstable_v1` symptom spelled out
|
||||
so anyone who already did it can self-diagnose. The anchor is unchanged, so existing links hold.
|
||||
- `configuration.md` gains the `PUNKTFUNK_ENCODE_WORKER` row and rewrites `PYROWAVE_QUEUE_PRIORITY`
|
||||
off "the packages deliberately do not grant this".
|
||||
- The 0.26.0 user-facing notes describe a privilege that is deliberately not granted. That is the
|
||||
record of what 0.26.0 shipped and is **not** rewritten; the new phrasing — granted to the worker,
|
||||
never to the host — lives in `docs/releases/v0.27.0.md`.
|
||||
- `install.md` **NixOS** documents `desktopSession`, and its `punktfunk-scripting` bullet no longer
|
||||
claims the runner "ships disabled": that was true only of Arch and source installs — apt, dnf, the
|
||||
Bazzite sysext and now the NixOS module all start it, because the library scanners are plugins.
|
||||
`bazzite.md` carried the same stale claim and is corrected. **Running as a service → Restart the
|
||||
host with your desktop** gains the NixOS one-liner beside the drop-in.
|
||||
- `packaging/nix/README.md`: `desktopSession`, `gamescopeHdr`/`gamescopePackage` and the
|
||||
`punktfunk` group added to the option tables; the "what the module configures" list gains the
|
||||
`security.wrappers` entry, with the KWin-identification reasoning for why the capability is on the
|
||||
worker and not the host; and a caveat recording that `nix flake check` does not check the module,
|
||||
plus the two rules for editing `module-check.nix`.
|
||||
|
||||
---
|
||||
|
||||
## v0.26.0
|
||||
|
||||
52 commits since v0.25.0.
|
||||
|
||||
### Versions
|
||||
|
||||
| | v0.25.0 | v0.26.0 | Notes |
|
||||
|---|---|---|---|
|
||||
| Wire protocol | 2 | **2** | unchanged |
|
||||
| C ABI | 17 | **17** | unchanged — no symbol added, removed or changed |
|
||||
| Workspace crate dirs | 26 | **26** | unchanged (40 workspace members) |
|
||||
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
|
||||
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
|
||||
| Plugin index schema | 1 | **1** | unchanged |
|
||||
| `api/openapi.json` | 0.24.0 | **0.25.0** | tracks API edits, lags one release by convention |
|
||||
| gamescope patch level (`+pfhdrN`) | 2 | **4** | 3 patches → 6; `pkgrel` 1 → 2 |
|
||||
| `@punktfunk/host` (SDK) | 0.1.2 | **0.1.4** | |
|
||||
| `@punktfunk/plugin-kit` | 0.3.2 | **0.4.0** | the `plugin` launch kind |
|
||||
|
||||
`crates/pf-driver-proto` is byte-for-byte identical to v0.25.0 and to v0.24.0 — if you ship the
|
||||
virtual-display driver or the gamepad channel, the last two releases have not touched you.
|
||||
|
||||
### ⚠ Breaking changes
|
||||
|
||||
**None.** This is a fixes release. Every embedder, packager and plugin that works against v0.25.0
|
||||
works against v0.26.0 unchanged. Two behaviour changes are worth knowing about anyway, because both
|
||||
make a client advertise *less* than it used to — see **Capability advertisement** below.
|
||||
|
||||
### Capability advertisement
|
||||
|
||||
- **`VIDEO_CAP_444` is now probed, not asserted.** It rode the "Full chroma" setting alone. That was
|
||||
safe while a software HEVC decoder sat underneath it; M8 removed one (there is no permissively
|
||||
licensed HEVC CPU decoder, so `software_decodable_codecs()` is `H264|AV1`). The host grants 4:4:4
|
||||
on HEVC **only** and answers the resolved chroma in the `Welcome` *before* the client builds a
|
||||
decoder — so on a device with no 4:4:4 decode the toggle did not cost crispness, it cost the whole
|
||||
codec: the Vulkan rung refuses the shape at construction, VAAPI refuses it too, there is no CPU
|
||||
rung, and the session reconnects on H.264. No AMD silicon has HEVC 4:4:4 decode, so every Steam
|
||||
Deck with that switch on lost HEVC. Per-profile and default-off, which is why it read as
|
||||
intermittent.
|
||||
|
||||
Now gated on `hevc_444_hardware_decodable`, which asks the driver through the same code the rung
|
||||
uses at construction (`VkH265Decoder::probe_stream_support`). **Both depths are required**, not
|
||||
either: with HDR the host may resolve 4:4:4 10-bit, and a device offering `YUV444_8` but not
|
||||
`YUV444_10` lands in the same hole. Answering from the Vulkan rung alone is exact rather than
|
||||
approximate — it is the only rung in this build that implements 4:4:4 at all
|
||||
(`pf_vaadec::profile_for` errors on `chroma_format_idc 3`, pf-dxvadec refuses anything but 4:2:0,
|
||||
the CPU rung is 8-bit 4:2:0).
|
||||
|
||||
⚠ Deliberately **not** extended to `VIDEO_CAP_10BIT`/HDR: all three rungs implement 10-bit 4:2:0,
|
||||
so a Vulkan-only probe there would withdraw HDR from boxes whose VAAPI/DXVA rung decodes it
|
||||
perfectly — a regression against a case never observed.
|
||||
|
||||
The bit arithmetic moved into `video::video_caps_for` so the part that was wrong is testable
|
||||
without a GPU, a host or a `Hello`; the test is verified non-vacuous against the planted defect.
|
||||
|
||||
### Host and client environment variables
|
||||
|
||||
Four new, one clarified. Verified new by `git grep` at the v0.25.0 tag, not assumed —
|
||||
`PUNKTFUNK_JUMBO`, `PUNKTFUNK_WIRE_MTU`, `PUNKTFUNK_STREAMED_AU`, `PUNKTFUNK_LIBRARY_ART_ROOTS`,
|
||||
`PUNKTFUNK_RECOVER_SESSION_CMD`, `PUNKTFUNK_GAMESCOPE_SDR_NITS`, `PUNKTFUNK_MAX_FPS` and
|
||||
`PUNKTFUNK_ON_CONNECT_CMD` all already existed.
|
||||
|
||||
- **`PUNKTFUNK_OVERLAY_MASK`** *(new, client)* — controls the Steam-overlay input mask below.
|
||||
- **`PUNKTFUNK_PYROWAVE_CHUNK_KIB`** *(new)* and **`PUNKTFUNK_PYROWAVE_STREAMED_AU`** *(new)* —
|
||||
PyroWave AU chunking and the streamed-AU path.
|
||||
- **`PYROWAVE_QUEUE_PRIORITY`** *(existed, but was inert on Linux — see below)* — grammar: unset →
|
||||
realtime, ASCII-lowercased, `off` alone disables, `high` asks for HIGH only, junk falls back to
|
||||
the ladder rather than to off. ⚠ **One env var must not mean two things on two platforms**, so
|
||||
the Rust grammar is unit-tested against the C patch's, including where both are deliberately
|
||||
un-clever (neither trims).
|
||||
- **`PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120`** *(new)* — widens the set a gamescope session
|
||||
offers in Steam's in-session display settings. The rate the session actually runs at is always
|
||||
included, so it can only add options; junk entries are skipped rather than failing the host.
|
||||
Requires gamescope patch level 3+.
|
||||
- **`PUNKTFUNK_COMPOSITOR`** *(behaviour clarified, not changed)* — documented as "which backend to
|
||||
drive", it also silently discarded `game_session=dedicated`: `resolve_compositor` gated the
|
||||
dedicated route on `!overridden` and logged nothing either way. The pin still wins — it is the
|
||||
operator's explicit knob — but it now says so and names itself. Two further holes closed with it:
|
||||
the pin put its backend into `available()` unconditionally *and* skipped `apply_session_env`'s
|
||||
`XDG_CURRENT_DESKTOP` scrub, so `pick_compositor` could never return `None` — the one call site of
|
||||
`try_recover_session()`, which left `PUNKTFUNK_RECOVER_SESSION_CMD` unreachable behind that arm.
|
||||
Liveness is now read on both paths. `needs_live_session()` exempts gamescope, which stands up its
|
||||
own session, so pinning it on a headless box stays supported.
|
||||
|
||||
### Client settings keys
|
||||
|
||||
All additive; an older client ignores what it does not know, and a newer value can never trap an
|
||||
older client.
|
||||
|
||||
- **`gamepad_ui_mode`** — `"connected"` (default, and exactly what the previous lone Bool meant) or
|
||||
`"always"`. Splits *whether* the controller UI is offered from *when* it appears.
|
||||
`GamepadUIEnvironment.isActive` takes the mode with **no default argument** on purpose: a call
|
||||
site that forgot it would silently strand everyone who chose Always. An unrecognized value waits
|
||||
for a controller.
|
||||
- **`ui_palette`** gains `oled` at **index 1**, directly after the brand default — keeping
|
||||
`PALETTES[0]` the unknown-id fallback and the dark-to-pale cycling order intact. Hand-mirrored in
|
||||
three languages (`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`); each
|
||||
port carries an `oled_is_actually_black` test that measures the claim (mean cell luminance 0.019
|
||||
against Violet's 0.254) rather than restating the table.
|
||||
- **`library-hidden.json`** — per-title hide list, mirroring how `library-scanners.json` holds
|
||||
disabled sources. Deliberately **not** stored on the entry: a scanner's and a plugin's titles are
|
||||
rebuilt from scratch on every scan and reconcile, so a flag written onto one would be erased
|
||||
minutes later. Applied in `all_games`, the single funnel every play surface already goes through
|
||||
(client grid, native clients, the GameStream app list, launch resolution).
|
||||
|
||||
### gamescope patches
|
||||
|
||||
Three → six, and the marker patch moves last so the banner is stamped after the capabilities it
|
||||
advertises.
|
||||
|
||||
- **0003 — headless: advertise the virtual display's mode and refresh rates.** `CHeadlessConnector`
|
||||
returned empty spans from `GetModes()` and `GetValidDynamicRefreshRates()` and reported
|
||||
`GAMESCOPE_SCREEN_TYPE_INTERNAL`, so `update_mode_atoms` **deleted** the mode-list atom and
|
||||
wlserver fell through to a one-entry refresh list built from `g_nOutputRefresh` — which, with
|
||||
`--nested-refresh` absent, is `Init()`'s 60 Hz default. That is why a 1920x1080@120 client saw
|
||||
"gamescope only shows 60hz" and Overwatch capped itself to 60 while the stream ran at 120. Now
|
||||
populates both from the resolved mode, reports `EXTERNAL`, and adds `--custom-refresh-rates`.
|
||||
gamescope-session-plus has probed for that flag for years; upstream never had it, so the
|
||||
`CUSTOM_REFRESH_RATES` env it plumbs was a no-op everywhere.
|
||||
- **0004 — pipewire: optionally composite the external overlay into the capture stream.** That layer
|
||||
is mangoapp. `paint_pipewire` has never referenced it on any version. Behind
|
||||
`--pipewire-composite-external-overlay`, off by default.
|
||||
- **0006 — never destroy the Vulkan device or output.** `g_device` (`CVulkanDevice`) and `g_output`
|
||||
(`VulkanOutput_t`) were plain globals, so glibc ran their destructors from `__run_exit_handlers`
|
||||
once `main()` returned — calling back into an ICD that had already been torn down and unloaded.
|
||||
Faulting address equalling the instruction pointer is the signature. Reproducible with
|
||||
`gamescope --backend headless -W 1280 -H 720 -r 60 --xwayland-count 1 -- true` (exit 139, every
|
||||
time). Both globals get storage constructed exactly as before but never destroyed; pinning only
|
||||
the device relocated the fault into `~VulkanOutput_t`, hence a shared `CNoDestroy<T>`.
|
||||
|
||||
⚠ **`+pfhdrN` deliberately does not move for 0006.** The marker is a capability tier the host
|
||||
probes via `gamescope_patch_level()` *before* it spawns; this patch adds no capability, so bumping
|
||||
it would advertise a tier that does not exist. Ships as a `pkgrel` bump instead.
|
||||
|
||||
⚠ gamescope CI legs are best-effort — a broken patch is a **missing package**, not a red run.
|
||||
|
||||
### Virtual-display handle ownership (Windows)
|
||||
|
||||
The control-device sharing contract was "bare `HANDLE` copies, never closed for the process
|
||||
lifetime": retired handles were kept alive because pinger/linger threads and capture closures held
|
||||
raw copies whose soundness depended on no-close. An open control handle is exactly what vetoes the
|
||||
PnP disable — and can wedge the `pnputil` restart — that wake-from-sleep recovery leans on, so every
|
||||
post-wake adapter reload came back REFUSED. `reset-pf-vdisplay.ps1` stops the whole host service
|
||||
precisely to get those handles closed; the in-process recovery could not.
|
||||
|
||||
Ownership is now `Arc` all the way out: `ensure_device` / `device_handle` / `control_device_handle`
|
||||
hand out `Arc<OwnedHandle>` clones, every consumer holds its clone across its IOCTLs (ending the
|
||||
`isize` smuggling — `Arc<OwnedHandle>` is `Send + Sync`), and retiring drops only the manager's
|
||||
reference. `DeviceSlot::retired` is gone.
|
||||
|
||||
⚠ **Nothing may store a bare control `HANDLE` again.** The whole fix is that the handle closes when
|
||||
the last in-flight user drains.
|
||||
|
||||
### Presenter — points are not pixels
|
||||
|
||||
`SDL_GetDesktopDisplayMode` reports a mode in **screen coordinates** and hands the pixels-per-point
|
||||
ratio back separately as `pixel_density`; `m.w`/`m.h` were read raw. KDE advertises a 2560x1600 panel
|
||||
at 150 % as 1707x1067 points with a density of ~1.4997, `render_scale::apply` even-floors both odd
|
||||
axes, and 1706x1066 went on the wire. Multiplying by the density recovers 2560x1600 to the pixel.
|
||||
|
||||
⚠ Inert on X11 and Windows: SDL never sets a density there and `SDL_video.c` normalizes the unset
|
||||
0.0 to 1.0. **This bug needed a compositor doing fractional scaling.**
|
||||
|
||||
Second, independent defect: the SDL window was created without `HIGH_PIXEL_DENSITY`, so the Wayland
|
||||
surface stayed at buffer scale 1 and the swapchain was built at 1707x1067 for KWin to upscale. That
|
||||
one also silently shrank "Match window", which asks the host for `size_in_pixels()`.
|
||||
|
||||
### Apple audio session
|
||||
|
||||
`micEnabled` and `echoCancel` both default to `true`, so the **default** iOS session is
|
||||
`.playAndRecord` — and that branch set `.defaultToSpeaker`. That option is an output **override**,
|
||||
not a preference, and it outranks an A2DP route. ⚠ **Wired headphones beat it, Bluetooth does not**,
|
||||
so testing with a cable returns the wrong answer — which is what the comment sitting on it asserted.
|
||||
|
||||
Now solved against the route actually given: after activation, if the current output is
|
||||
`.builtInReceiver`, override to speaker; anything external (Bluetooth, wired, CarPlay, AirPlay) is
|
||||
left strictly alone. The override is a property of the current route — iOS drops it on every route
|
||||
change, which is what lets a newly-connected headset win — so it is re-applied per route via an
|
||||
observer, registered only for `.playAndRecord`, removed in `stop()` before deactivate, `deinit` as
|
||||
backstop. Without it, dropping Bluetooth mid-stream lands on the earpiece.
|
||||
|
||||
⚠ Deliberately **not** adding `.allowBluetooth`: it would make a headset's mic usable but drag the
|
||||
whole route onto HFP/SCO and collapse game audio to narrowband.
|
||||
|
||||
### Audio jitter policy
|
||||
|
||||
`JitterPolicy` (`punktfunk-core/src/audio.rs`, used by Linux/Windows/Android) and its mirror in
|
||||
Swift `AudioRing`. The policy learned exclusively from audible failures on both sides: growth needed
|
||||
**three** audible underruns; the A/V sync loop re-tested a shallower ring every five quiet seconds
|
||||
and paid an audible starvation event every time it was wrong, forever; and a grown target was never
|
||||
re-banked (growth raises a threshold — only a re-prime deepens the ring), so a bunching link rode
|
||||
the knife edge with the "grown" target sitting inert.
|
||||
|
||||
Three mechanisms: **near-miss** (a read served with less than one protocol frame left over is the
|
||||
same evidence as an underrun, heard by no one — grows one step per window, *before* the click);
|
||||
**shrink probes** (every shrink armed for 5 s, undone on the spot if answered by an underrun or
|
||||
near-miss, with a doubling backoff 60 s → 8 min on a failed sync-driven shrink; a surviving probe
|
||||
resets it); **hollow re-prime** (an underrun while the depth *average* runs more than a step below
|
||||
target re-primes immediately — the average, not the instant, separates a hollow ring from one late
|
||||
packet, and it is seeded on prime so a fresh ring is never spuriously hollow).
|
||||
|
||||
Measured on a ten-minute simulation of the Wi-Fi power-save pattern (25 ms gaps / 300 ms, −50 ppm
|
||||
skew): **~2000 audible events → 9.**
|
||||
|
||||
### Plugins, SDK and the runner
|
||||
|
||||
- **`category` never shipped.** The console correctly keeps `category: "library"` plugins out of the
|
||||
nav; the host reported no category for them at all. `defineLibraryPlugin` sets it and
|
||||
`sdk/src/ui.ts` forwards it — what shipped did not: `@punktfunk/host` was bumped to 0.1.2 on
|
||||
2026-07-20 and `category` landed 2026-08-05 without a bump, so the registry's 0.1.2 is the
|
||||
pre-category build. ⚠ **Inert until published.** `serveUi` now reads its own directory entry back
|
||||
and warns once when a requested category did not land.
|
||||
- **Local art sync failed on a `file://` disagreement.** `local_art_bytes` decodes a `file://` value
|
||||
before testing containment; `validate_art_paths` handed the raw value to `Path::new`. Same defect
|
||||
produced both the unreachable settings and `sync (startup) failed: HostRequestError`.
|
||||
- **The runner now carries SDK updates.** The copy each installed plugin runs was pinned at install
|
||||
time, so an SDK fix could never reach it.
|
||||
- **`bun publish` runs `prepare`, and `prepare` needs bun2nix** — the SDK could not be published at
|
||||
all. Also fixed: a corrupt committed `bun.lock` in plugin-kit.
|
||||
- **Decky client update.** `flatpak remote-info punktfunk-origin io.unom.Punktfunk` names no branch;
|
||||
the remote publishes `stable` **and** `canary`, so the ref is ambiguous and flatpak refuses it —
|
||||
⚠ one branch being *installed* does not disambiguate, the ambiguity is on the remote. The call
|
||||
failed on every box, every time, and returned `available=False`, which the panel rendered as good
|
||||
news. Every query now names the ref in full via `_flatpak_ref()` (no subprocess), carrying the
|
||||
**scope** too, so a system-wide install is no longer invisible to a check that hardcoded `--user`.
|
||||
A check that cannot run now reports `client_error`.
|
||||
|
||||
### Packaging
|
||||
|
||||
- **The `punktfunk` group is created everywhere the udev rule needs it.** `60-punktfunk.rules`
|
||||
chgrp's the usbip vhci attach/detach nodes to a dedicated group (security review 2026-08-05 M-4:
|
||||
writing `attach` materialises an arbitrary emulated USB device, so it must not ride on `input`).
|
||||
**Four of six install paths shipped that rule in 0.25.0 without creating the group** — chgrp
|
||||
failed, nodes stayed `root:root 0644`, the virtual Deck pad silently never attached, and
|
||||
`usermod -aG punktfunk` failed outright. Fixed in arch `post_upgrade()` (only `post_install` was
|
||||
correct, so every box that reached 0.25.0 by `pacman -Syu` missed it), nix (`users.groups.punktfunk`
|
||||
did not exist), the bazzite sysext (a group is host state and cannot ride an image), and the Steam
|
||||
Deck scripts. deb and rpm were correct throughout.
|
||||
- **`punktfunk-gamescope` now builds for RPM and apt**, not Arch only.
|
||||
- **Arch release-rebuild prune** called a helper that cannot exist in a release rebuild. Together
|
||||
with the FFmpeg 9 repackage this closes the 0.25.0-1 → 0.25.0-2 episode in the pipeline rather
|
||||
than by hand.
|
||||
- **Steam Deck `update.sh` / `install.sh`.** The web step ran `bun install --frozen-lockfile` with
|
||||
no `--ignore-scripts`, so web's `postinstall` (`bun2nix -o bun.nix`) rewrote a **tracked** file on
|
||||
every update; the SDK step below it had always passed `--ignore-scripts`, and that asymmetry is
|
||||
the whole bug. Now `--ignore-scripts` plus an explicit `bun run codegen` — provably equivalent,
|
||||
since web's `prepare` is literally `"bun run codegen"` and `src/api/gen`, `src/paraglide` and
|
||||
`src/routeTree.gen.ts` are gitignored. `--pull` restores `web/bun.nix` and `sdk/bun.nix` before
|
||||
pulling, which is lossless by construction. ⚠ Deliberately **not** `git reset --hard`: `$SRC`
|
||||
defaults to the operator's own checkout. Also: `web.env` secret hygiene — `chmod 600` sat inside
|
||||
the create-only branch, so an install set up once and only updated since kept it world-readable.
|
||||
⚠ `packaging/debian/build-web-deb.sh`, `packaging/arch/PKGBUILD` and `packaging/rpm/punktfunk.spec`
|
||||
still lack `--ignore-scripts` for web — harmless (throwaway build trees), left as follow-up.
|
||||
|
||||
### Triage tooling
|
||||
|
||||
**`--probe-decode` described a different device from the one that streams.** The RADV
|
||||
video-decode opt-in sat *after* the `--list-adapters` / `--probe-decode` / `--list-audio` / `--pair`
|
||||
early exits, so the triage tool never had it. Measured on a Deck, same binary back to back: bare
|
||||
`--probe-decode` printed "vulkan video decode: no", "driver decode ops: none (0x0)", "no queue
|
||||
family advertises VIDEO_DECODE"; with `RADV_PERFTEST=video_decode` in the environment, "YES" and
|
||||
"H.264, H.265, AV1, VP9". ⚠ **Any Deck triage that consulted it reached the opposite of the truth.**
|
||||
Hoisted to the top of `run`, ahead of every early exit.
|
||||
|
||||
### PyroWave on Linux — Wave 2
|
||||
|
||||
The program's own measurement, from patch 0005's header: `encode_gpu_synchronous` goes from ~2 ms
|
||||
to **15–18 ms at 95 % game load**, with the stream frame rate collapsing. PyroWave encodes on the
|
||||
same shader cores a game saturates; NVENC is immune because it has its own ASIC.
|
||||
|
||||
- **PW1 — the GPU-priority lever had never fired on Linux.** The vendored patch requests an elevated
|
||||
global-priority queue, gated `if (!inherit_info)` — and **only Windows leaves `inherit_info` null**
|
||||
(`pyrowave_create_device_by_compat`, where Granite builds the device itself). Linux passes its own
|
||||
create-infos, Granite's `get_existing_create_info()` hands them back, `create_device` takes the
|
||||
inherit branch, and the whole block is skipped. Now wired natively in `open_inner`'s `DeviceHold`,
|
||||
ladder REALTIME → HIGH → no-priority, stepping only on refusal; a refused class can never fail the
|
||||
open. The extension probe reuses the `dev_ext_props` already fetched for `queue_family_foreign` and
|
||||
takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
|
||||
⭐ **Needs `CAP_SYS_NICE`**, which the packaging granted in `0.26.0-1`; without it the lever does
|
||||
nothing.
|
||||
🛑 **Corrected in `0.26.0-2`: the packaging no longer grants it, and must not.** Every channel that
|
||||
did (Arch `.install`, RPM `%caps()`, the Bazzite sysext image, the deb postinst, the NixOS
|
||||
`security.wrappers` entry) broke desktop streaming on KDE outright — field-reported on CachyOS and
|
||||
Bazzite as `KWin does not expose zkde_screencast_unstable_v1 to this client`. KWin identifies a
|
||||
client by resolving its `/proc/<pid>/exe` against an installed `.desktop`, and the kernel refuses
|
||||
that readlink to any reader whose effective set is not a superset of the target's **permitted**
|
||||
set (`cap_ptrace_access_check`) — KWin has no capabilities, so a capability-carrying host is
|
||||
unidentifiable and the restricted globals are never advertised. Neither `prctl(PR_SET_DUMPABLE, 1)`
|
||||
nor systemd `AmbientCapabilities=` rescues it; only an uncapped process is identifiable. The lever
|
||||
therefore stays wired but unexercised on a stock install (the ladder degrades to default priority),
|
||||
and is opt-in for gamescope-only hosts, which have no such identity check.
|
||||
- **PW5 — two encoder handles.** `Encoder::Impl` owns exactly one each of `wavelet_img_high_res`,
|
||||
`bucket_buffer`, `meta_buffer`, `block_stat_buffer`, `payload_data`, `quant_buffer`, and
|
||||
`Impl::encode` *opens* by discarding them (an image barrier with `VK_IMAGE_LAYOUT_UNDEFINED` as the
|
||||
old layout, plus three `fill_buffer` clears). Two encodes submitted to one queue have **no**
|
||||
execution dependency in Vulkan — submission order orders the start, not the completion — so N+1's
|
||||
DWT would overwrite N's wavelet bands while N's block packing still reads them. Content-dependent
|
||||
and silent. Overlap therefore means two handles alternated, one per slot. ⚠⚠ **The landmine:**
|
||||
`sequence_count` also lives on `Impl`, and it is the **3-bit** counter stamped into every block
|
||||
header. Two handles each counting 1,2,3… put 1,1,2,2,3,3… on the wire, and the decoder restarts a
|
||||
frame only when the value *changes* — so a repeat reads as more blocks of the same frame. Depth is
|
||||
**still 1**; the handles alternate with one in flight.
|
||||
- **PW3 — the fence wait moved out of submit.** PyroWave was the one backend waiting its fence inside
|
||||
`submit`.
|
||||
- **PW7a — the jumbo leg was dead code.** quinn caps a peer's MTU-discovery search at
|
||||
`min(MtuDiscoveryConfig::upper_bound, the other side's advertised max_udp_payload_size)`, and
|
||||
`EndpointConfig::max_udp_payload_size` **defaults to 1472**. Nothing in the repo had ever touched
|
||||
`EndpointConfig`, so raising the host's probe ceiling could never make discovery settle above 1472
|
||||
— and the shipped mid-session grow's `settled >= sealed_datagram_bytes(target)` gate was
|
||||
unreachable on **every path that has ever existed**. Two smaller contributors fixed with it: the
|
||||
watcher stopped sampling the moment `settled >= 1472`, discarding the very climb the proof needs;
|
||||
and a session sealed above the 1500-byte default was never checked against the path at all.
|
||||
|
||||
The advertisement is raised on the **client** endpoint under the same `jumbo_wire_mtu()` opt-in,
|
||||
because it is not free: quinn sizes its endpoint receive buffer
|
||||
`max_udp_payload_size × max_receive_segments × BATCH_SIZE` — on a GRO-capable Linux/Android client
|
||||
that is ~2.9 MiB at the default and **~18 MiB at jumbo** (47 KiB → 288 KiB on Apple/Windows).
|
||||
PyroWave is the codec that most wants this: it can never be re-keyed mid-stream (its client parses
|
||||
chunk-aligned AUs in windows of the `Welcome` value, read once over the C ABI), so it should
|
||||
*start* at the big shard. At an 8908-byte shard that is ~6× fewer datagrams per frame — **~49k → ~8k
|
||||
pps at 550 Mb/s**.
|
||||
|
||||
### Zero-copy capture
|
||||
|
||||
- **The dmabuf latch conflated two causes with different lifetimes.** One `AtomicBool` served both
|
||||
"the encoder repeatedly failed to import what this compositor allocates" (unrecoverable, a driver
|
||||
fact) and "the dmabuf-only capture offer never negotiated" (which can just mean the compositor was
|
||||
mid-restart). Sharing it made the second as permanent as the first: **one timeout, and every later
|
||||
session on that host captured CPU frames until the process restarted** — including sessions against
|
||||
a different compositor and a different node that had never failed at anything, with nothing said.
|
||||
Now a `RawDmabufLatch` owning both: import failures stay sticky (unchanged 3-consecutive threshold);
|
||||
negotiation timeouts get a retry budget of **2** — deliberately small, since each failure costs a
|
||||
~10 s stall the user pays in dead air; a capture that negotiates credits the budget back; and both
|
||||
are keyed to a capture identity (node id + portal bit).
|
||||
- **The zero-copy path never asked for buffer headroom.** `build_dmabuf_buffers` set
|
||||
`SPA_PARAM_BUFFERS_dataType` and stopped — no `SPA_PARAM_BUFFERS_buffers` at all, so the pool depth
|
||||
every zero-copy safety argument rests on was entirely the producer's choice and we never expressed
|
||||
a preference. Now asks for 8 (min 2, max 16) as a **Choice Range, deliberately not a fixed count**:
|
||||
SPA intersects consumer and producer params, so a fixed 8 against a producer that can only afford 4
|
||||
empties the intersection and the link stalls in "negotiating" with no error anywhere — ⚠ the exact
|
||||
trap that once cost this codebase the entire Linux cursor channel, when a 256² cursor-meta max
|
||||
failed to intersect Mutter's fixed 384². 8 buffers is ~133 ms of pool at 60 Hz and ~33 ms at 240 Hz;
|
||||
16 is a ceiling, not a request (a 4K 4:4:4 buffer is ~25 MB).
|
||||
- **A PyroWave session could drop to CPU capture and log nothing.** The CPU-fallback warning was gated
|
||||
on `backend_is_vaapi`, which reads the **host-global** encoder pref — but a PyroWave session is
|
||||
negotiated **per session**, so on an NVIDIA/auto host that gate is false and the session fell out of
|
||||
every arm of the negotiation log chain while paying a full-resolution CPU pixel touch every frame.
|
||||
A degraded host and a healthy one produced identical logs. Now asks the per-session question
|
||||
(`consumer_kind`), widened to every GPU consumer and excluding only the software encoder, whose
|
||||
native input *is* CPU frames. ⚠ `pyrowave_session` must outrank `backend_is_vaapi`, because a
|
||||
PyroWave pref flips `backend_is_vaapi` on too.
|
||||
|
||||
### Steam-overlay input masking (Steam Deck)
|
||||
|
||||
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the **same physical controller** the
|
||||
client forwards, so opening either moved the game on the host as well — a second, invisible player.
|
||||
Steam Input masks a normal game here; it cannot mask us, because masking happens on Steam Input's
|
||||
virtual pad and we deliberately forward the **real** one (the virtual pad has no gyro, trackpads or
|
||||
paddles).
|
||||
|
||||
⚠ **SDL's own gate cannot fire on a Deck.** SDL drops presses while a process has windows but no
|
||||
keyboard focus, and it is on by default — but gamescope resolves focus per Xwayland ctx and the client
|
||||
sits alone in its own, so the Steam overlay (which lives in the root ctx) never takes our X focus and
|
||||
no `FocusOut` is ever generated. Measured on glass: with the QAM open, X input focus inside the
|
||||
client's ctx stayed on its window for the whole 4 s while `GAMESCOPE_FOCUSED_APP` flipped to 769
|
||||
(Steam) and `GAMESCOPE_FOCUSED_APP_GFX` stayed on the app. **That pair of atoms is the signal.**
|
||||
|
||||
⚠ `overlay_focus` watches them on the gamescope **root** ctx, which is *not* our own `$DISPLAY` under
|
||||
`--xwayland-count 2` — hence the socket-directory walk and the flatpak filesystem line.
|
||||
|
||||
⚠⚠ Masking is deliberately **not** `set_forwarding`: that closes the slot and sends `GamepadRemove`,
|
||||
so the game would see a controller **unplug** every time somebody opened the QAM. Every slot stays
|
||||
open and only transitions stop, after flushing what the host believes is held (so a stick deflected at
|
||||
overlay-open stops steering instead of freezing at its last value). On the way back, held buttons are
|
||||
**adopted rather than replayed** — the A that picked a QAM row must not fire in the game as it closes
|
||||
— while axes *are* re-sent, since a stick has no press to ghost and SDL only speaks on change.
|
||||
|
||||
### The `plugin` launch kind
|
||||
|
||||
The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses on the
|
||||
**first** offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`, stopped
|
||||
putting anything in the library at all. Playnite hit the same wall and was rescued with a typed kind
|
||||
the host resolves itself; there is no fixed scheme for "whichever emulator the operator configured,
|
||||
with the core and flags they chose", so that trick does not generalise.
|
||||
|
||||
The entry now carries an **opaque key and nothing executable**, and the host asks the owning plugin
|
||||
what to run at launch time, over the loopback UI port and per-boot secret it already registered.
|
||||
⭐ **A stolen plugin token stops being command execution:** planting an entry is not enough, because
|
||||
the live plugin answers 404 for a key it never published. Nothing executable is persisted or served to
|
||||
a client, and an emulator that moved is picked up on the next launch rather than leaving a dead tile
|
||||
(same reasoning as `xbox` resolving its AUMID at launch time).
|
||||
|
||||
⚠ **The host still spawns it**, because only the host can put the process where the stream can see it:
|
||||
on Linux that is either gamescope's own argv or a spawn carrying the session's compositor env, and the
|
||||
returned child is what session-game-lifetime tracks to know the game exited. A plugin spawning the
|
||||
emulator itself would land it outside both.
|
||||
|
||||
### Verification status
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| gamescope 0006 | 6/6 exit 0 on a release build at the real spawn shape (`2752x2064@120 --steam --xwayland-count 1`); distro control SIGSEGVs |
|
||||
| Decky client update | on the Deck against the real install — pre-fix `available=False remote=''`, post-fix `available=True remote=ca010668` |
|
||||
| `--probe-decode` | on a Deck, same binary back to back, with and without the RADV opt-in |
|
||||
| Apple audio | builds on arm64-apple-ios17.0 (the triple that compiles the `#if os(iOS)` blocks — a plain `swift build` is macOS and skips them), arm64-apple-tvos17.0, macOS; 257 Swift tests |
|
||||
| Audio jitter | 10-minute Wi-Fi power-save simulation, ~2000 → 9 audible events |
|
||||
| 4:4:4 gate | test verified non-vacuous against the planted original defect |
|
||||
| Steam Deck scripts | `bash -n` + shellcheck 0.11.0 clean at `-S warning`; exec bits preserved |
|
||||
| Steam-overlay masking | on glass on a Deck — atom flip and X-focus non-flip both measured over a 4 s QAM open |
|
||||
| PyroWave depth 2 | exercised on real hardware **without shipping depth 2** (dedicated test, shipped depth stays 1) |
|
||||
| PW6 streamed AU | the trap is real, and at 2 % loss it costs exactly nothing |
|
||||
|
||||
⏳ **Owed on glass:** iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
|
||||
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the PyroWave-under-game-load A/B on a
|
||||
Linux host with `CAP_SYS_NICE` actually granted — the number this whole wave is aimed at. ⚠ That
|
||||
last one now needs a **gamescope-only** host, or a hand-granted capability on a box you are not
|
||||
streaming the KDE desktop from: see the `0.26.0-2` correction under PW1 above.
|
||||
|
||||
---
|
||||
|
||||
## v0.25.0
|
||||
|
||||
407 commits since v0.24.0.
|
||||
@@ -1037,20 +79,6 @@ capability rode on `input`, which every gamepad guide tells users to join — bu
|
||||
arbitrary USB hardware. Operators must `usermod -aG punktfunk "$USER"` and re-login or the pad stops
|
||||
attaching. Ordinary virtual gamepads are unaffected.
|
||||
|
||||
> **Known issue in 0.25.0, fixed after it.** Four of the six install paths shipped
|
||||
> `60-punktfunk.rules` — whose `RUN+=` does `chgrp punktfunk` on the vhci `attach`/`detach` nodes —
|
||||
> without ever creating the group, so the `chgrp` failed, the nodes stayed root-only, and the pad
|
||||
> silently never attached. The `usermod` above also fails outright on those boxes with *group
|
||||
> 'punktfunk' does not exist*. Affected: **Arch/CachyOS upgraded** rather than freshly installed
|
||||
> (`post_upgrade` called only `_ensure_update_group`), the **NixOS module** (no
|
||||
> `users.groups.punktfunk`), the **Bazzite sysext** (a group is host state and cannot ride an
|
||||
> image), and **Steam Deck source installs** (`scripts/steamdeck/install.sh`/`update.sh` handled
|
||||
> only `input`). The deb and rpm scriptlets were correct throughout — they run one `%post`/`postinst`
|
||||
> on install and upgrade alike. All four now create the group, and the two that know which user
|
||||
> runs the host (the Deck scripts and the NixOS module's `host.users`) add that user to it as well.
|
||||
> Workaround on an unpatched box:
|
||||
> `sudo groupadd --system punktfunk`, then the `usermod`, then re-login.
|
||||
|
||||
**3. Plugins may no longer set `launch.command` or the pre-launch command.** Both run through a
|
||||
shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that
|
||||
populated them need updating — use the `launcher_ui` / `xbox` launch kinds instead.
|
||||
|
||||
@@ -994,7 +994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1114,9 +1114,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"pf-win-display",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
@@ -2359,7 +2358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2464,7 +2463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2499,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2989,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2997,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3018,7 +3017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3048,12 +3047,11 @@ dependencies = [
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3071,7 +3069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3079,7 +3077,6 @@ dependencies = [
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"serde_json",
|
||||
"skia-safe",
|
||||
"tracing",
|
||||
]
|
||||
@@ -3093,7 +3090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3103,7 +3100,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3120,8 +3117,6 @@ dependencies = [
|
||||
"pf-zerocopy",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -3129,7 +3124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3141,7 +3136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3155,11 +3150,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3188,14 +3183,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3210,7 +3205,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3218,7 +3213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3230,7 +3225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3239,7 +3234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3272,7 +3267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3283,7 +3278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3295,7 +3290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3518,7 +3513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3529,7 +3524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3547,7 +3542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3564,7 +3559,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3579,7 +3574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3598,7 +3593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3628,18 +3623,9 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3724,7 +3710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3738,7 +3724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3761,7 +3747,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.27.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
@@ -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.27.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
version = "0.25.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"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.27.0"
|
||||
"version": "0.25.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -53,7 +53,7 @@
|
||||
"clients"
|
||||
],
|
||||
"summary": "Unpair a client",
|
||||
"description": "Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Revocation is complete: a LIVE GameStream session owned by this certificate is\nended (the client gets the standard TERMINATION+disconnect), and removing the last pairing\nalso closes the ENet control port (UDP 47999), which is only bound while at least one pairing\nexists. The nvhttp TLS layer still completes a handshake with any well-formed client cert BY\nDESIGN (authorization is per-request via the paired-fingerprint check) — an unpaired client\nthat reconnects is rejected at every post-pair endpoint.",
|
||||
"description": "Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer\ndoes not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.",
|
||||
"operationId": "unpairClient",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4489,13 +4489,6 @@
|
||||
],
|
||||
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||
@@ -4562,13 +4555,6 @@
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -4753,10 +4739,6 @@
|
||||
"type": "boolean",
|
||||
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
|
||||
},
|
||||
"edid_lock": {
|
||||
"type": "boolean",
|
||||
"description": "**EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the\nsoftware equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at\nthe first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its\nlive-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks\non its next start. Targets the standby-sink stall class at its SOURCE: with emulation\npinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD\ndriver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like\n`game_session`); `#[serde(default)]` = off."
|
||||
},
|
||||
"game_session": {
|
||||
"$ref": "#/components/schemas/GameSession",
|
||||
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
||||
@@ -4792,7 +4774,7 @@
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject. Read\nat load time ([`DisplayPolicyStore::load_from`] warns when a file claims a version this host\ndoes not know, then reads it best-effort) and pinned back to the current version on write.",
|
||||
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
@@ -4861,7 +4843,7 @@
|
||||
},
|
||||
"EffectivePolicy": {
|
||||
"type": "object",
|
||||
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the policy call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].\n\n**Every field is required on the wire, deliberately.** Unlike [`DisplayPolicy`] — which is only\never a *file* — this shape is also the `fields` member of [`CustomPresetInput`], i.e. the request\nbody of `POST /display/presets` and `PUT /display/presets/{id}`, and a *response* member three\ntimes over (`DisplaySettingsState.effective`, `PresetInfo.fields`, `CustomPreset.fields`).\n`#[serde(default)]` here would (a) turn `{\"name\":\"Kiosk\",\"fields\":{}}` — or any camelCase typo —\nfrom a serde rejection into a 201 storing a preset that expands to six axes nobody chose, and\n(b) make all six OPTIONAL in the generated OpenAPI schema, so every codegen'd client has to\nnull-check them. The *persisted* catalog's tolerance for an entry written before an axis existed\nis bought where it belongs, on the read path only: see [`StoredEffectivePolicy`].",
|
||||
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
|
||||
"required": [
|
||||
"keep_alive",
|
||||
"topology",
|
||||
@@ -5368,14 +5350,6 @@
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.",
|
||||
"example": "steam"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||
@@ -5919,7 +5893,7 @@
|
||||
},
|
||||
"Identity": {
|
||||
"type": "string",
|
||||
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). The\nslot this resolves to is carried per backend: the Windows EDID serial + IddCx connector index,\nKWin's per-slot output name, and the host-persisted Mutter scale map.",
|
||||
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
|
||||
"enum": [
|
||||
"shared",
|
||||
"per-client",
|
||||
@@ -6136,14 +6110,14 @@
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Linger window in seconds, clamped to `0..=86400` on write (see\n[`DisplayPolicy::sanitized`]): a window longer than a day is `forever` by any honest\nreading, and `u32` seconds is ~136 years — a deadline the reaper would never reach and a\nnonsense `expires_in_ms` in `/display/state`.",
|
||||
"description": "Linger window in seconds.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\nHonored end-to-end: the registry resolves it to `Release::Pin`, so the display survives every\ndisconnect — free it with `POST /display/release` (which force-releases `Pinned` exactly like\na `Lingering` display). This is what the `gaming-rig` preset selects.",
|
||||
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
@@ -6187,7 +6161,6 @@
|
||||
},
|
||||
"positions": {
|
||||
"type": "object",
|
||||
"description": "Keys are the **canonical decimal** identity-slot id (`\"1\"`..`\"15\"`) — the exact string\n`arrange` looks a member up by. [`DisplayPolicy::sanitized`] re-canonicalizes them on write\n(`\"01\"` → `\"1\"`) and drops anything that is not a slot id, because a key that never matches is\na pin the operator can see in the console and in `GET /display/settings` while every session\nsilently auto-rows past it.",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Position"
|
||||
},
|
||||
@@ -6199,7 +6172,7 @@
|
||||
},
|
||||
"LayoutMode": {
|
||||
"type": "string",
|
||||
"description": "How group members are arranged in the desktop coordinate space, resolved by `layout::arrange` —\nwhich both the `/display/state` readout and (on Linux, KWin only) the per-backend position apply\nconsume, so the answer is computed in exactly one place.",
|
||||
"description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
|
||||
"enum": [
|
||||
"auto-row",
|
||||
"manual"
|
||||
@@ -6359,7 +6332,7 @@
|
||||
},
|
||||
"ModeConflict": {
|
||||
"type": "string",
|
||||
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Enforced by [`super::admission`] before the Welcome is sent, so a `reject` is a\nclean handshake error rather than a half-built session.",
|
||||
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
|
||||
"enum": [
|
||||
"separate",
|
||||
"steal",
|
||||
@@ -6896,13 +6869,6 @@
|
||||
"type": "string",
|
||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -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 |
@@ -144,10 +144,6 @@ dependencies {
|
||||
testImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
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")
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
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.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.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,
|
||||
) {
|
||||
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 = 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 “$deviceName”. 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
|
||||
|
||||
@@ -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
|
||||
@@ -194,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).
|
||||
@@ -308,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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -633,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
|
||||
@@ -718,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 ->
|
||||
@@ -783,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
|
||||
@@ -839,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(
|
||||
@@ -921,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() },
|
||||
)
|
||||
}
|
||||
@@ -933,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"}"
|
||||
}
|
||||
|
||||
@@ -946,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
|
||||
@@ -958,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
|
||||
@@ -968,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
|
||||
@@ -978,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,15 +11,12 @@ import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
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
|
||||
@@ -43,15 +39,11 @@ 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.Sc2Capture
|
||||
@@ -63,145 +55,10 @@ 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
|
||||
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 },
|
||||
// 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
|
||||
fun ConsoleControllersScreen(gamepadSetting: Int, 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)
|
||||
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 },
|
||||
// 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,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
@@ -227,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
|
||||
@@ -259,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)
|
||||
@@ -302,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
|
||||
@@ -402,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)
|
||||
@@ -876,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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -17,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
|
||||
@@ -43,6 +45,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -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
|
||||
@@ -432,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,
|
||||
@@ -449,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,
|
||||
@@ -471,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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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,6 @@ 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
|
||||
@@ -94,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)
|
||||
@@ -111,14 +99,6 @@ 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) }
|
||||
|
||||
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||
state = LibState.Loading
|
||||
@@ -147,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()
|
||||
@@ -169,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
|
||||
@@ -177,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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -203,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(
|
||||
@@ -219,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(
|
||||
@@ -294,18 +262,10 @@ private 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(
|
||||
@@ -327,22 +287,10 @@ private 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 —
|
||||
@@ -401,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,
|
||||
) {
|
||||
@@ -422,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
|
||||
@@ -143,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.
|
||||
@@ -617,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 }
|
||||
@@ -709,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
|
||||
|
||||
@@ -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,9 +28,6 @@ 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
|
||||
@@ -56,7 +53,6 @@ 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
|
||||
@@ -156,35 +152,6 @@ 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
|
||||
@@ -194,8 +161,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// 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
|
||||
@@ -218,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) {
|
||||
@@ -401,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
|
||||
@@ -426,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
|
||||
@@ -552,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
|
||||
@@ -697,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
|
||||
@@ -900,42 +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 mic badge, 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.
|
||||
if (hasTouch && touchMode != TouchMode.TOUCH) 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.
|
||||
@@ -1142,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)
|
||||
}
|
||||
}
|
||||
@@ -83,16 +83,6 @@ class ScreenshotTest {
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamNormal() = shootRoot("stream-normal") { 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") { StreamBannerScene(pad = true) }
|
||||
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) }
|
||||
|
||||
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
|
||||
@Test
|
||||
fun connecting() = shootScreen("connecting") {
|
||||
@@ -124,59 +114,6 @@ class ScreenshotTest {
|
||||
fun consoleSettingsLight() =
|
||||
shootRoot("console-settings-light") { 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") { 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") { ConsoleHomeScene() }
|
||||
|
||||
@Test
|
||||
fun consoleHomeLight() = shootRoot("console-home-light") { 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") { 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") { 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") { ConsoleLicensesScene() }
|
||||
|
||||
@Test
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -32,13 +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 io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
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
|
||||
@@ -47,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
|
||||
@@ -247,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),
|
||||
@@ -433,111 +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 scene renders its deterministic
|
||||
* "nothing connected" state.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@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
|
||||
|
||||
@@ -46,8 +46,8 @@ 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.
|
||||
*/
|
||||
@@ -149,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
|
||||
@@ -218,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
|
||||
@@ -246,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) {
|
||||
@@ -640,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
|
||||
|
||||
@@ -666,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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The router's instant button chords — mic mute (Select + Y) and the stats-tier cycle
|
||||
* (Select + X) — pinned at the one place a JVM test can reach them: [GamepadRouter.completesChord],
|
||||
* the shared edge rule both fire on. A [GamepadRouter] itself needs an InputManager, a main Looper
|
||||
* and live InputDevices behind it, so driving real KeyEvents through it is not a unit test; the
|
||||
* rule below is the whole of what those two `if`s decide.
|
||||
*
|
||||
* The Apple client pins the same chords from its own side (`GamepadStatsChordTests`), and the two
|
||||
* suites exist for the same reason: a chord that stops completing fails INVISIBLY — the buttons
|
||||
* still reach the game, nothing logs, and the couch simply finds that a shortcut it was told about
|
||||
* does nothing. On a TV the stats chord is the only route to the overlay at all.
|
||||
*/
|
||||
class GamepadChordTest {
|
||||
|
||||
/** The two chords `slotButton` tests on every press, in the order it tests them. */
|
||||
private val instantChords = listOf(GamepadRouter.MIC_CHORD, GamepadRouter.STATS_CHORD)
|
||||
|
||||
/**
|
||||
* One pad's held-button set, driven exactly the way `slotButton` drives a slot's: the chord
|
||||
* test reads the state from BEFORE the press, then the bit joins `held`. [press] returns the
|
||||
* chords that completed on it — an empty list means the press was silent.
|
||||
*/
|
||||
private inner class Pad {
|
||||
var held = 0
|
||||
private set
|
||||
|
||||
fun press(bit: Int): List<Int> {
|
||||
val wasHeld = held
|
||||
held = held or bit
|
||||
return instantChords.filter { GamepadRouter.completesChord(wasHeld, bit, it) }
|
||||
}
|
||||
|
||||
/** An auto-repeat DOWN: Android re-delivers a held button, so `wasHeld` already has it. */
|
||||
fun repeat(bit: Int): List<Int> = press(bit)
|
||||
|
||||
fun release(bit: Int) {
|
||||
held = held and bit.inv()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select + X, the same pair as the Apple client's `GamepadCapture.statsChord`
|
||||
* (`GamepadWire.back | GamepadWire.x`). A per-platform shortcut is worse than none, so the
|
||||
* literal bits are spelled out here rather than derived from the constant under test.
|
||||
*/
|
||||
@Test
|
||||
fun `the stats chord is Select plus X`() {
|
||||
assertEquals(0x0020 or 0x4000, GamepadRouter.STATS_CHORD)
|
||||
assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_X, GamepadRouter.STATS_CHORD)
|
||||
assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_Y, GamepadRouter.MIC_CHORD)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three chords must not be reachable through one another: pressing toward the exit chord
|
||||
* may not cycle the overlay or mute the mic on the way, and neither instant chord may arm a
|
||||
* disconnect. Select is the one button they share by design — everything else is disjoint, and
|
||||
* no chord is a subset of another (a subset would complete whenever its superset did).
|
||||
*/
|
||||
@Test
|
||||
fun `the chords meet only on Select`() {
|
||||
val chords = mapOf(
|
||||
"exit" to GamepadRouter.EXIT_CHORD,
|
||||
"mic" to GamepadRouter.MIC_CHORD,
|
||||
"stats" to GamepadRouter.STATS_CHORD,
|
||||
)
|
||||
for ((aName, a) in chords) {
|
||||
for ((bName, b) in chords) {
|
||||
if (aName == bName) continue
|
||||
assertEquals("$aName and $bName share a button other than Select", Gamepad.BTN_BACK, a and b)
|
||||
assertNotEquals("$aName is a subset of $bName", a and b, a)
|
||||
assertNotEquals("$bName is a subset of $aName", a and b, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One cycle per chord, not one per press: the completing button fires it, an auto-repeat of
|
||||
* that same button does not, and a third button pressed on top of the held chord finds the mask
|
||||
* already complete.
|
||||
*/
|
||||
@Test
|
||||
fun `the chord fires once, on the button that completes it`() {
|
||||
val pad = Pad()
|
||||
assertEquals("Select alone is not a chord", emptyList<Int>(), pad.press(Gamepad.BTN_BACK))
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
|
||||
assertEquals("auto-repeat re-fired the chord", emptyList<Int>(), pad.repeat(Gamepad.BTN_X))
|
||||
assertEquals("a press on top re-fired the chord", emptyList<Int>(), pad.press(Gamepad.BTN_A))
|
||||
assertEquals(emptyList<Int>(), pad.press(Gamepad.BTN_B))
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifting either member re-arms the chord — pressing it again is a fresh completion. Both
|
||||
* directions matter: a couch user cycling tiers taps X with Select still down, and one who
|
||||
* lifted Select instead taps Select again with X still down.
|
||||
*/
|
||||
@Test
|
||||
fun `either member re-arms the chord when released`() {
|
||||
val pad = Pad()
|
||||
pad.press(Gamepad.BTN_BACK)
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
|
||||
pad.release(Gamepad.BTN_X)
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
|
||||
pad.release(Gamepad.BTN_BACK)
|
||||
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_BACK))
|
||||
}
|
||||
|
||||
/** A partial mask fires nothing — either member alone, or with a non-member alongside it. */
|
||||
@Test
|
||||
fun `a partial chord never fires`() {
|
||||
for (opening in listOf(Gamepad.BTN_BACK, Gamepad.BTN_X, Gamepad.BTN_Y)) {
|
||||
val pad = Pad()
|
||||
assertEquals(emptyList<Int>(), pad.press(opening))
|
||||
for (other in listOf(Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_LB, Gamepad.BTN_DPAD_UP)) {
|
||||
assertEquals(emptyList<Int>(), pad.press(other))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walking into the exit chord (Select + Start + L1 + R1, in any order) must pass through
|
||||
* neither instant chord: the disconnect hold is the one gesture where a stray mute or a
|
||||
* changed overlay would land while the user is looking at the "hold to quit" hint.
|
||||
*/
|
||||
@Test
|
||||
fun `reaching the exit chord fires nothing on the way`() {
|
||||
val exit = listOf(Gamepad.BTN_BACK, Gamepad.BTN_START, Gamepad.BTN_LB, Gamepad.BTN_RB)
|
||||
for (order in exit.permutations()) {
|
||||
val pad = Pad()
|
||||
for (bit in order) {
|
||||
assertEquals("$order fired a chord at $bit", emptyList<Int>(), pad.press(bit))
|
||||
}
|
||||
assertEquals(GamepadRouter.EXIT_CHORD, pad.held)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* X and Y held, then Select: ONE press completes BOTH chords. That is the honest reading of
|
||||
* "the button that completes the mask", it is what the Apple client does too, and the
|
||||
* alternative — first match wins — would make the same press mean different things depending
|
||||
* on which chord the router happened to test first. Pinned so the behaviour is a decision
|
||||
* rather than a surprise; both outcomes are visible and reversible on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `a shared Select can complete both chords at once`() {
|
||||
val pad = Pad()
|
||||
pad.press(Gamepad.BTN_X)
|
||||
pad.press(Gamepad.BTN_Y)
|
||||
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
|
||||
}
|
||||
|
||||
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
|
||||
@Test
|
||||
fun `chord masks are wire button bits`() {
|
||||
for (chord in instantChords + GamepadRouter.EXIT_CHORD) {
|
||||
assertTrue("chord $chord has no bits", chord != 0)
|
||||
assertEquals("a chord bit is not a known BTN_*", chord, chord and ALL_BUTTONS)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Every button bit `Gamepad` defines — the universe a chord may draw from. */
|
||||
val ALL_BUTTONS = listOf(
|
||||
Gamepad.BTN_DPAD_UP, Gamepad.BTN_DPAD_DOWN, Gamepad.BTN_DPAD_LEFT, Gamepad.BTN_DPAD_RIGHT,
|
||||
Gamepad.BTN_START, Gamepad.BTN_BACK, Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK,
|
||||
Gamepad.BTN_LB, Gamepad.BTN_RB, Gamepad.BTN_GUIDE,
|
||||
Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_X, Gamepad.BTN_Y,
|
||||
Gamepad.BTN_PADDLE1, Gamepad.BTN_PADDLE2, Gamepad.BTN_PADDLE3, Gamepad.BTN_PADDLE4,
|
||||
Gamepad.BTN_TOUCHPAD, Gamepad.BTN_MISC1,
|
||||
).fold(0) { acc, bit -> acc or bit }
|
||||
|
||||
/** Every ordering of a chord's buttons — presses arrive in whatever order the hands do. */
|
||||
fun <T> List<T>.permutations(): List<List<T>> =
|
||||
if (size <= 1) listOf(this)
|
||||
else flatMap { head -> (this - head).permutations().map { listOf(head) + it } }
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,10 @@ struct OutputReady {
|
||||
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
|
||||
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
|
||||
enum DecodeEvent {
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
|
||||
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
|
||||
/// reassembler's later `frames_dropped` climb for the loss (the feeder already fired the RFI
|
||||
/// request).
|
||||
Au(Frame, u32),
|
||||
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
|
||||
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
|
||||
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
|
||||
Au(Frame, bool),
|
||||
/// An input buffer slot freed (index) — we can queue an AU into it.
|
||||
InputAvailable(usize),
|
||||
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
|
||||
@@ -605,11 +603,7 @@ fn feeder_loop(
|
||||
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
|
||||
// counting AUs.
|
||||
let au_first = frame.part.is_none_or(|p| p.first);
|
||||
let gap = if au_first {
|
||||
client.note_frame_index(frame.frame_index)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let gap = au_first && client.note_frame_index(frame.frame_index);
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
@@ -697,12 +691,9 @@ fn dispatch_event(
|
||||
match ev {
|
||||
DecodeEvent::Au(f, gap) => {
|
||||
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo). Credited arm: the gap width
|
||||
// pre-covers the reassembler's ~120 ms-later `frames_dropped` climb for the same loss,
|
||||
// so a fast RFI anchor that heals in between isn't re-frozen by it (the double-arm
|
||||
// race — see `ReanchorGate::arm_expecting_drops`).
|
||||
if gap > 0 {
|
||||
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
|
||||
// fold `on_decoded` (keyed by the pts the codec will echo).
|
||||
if gap {
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
// One entry per AU (parts share the pts): the completing delivery carries it.
|
||||
if f.complete {
|
||||
|
||||
@@ -222,13 +222,8 @@ pub(super) fn run_sync(
|
||||
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
|
||||
// arms the freeze gate so the decoder's concealment is held off the screen until the
|
||||
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
|
||||
// Credited arm: the gap width pre-covers the reassembler's ~120 ms-later
|
||||
// `frames_dropped` climb for the same loss, so a fast RFI anchor that heals in
|
||||
// between isn't re-frozen by it (the double-arm race — see
|
||||
// `ReanchorGate::arm_expecting_drops`).
|
||||
let gap = client.note_frame_index(frame.frame_index);
|
||||
if gap > 0 {
|
||||
gate.arm_expecting_drops(Instant::now(), u64::from(gap));
|
||||
if client.note_frame_index(frame.frame_index) {
|
||||
gate.arm(Instant::now());
|
||||
}
|
||||
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
|
||||
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
|
||||
|
||||
@@ -200,7 +200,7 @@ fn resolve(info: &ResolvedService) -> Option<Host> {
|
||||
/// hold the Wi-Fi `MulticastLock` for the browse lifetime.
|
||||
///
|
||||
/// [`nativeDiscoveryStop`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -214,7 +214,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoverySt
|
||||
/// `NativeBridge.nativeDiscoveryPoll(handle): String` — the current resolved-host snapshot,
|
||||
/// newline-joined records of `key␟name␟addr␟port␟fp␟pair␟mac␟os` (`␟` = U+001F). Empty string = no hosts /
|
||||
/// `0` handle. Poll ~1 Hz from the UI thread (cheap: a mutex lock + string build).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -245,7 +245,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPo
|
||||
///
|
||||
/// [`nativeDiscoveryStart`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStart
|
||||
/// [`nativeDiscoveryPoll`]: Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryPoll
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDiscoveryStop(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
|
||||
@@ -60,7 +60,7 @@ const TAG_HID_RAW: u8 = 0x05;
|
||||
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
|
||||
/// back to the controller holding that wire `pad` index (multi-pad rumble). Run from a Kotlin
|
||||
/// poll thread.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -99,7 +99,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
|
||||
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
|
||||
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
|
||||
@@ -54,7 +54,7 @@ mod probe;
|
||||
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
|
||||
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn JNI_OnLoad(
|
||||
_vm: *mut jni::sys::JavaVM,
|
||||
_reserved: *mut std::ffi::c_void,
|
||||
@@ -74,7 +74,7 @@ pub extern "system" fn JNI_OnLoad(
|
||||
/// `NativeBridge.abiVersion(): Int` — the core's C-ABI version. A non-error return is the
|
||||
/// scaffold's proof that `System.loadLibrary` found the `.so`, the JNI symbol resolved, and the
|
||||
/// linked `punktfunk-core` is the one we expect.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -83,7 +83,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_abiVersion(
|
||||
}
|
||||
|
||||
/// `NativeBridge.coreVersion(): String` — the crate version, proving JNI string marshaling works.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::time::Duration;
|
||||
/// `NativeBridge.nativeProbe(host, port, timeoutMs): Boolean` — true if `host:port` completed a
|
||||
/// QUIC handshake within `timeoutMs`. No pin/identity presented (trust-agnostic), mDNS-independent.
|
||||
/// Blocking (builds its own runtime) — Kotlin runs it on `Dispatchers.IO`, never the main thread.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbe<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -40,7 +40,7 @@ fn client(handle: jlong) -> Option<&'static SessionHandle> {
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipSupported(handle)` — the host advertised `HOST_CAP_CLIPBOARD`.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupported(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -53,7 +53,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipSupport
|
||||
|
||||
/// `NativeBridge.nativeClipControl(handle, enabled)` — session-level opt-in/out. Nothing
|
||||
/// clipboard-related happens on either side until an `enabled: true` crosses.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -68,7 +68,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipControl
|
||||
/// `NativeBridge.nativeClipOfferText(handle, seq)` — announce "the Android clipboard now holds
|
||||
/// text" (format list only; bytes cross when the host fetches). `seq` is Kotlin's monotonic
|
||||
/// counter, newest wins.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferText(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -88,7 +88,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipOfferTe
|
||||
|
||||
/// `NativeBridge.nativeClipFetchText(handle, seq)` — pull the text of the host's offer `seq`.
|
||||
/// Returns the transfer id echoed on the matching `data:`/`error:` event, or −1.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchText(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -106,7 +106,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipFetchTe
|
||||
|
||||
/// `NativeBridge.nativeClipServeText(handle, reqId, text)` — answer a `fetch:` event with the
|
||||
/// clipboard's current text (the host is pasting our offer).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeText(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -125,7 +125,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipServeTe
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeClipCancel(handle, id)` — abort a transfer (either direction).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -144,7 +144,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClipCancel(
|
||||
/// Text payloads ride `data:<xfer_id>:<text>` decoded lossily — safe because the phase-0
|
||||
/// clipboard task delivers a whole payload in ONE event (`last = true`), so a chunk boundary
|
||||
/// can never split a UTF-8 sequence.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextClip(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
|
||||
@@ -9,7 +9,7 @@ use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{hex32, jni_guard, lock_recover, parse_hex32, SessionHandle};
|
||||
use super::{hex32, jni_guard, parse_hex32, SessionHandle};
|
||||
|
||||
/// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared)
|
||||
/// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old
|
||||
@@ -36,12 +36,12 @@ fn note_error(e: &punktfunk_core::error::PunktfunkError) {
|
||||
/// `NativeBridge.nativeTakeLastError(): String` — the machine token of the most recent failed
|
||||
/// `nativeConnect`/`nativePair`, cleared on read (`""` when none). Call right after a `0`
|
||||
/// handle / `""` fingerprint.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastError<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
) -> jni::sys::jstring {
|
||||
let token = std::mem::take(&mut *lock_recover(&LAST_ERROR));
|
||||
let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap());
|
||||
match env.new_string(token) {
|
||||
Ok(s) => s.into_raw(),
|
||||
Err(_) => JObject::null().into_raw(),
|
||||
@@ -51,7 +51,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastErr
|
||||
/// `NativeBridge.nativeGenerateIdentity(): String` — mint a fresh persistent self-signed identity.
|
||||
/// Returns `"<certPem>\n-----PUNKTFUNK-KEY-----\n<keyPem>"`, or `""` on failure (logged). Kotlin
|
||||
/// persists it (Keystore-wrapped) and only calls this again when the store is genuinely empty.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIdentity<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -74,7 +74,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
|
||||
/// the media sockets. Must be called BEFORE `nativeConnect` (the tag is applied at socket
|
||||
/// creation); Kotlin's one connect choke point (`HostConnect.connectToHost`) does. The rest of the
|
||||
/// toggle rides explicit per-session parameters (`nativeStartVideo` / `nativeStartAudio`).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLatencyMode(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -120,7 +120,7 @@ fn force_parts_sysprop() -> bool {
|
||||
/// budget: the normal path passes a short value, the no-PIN "request access" path a long one (≥ the
|
||||
/// host's approval-park window) so a slow operator approval lands on this same parked connection
|
||||
/// rather than timing the client out first. Returns an opaque handle, or 0 on failure.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
@@ -322,7 +322,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||
/// closed exactly once and not concurrently with other calls on the same handle (Kotlin owns this).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -344,7 +344,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
/// # Safety contract
|
||||
/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`],
|
||||
/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -363,7 +363,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQ
|
||||
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
||||
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
||||
/// connect. `""` on a `0` handle.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerprint<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -388,7 +388,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostFingerp
|
||||
/// Kotlin's stream watchdog polls this (~1 Hz) to leave a dead stream and return to the menu (where
|
||||
/// the user can Wake-on-LAN the host) instead of stranding them on a frozen frame. `false` on a `0`
|
||||
/// handle. Cheap (one atomic load); safe on the UI thread.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnded(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -413,7 +413,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
|
||||
/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for
|
||||
/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one
|
||||
/// atomic load); safe on the UI thread.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -433,7 +433,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
/// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint
|
||||
/// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns
|
||||
/// `""` (logged). Blocking — Kotlin calls it off the UI thread.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
|
||||
@@ -35,7 +35,7 @@ fn send_event(handle: jlong, kind: InputKind, code: u32, x: i32, y: i32, flags:
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPointerMove(handle, dx, dy)` — relative mouse motion (screen +y down).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerMove(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -51,7 +51,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
/// normalizing against the size packed into `flags` as `(w << 16) | h` and mapping into the output
|
||||
/// region (it drops the event if that size is zero). This is the touch "direct pointing" path — the
|
||||
/// cursor jumps to the finger — and matches the Apple client's absolute touch forwarding.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerAbs(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -68,7 +68,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
|
||||
/// `NativeBridge.nativeSendPointerButton(handle, button, down)` — one button transition.
|
||||
/// `button`: GameStream id (1=left, 2=middle, 3=right, 4=X1, 5=X2). `down`: 1=press, 0=release.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointerButton(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -86,7 +86,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPointer
|
||||
|
||||
/// `NativeBridge.nativeSendScroll(handle, axis, delta)` — one scroll step. `axis`: 0=vertical,
|
||||
/// 1=horizontal. `delta`: signed, WHEEL_DELTA(120)-scaled, +=up/right.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -103,7 +103,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
|
||||
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
|
||||
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
|
||||
/// (libei touchscreen / wlroots / SendInput).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -128,7 +128,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
|
||||
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
|
||||
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
|
||||
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -151,7 +151,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
|
||||
/// `NativeBridge.nativeTextInputSupported(handle)` — whether the host advertised
|
||||
/// `HOST_CAP_TEXT_INPUT` (its inject backend types committed text), so the Kotlin side can pick
|
||||
/// the real IME `InputConnection` over the TYPE_NULL raw-key fallback. `0` handle → false.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -168,7 +168,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSu
|
||||
/// `NativeBridge.nativeHostSupportsPen(handle)` — the host advertised `HOST_CAP_PEN`, so the
|
||||
/// Kotlin side splits stylus pointers out of the touch path onto the pen plane
|
||||
/// (design/pen-tablet-input.md §7). `0` handle → false.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupportsPen(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -197,7 +197,7 @@ const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
|
||||
/// normalized 0..1; `distance`/`tilt_deg`/`azimuth_deg`/`roll_deg` < 0 = unknown. Call only
|
||||
/// against a [`nativeHostSupportsPen`] host; the client heartbeats the last sample ≤100 ms
|
||||
/// while in range (Kotlin side — see `StylusStream`).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -264,7 +264,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
|
||||
/// Unicode scalar (`code` = the scalar; multi-char commits are consecutive events in order).
|
||||
/// Control characters are skipped — Enter/Backspace/Tab ride the VK key path. Call only when
|
||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeTextInputSupported`] returned true.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -296,7 +296,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendText(
|
||||
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
|
||||
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
|
||||
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -318,7 +318,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
|
||||
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
|
||||
/// (−32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -343,7 +343,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
|
||||
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
|
||||
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -373,7 +373,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
///
|
||||
/// A `0` handle answers `true` — "don't suppress" is the safe answer when we cannot tell, matching
|
||||
/// the `Auto` rule inside the predicate itself.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -399,7 +399,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionRe
|
||||
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
|
||||
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
|
||||
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -415,7 +415,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
|
||||
/// `len` bytes are the report, id byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …);
|
||||
/// `len` is clamped to the 64-byte wire body. Called from the capture thread at the controller's
|
||||
/// own report rate (~250–500 Hz) — the direct-buffer read avoids a JNI array copy per report.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidReport(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -455,7 +455,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
|
||||
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
|
||||
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
|
||||
/// the capture diffs, the host holds per-slot state.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -485,7 +485,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouc
|
||||
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
|
||||
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
|
||||
/// controller's report rate.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
|
||||
_env: JNIEnv,
|
||||
|
||||
@@ -45,15 +45,6 @@ pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
|
||||
})
|
||||
}
|
||||
|
||||
/// Poison-recovering lock for the JNI entry points that are NOT behind [`jni_guard`]: a
|
||||
/// `.lock().unwrap()` there turns a poisoned mutex into a panic across the `extern "system"`
|
||||
/// boundary — an abort of the whole app on Rust ≥ 1.81 (the panic-in-extern grep gate's class).
|
||||
/// The slots behind these mutexes are plane-thread handles and last-value caches; whatever a
|
||||
/// poisoned writer left is still valid to inspect or replace.
|
||||
pub(crate) fn lock_recover<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
|
||||
pub(crate) struct SessionHandle {
|
||||
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
|
||||
|
||||
@@ -8,7 +8,7 @@ use jni::objects::JString;
|
||||
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
|
||||
use jni::JNIEnv;
|
||||
|
||||
use super::{jni_guard, lock_recover, SessionHandle};
|
||||
use super::{jni_guard, SessionHandle};
|
||||
|
||||
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
|
||||
/// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow`
|
||||
@@ -19,7 +19,7 @@ use super::{jni_guard, lock_recover, SessionHandle};
|
||||
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
|
||||
/// No-op if already started.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
mut env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -48,7 +48,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
.filter(|s| !s.is_empty());
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = lock_recover(&h.video);
|
||||
let mut guard = h.video.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return; // already streaming
|
||||
}
|
||||
@@ -91,7 +91,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
|
||||
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
|
||||
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -116,7 +116,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'
|
||||
/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap;
|
||||
/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -140,7 +140,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecL
|
||||
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
|
||||
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
|
||||
/// device).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
@@ -161,7 +161,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecode
|
||||
|
||||
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
|
||||
/// session). No-op on `0`.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -210,7 +210,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
/// Poll ~1 Hz from the UI; each call
|
||||
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
|
||||
/// the host build too (Kotlin only ever calls it on device).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -222,7 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
if lock_recover(&h.video).is_none() {
|
||||
if h.video.lock().unwrap().is_none() {
|
||||
return std::ptr::null_mut(); // not streaming → no stats
|
||||
}
|
||||
let snap = h
|
||||
@@ -312,7 +312,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
|
||||
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
|
||||
/// build too. Cheap; safe on the UI thread.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -346,7 +346,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
|
||||
/// session (survives video stop/start across surface recreation). No-op on `0`. Not android-gated —
|
||||
/// pure `jni` + an atomic store, so it links on the host build too.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoStatsEnabled(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -373,7 +373,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
|
||||
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
|
||||
/// streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -385,7 +385,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = lock_recover(&h.audio);
|
||||
let mut guard = h.audio.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return; // already playing
|
||||
}
|
||||
@@ -398,7 +398,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
|
||||
/// `NativeBridge.nativeStopAudio(handle)` — stop + join the audio thread and close AAudio (without
|
||||
/// closing the session). No-op on `0`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -422,7 +422,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
/// the running capture's id. Caller MUST hold RECORD_AUDIO; a failure (e.g. no permission) leaves
|
||||
/// the rest of the session streaming.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -434,7 +434,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mut guard = lock_recover(&h.mic);
|
||||
let mut guard = h.mic.lock().unwrap();
|
||||
if let Some(m) = guard.as_ref() {
|
||||
return m.session_id(); // already capturing — same stream, same session
|
||||
}
|
||||
@@ -457,7 +457,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic(
|
||||
/// stream (without closing the session). No-op on `0`. Leaves the session's mute state alone: a
|
||||
/// surface recreate stops and restarts the mic, and a user who muted must stay muted through it.
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -484,7 +484,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
|
||||
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
|
||||
/// app-side fix worth blocking a session on.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
@@ -516,7 +516,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*lock_recover(&h.pad_audio) = Some(p);
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
@@ -530,7 +530,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
|
||||
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
|
||||
/// never reveal that the client handed the renderer a descriptor something else was already
|
||||
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
@@ -553,7 +553,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSel
|
||||
///
|
||||
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
|
||||
/// `UsbDeviceConnection` as soon as this returns and not before.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
@@ -594,7 +594,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi
|
||||
/// One honest consequence of keeping the stream open: the platform's own recording indicator stays
|
||||
/// lit while muted, because the mic really is still open. What stops is the encode and the send —
|
||||
/// no captured audio leaves the process.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -617,7 +617,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetMicMuted
|
||||
/// refused every AAudio input rung (or a missing RECORD_AUDIO grant) shows no control instead of a
|
||||
/// lie about a mic that is being heard. `false` on a `0` handle. Cheap (one uncontended lock).
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -629,6 +629,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive(
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
jboolean::from(lock_recover(&h.mic).is_some())
|
||||
jboolean::from(h.mic.lock().unwrap().is_some())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const PROBE_RESULT_LEN: usize = 6;
|
||||
/// **briefly pausing video**. Non-blocking: poll
|
||||
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] until its `done` element is 1.
|
||||
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
@@ -54,7 +54,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
|
||||
///
|
||||
/// Layout (doubles so one array carries both the counts and the percentages):
|
||||
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -10,7 +10,7 @@ use jni::JNIEnv;
|
||||
/// magic packet. `macsCsv` is comma-separated MACs (`aa:bb:..,cc:dd:..`, learned from the host's
|
||||
/// mDNS `mac` TXT while it was online); `lastIp` is the host's last-known IPv4 (or empty).
|
||||
/// Returns true if at least one datagram went out.
|
||||
#[unsafe(no_mangle)]
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeWakeOnLan<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_this: JObject<'local>,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "glur",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/joogps/Glur.git",
|
||||
"state" : {
|
||||
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -16,17 +16,6 @@ let package = Package(
|
||||
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
|
||||
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
||||
],
|
||||
dependencies: [
|
||||
// Progressive (gradient) backdrop blur for the form screens' trays — a real blur with no
|
||||
// material tint stage (see GamepadTrayBlur). Pinned by REVISION, not `from:`: the
|
||||
// GlurBackdrop product exists only on main — no release carries it (the newest tag,
|
||||
// `1.1`, predates it, and is not three-component semver anyway, so version-based
|
||||
// resolution stops at 1.0.4). The revision is main's head at adoption time; a revision
|
||||
// pin stays reproducible when the branch moves.
|
||||
.package(
|
||||
url: "https://github.com/joogps/Glur.git",
|
||||
revision: "ba4f05d3c9a608ec773b9305f2af6089390de68a"),
|
||||
],
|
||||
targets: [
|
||||
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
||||
// No dependencies by design — an extension process links this alone.
|
||||
@@ -50,10 +39,6 @@ let package = Package(
|
||||
// licensing in that README). `.process` compiles the catalog; loaded via
|
||||
// OsIcon.swift.
|
||||
.process("Resources/OsIcons.xcassets"),
|
||||
// The launcher tiles' brand marks (template vector imagesets generated from the
|
||||
// assets/launcher-icons masters by scripts/gen-launcher-icons.sh — per-mark
|
||||
// provenance and licensing in that README). Loaded via LauncherIcon.swift.
|
||||
.process("Resources/LauncherIcons.xcassets"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
@@ -66,12 +51,7 @@ let package = Package(
|
||||
// (The tvOS slide-transition package is referenced by the Xcode PROJECT only —
|
||||
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
||||
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
||||
.executableTarget(
|
||||
name: "PunktfunkClient",
|
||||
dependencies: [
|
||||
"PunktfunkKit",
|
||||
.product(name: "GlurBackdrop", package: "Glur"),
|
||||
]),
|
||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
||||
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||
.testTarget(
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||
EE0000000000000000000012 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000002 /* Glur */; };
|
||||
EE0000000000000000000013 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000003 /* GlurBackdrop */; };
|
||||
EE0000000000000000000014 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000004 /* Glur */; };
|
||||
EE0000000000000000000015 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000005 /* GlurBackdrop */; };
|
||||
EE0000000000000000000016 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000006 /* Glur */; };
|
||||
EE0000000000000000000017 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000007 /* GlurBackdrop */; };
|
||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
@@ -94,8 +88,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
EE0000000000000000000012 /* Glur in Frameworks */,
|
||||
EE0000000000000000000013 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -104,8 +96,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
EE0000000000000000000014 /* Glur in Frameworks */,
|
||||
EE0000000000000000000015 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -115,8 +105,6 @@
|
||||
files = (
|
||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */,
|
||||
EE0000000000000000000016 /* Glur in Frameworks */,
|
||||
EE0000000000000000000017 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -187,8 +175,6 @@
|
||||
name = Punktfunk;
|
||||
packageProductDependencies = (
|
||||
AA0000000000000000000006 /* PunktfunkKit */,
|
||||
EE0000000000000000000002 /* Glur */,
|
||||
EE0000000000000000000003 /* GlurBackdrop */,
|
||||
);
|
||||
productName = Punktfunk;
|
||||
productReference = AA0000000000000000000001 /* Punktfunk.app */;
|
||||
@@ -215,8 +201,6 @@
|
||||
name = "Punktfunk-iOS";
|
||||
packageProductDependencies = (
|
||||
BB0000000000000000000006 /* PunktfunkKit */,
|
||||
EE0000000000000000000004 /* Glur */,
|
||||
EE0000000000000000000005 /* GlurBackdrop */,
|
||||
);
|
||||
productName = "Punktfunk-iOS";
|
||||
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
|
||||
@@ -242,8 +226,6 @@
|
||||
packageProductDependencies = (
|
||||
CC0000000000000000000006 /* PunktfunkKit */,
|
||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */,
|
||||
EE0000000000000000000006 /* Glur */,
|
||||
EE0000000000000000000007 /* GlurBackdrop */,
|
||||
);
|
||||
productName = "Punktfunk-tvOS";
|
||||
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
||||
@@ -301,7 +283,6 @@
|
||||
packageReferences = (
|
||||
AA000000000000000000000F /* XCLocalSwiftPackageReference "." */,
|
||||
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */,
|
||||
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = AA0000000000000000000008 /* Products */;
|
||||
@@ -867,14 +848,6 @@
|
||||
minimumVersion = 0.18.0;
|
||||
};
|
||||
};
|
||||
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/joogps/Glur.git";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = ba4f05d3c9a608ec773b9305f2af6089390de68a;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -895,36 +868,6 @@
|
||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||
productName = SwiftUINavigationTransitions;
|
||||
};
|
||||
EE0000000000000000000002 /* Glur */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = Glur;
|
||||
};
|
||||
EE0000000000000000000003 /* GlurBackdrop */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = GlurBackdrop;
|
||||
};
|
||||
EE0000000000000000000004 /* Glur */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = Glur;
|
||||
};
|
||||
EE0000000000000000000005 /* GlurBackdrop */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = GlurBackdrop;
|
||||
};
|
||||
EE0000000000000000000006 /* Glur */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = Glur;
|
||||
};
|
||||
EE0000000000000000000007 /* GlurBackdrop */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = GlurBackdrop;
|
||||
};
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkShared;
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
{
|
||||
"originHash" : "bb1ce9bc6042f166bd0aad78a15081e673781d3a90fa52fd8ec8a08875878ef6",
|
||||
"originHash" : "5d17a752eb57d190a90cbd663718ff44034b24fe0ae1baafea7677db2d49da6f",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "glur",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/joogps/Glur.git",
|
||||
"state" : {
|
||||
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "objc-runtime-tools",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
// Configurable Home-Screen / Lock-Screen library widget (kind "PunktfunkLibrary"). The user picks
|
||||
// a saved host in the widget's configuration (long-press → Edit Widget — the picker is
|
||||
// `HostEntity`'s query over the shared App-Group store, running in this extension process); a tap
|
||||
// deep-links into that host's game library via `punktfunk://browse/<uuid>` — the app's onOpenURL
|
||||
// routes it to the same library presentation every internal surface drives. No session starts
|
||||
// until a title is picked there.
|
||||
//
|
||||
// Unconfigured, it follows the most recently connected host (the same order the hosts widget
|
||||
// leads with). A configured host that no longer exists shows the empty state rather than silently
|
||||
// following a different host — a widget that says "Studio" must never open someone else's library.
|
||||
//
|
||||
// Timeline is a single `.never` entry — the app pushes reloads on store changes (HostStore →
|
||||
// WidgetCenter.reloadTimelines), exactly like the hosts widget.
|
||||
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
import PunktfunkShared
|
||||
|
||||
// MARK: - Configuration intent
|
||||
|
||||
/// The widget's per-instance configuration. Executes in the EXTENSION process — which is why
|
||||
/// `HostEntity` and its query live in PunktfunkShared, not the app.
|
||||
struct LibraryWidgetConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "Choose Host"
|
||||
static let description = IntentDescription("Pick whose game library this widget opens.")
|
||||
|
||||
@Parameter(title: "Host", description: "Leave empty to follow your most recent host.")
|
||||
var host: HostEntity?
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
struct LibraryEntry: TimelineEntry {
|
||||
let date: Date
|
||||
/// The resolved target: the configured host if it still exists, the most recent one when
|
||||
/// unconfigured, nil when there's nothing to open (empty store, or a removed configured host).
|
||||
let host: StoredHost?
|
||||
}
|
||||
|
||||
struct LibraryProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> LibraryEntry {
|
||||
LibraryEntry(date: .now, host: nil)
|
||||
}
|
||||
|
||||
func snapshot(for configuration: LibraryWidgetConfigIntent, in context: Context) async
|
||||
-> LibraryEntry {
|
||||
LibraryEntry(date: .now, host: Self.resolve(configuration.host))
|
||||
}
|
||||
|
||||
func timeline(for configuration: LibraryWidgetConfigIntent, in context: Context) async
|
||||
-> Timeline<LibraryEntry> {
|
||||
// Single entry, never auto-refresh: the app reloads this timeline on every store change.
|
||||
Timeline(entries: [LibraryEntry(date: .now, host: Self.resolve(configuration.host))],
|
||||
policy: .never)
|
||||
}
|
||||
|
||||
/// The configured host by id — nil (NOT a fallback) when it's gone; most-recent when nothing
|
||||
/// was configured.
|
||||
static func resolve(_ configured: HostEntity?) -> StoredHost? {
|
||||
let hosts = HostsProvider.loadHosts() // shared-suite JSON, most-recent first
|
||||
guard let configured else { return hosts.first }
|
||||
return hosts.first { $0.id == configured.id }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct LibraryWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: "PunktfunkLibrary", intent: LibraryWidgetConfigIntent.self,
|
||||
provider: LibraryProvider()
|
||||
) { entry in
|
||||
LibraryWidgetView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("Game Library")
|
||||
.description("Jump straight into a host's game library.")
|
||||
.supportedFamilies([.systemSmall, .accessoryCircular, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
/// Deep link that opens a stored host's library.
|
||||
private func browseURL(_ host: StoredHost) -> URL {
|
||||
DeepLink.browse(host: host.id).url
|
||||
}
|
||||
|
||||
struct LibraryWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: LibraryEntry
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
CircularLibraryView(host: entry.host)
|
||||
case .accessoryRectangular:
|
||||
RectangularLibraryView(host: entry.host)
|
||||
default: // systemSmall + fallback
|
||||
SmallLibraryView(host: entry.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SmallLibraryView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
if let host {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.brand)
|
||||
Spacer(minLength: 0)
|
||||
Text(host.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
Text("Game Library")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.widgetURL(browseURL(host))
|
||||
} else {
|
||||
EmptyLibraryView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularLibraryView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
}
|
||||
.widgetURL(host.map(browseURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct RectangularLibraryView: View {
|
||||
let host: StoredHost?
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
VStack(alignment: .leading) {
|
||||
Text(host?.displayName ?? "Punktfunk")
|
||||
.lineLimit(1)
|
||||
Text("Library")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.widgetURL(host.map(browseURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyLibraryView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "square.grid.2x2")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Open Punktfunk to pick a host.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews (Xcode canvas)
|
||||
//
|
||||
// Same pattern as the hosts widget: `#Preview(as:widget:timeline:)` feeds sample entries directly,
|
||||
// so the canvas works without a paired device or saved hosts. The small preview's second entry
|
||||
// shows the empty state one timeline click away.
|
||||
|
||||
private let previewHost = StoredHost(
|
||||
name: "Studio", address: "192.168.1.20",
|
||||
lastConnected: .now.addingTimeInterval(-40 * 60))
|
||||
|
||||
#Preview("Small", as: .systemSmall) {
|
||||
LibraryWidget()
|
||||
} timeline: {
|
||||
LibraryEntry(date: .now, host: previewHost)
|
||||
LibraryEntry(date: .now, host: nil)
|
||||
}
|
||||
|
||||
#Preview("Lock Screen circular", as: .accessoryCircular) {
|
||||
LibraryWidget()
|
||||
} timeline: {
|
||||
LibraryEntry(date: .now, host: previewHost)
|
||||
}
|
||||
|
||||
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
|
||||
LibraryWidget()
|
||||
} timeline: {
|
||||
LibraryEntry(date: .now, host: previewHost)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import WidgetKit
|
||||
struct PunktfunkWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
HostsWidget()
|
||||
LibraryWidget()
|
||||
PunktfunkSessionLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,12 +56,6 @@ struct ContentView: View {
|
||||
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
||||
/// the session model's published state below; iPhone/iPad only.
|
||||
@State private var liveActivity = SessionActivityController()
|
||||
/// The window's bottom safe-area inset (the home-indicator strip), reported by
|
||||
/// DisplayBottomInsetProbe from UIKit's own callbacks and published as
|
||||
/// `\.displayBottomInset` for the screens that pin a legend to the display's corner. Held
|
||||
/// HERE and read through the environment because asking UIKit for it during a body severs
|
||||
/// the asking view's updates on device (see the probe).
|
||||
@State private var displayBottomInset: CGFloat = 0
|
||||
#endif
|
||||
@State private var pairingTarget: StoredHost?
|
||||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||
@@ -71,7 +65,7 @@ struct ContentView: View {
|
||||
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
|
||||
@State private var awaitingApproval: ApprovalRequest?
|
||||
@State private var speedTestTarget: StoredHost?
|
||||
@State private var libraryTarget: LibraryTarget?
|
||||
@State private var libraryTarget: StoredHost?
|
||||
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
|
||||
/// "Waking…" phase of the connect overlay). Available on every platform now that the iOS/tvOS
|
||||
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
|
||||
@@ -121,23 +115,6 @@ struct ContentView: View {
|
||||
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers — resign-active
|
||||
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
#endif
|
||||
|
||||
/// The gamepad UI's form-metric tier for this window, published from HERE — the app's root.
|
||||
/// A screen that applies `gamepadPaletteInk` itself sits ABOVE its own copy of the environment,
|
||||
/// so its `@Environment` resolves against its parent; publishing at the root is what makes
|
||||
/// every one of them (including the ones presented as sheets and covers, which inherit the
|
||||
/// environment) read its own window's tier instead of the bare default.
|
||||
private var gamepadMetrics: GamepadFormMetrics {
|
||||
#if os(iOS)
|
||||
.forWindow(h: hSizeClass, v: vSizeClass)
|
||||
#else
|
||||
.platformDefault
|
||||
#endif
|
||||
}
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||
@@ -204,36 +181,6 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var driven: some View {
|
||||
drivenBase
|
||||
.environment(\.gamepadMetrics, gamepadMetrics)
|
||||
#if os(iOS)
|
||||
.environment(\.displayBottomInset, displayBottomInset)
|
||||
// The probe is UIKit's, not any screen's: mounted once here as a background so the
|
||||
// legend-pinning screens can READ the inset from the environment without ever asking
|
||||
// UIKit during their own body (which severs their updates — see the probe).
|
||||
.background {
|
||||
DisplayBottomInsetProbe { displayBottomInset = $0 }
|
||||
}
|
||||
#endif
|
||||
#if os(iOS) || os(macOS)
|
||||
// The console's own modal, over WHICHEVER screen is up. Not attached to `home`, which
|
||||
// renders only while `model.connection == nil`: a connection exists through the
|
||||
// pair-required and approval handshakes, which is precisely when these prompts fire.
|
||||
// It sits above the connect takeover too — the delegated-approval wait is raised
|
||||
// DURING a dial and owns the only Cancel for it. (The takeover draws nothing in that
|
||||
// state: `connectingOverlayName` is nil while `awaitingApproval` is set, so the two
|
||||
// never poll the pad at once.)
|
||||
.overlay {
|
||||
if let prompt = consolePrompt {
|
||||
GamepadPromptView(prompt: prompt)
|
||||
.gamepadPaletteInk()
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var drivenBase: some View {
|
||||
Group {
|
||||
// The stream view's structural identity MUST be stable across the
|
||||
// awaiting-trust → streaming transition: recreating it restarts the pump,
|
||||
@@ -412,30 +359,17 @@ struct ContentView: View {
|
||||
// (like the sheets below) so it survives the streaming → home transition the disconnect
|
||||
// drives, and consumed here — the model hands the host over once and we clear it, so a
|
||||
// later manual dismiss of the library can't be undone by a stale value.
|
||||
.onChange(of: model.returnToLibrary) { _, shelf in
|
||||
guard let shelf else { return }
|
||||
.onChange(of: model.returnToLibrary) { _, host in
|
||||
guard let host else { return }
|
||||
model.returnToLibrary = nil
|
||||
libraryTarget = shelf
|
||||
libraryTarget = host
|
||||
}
|
||||
// On the outer Group so the sheet survives the trust-prompt → home transition
|
||||
// (the "Pair with PIN instead" path disconnects first — the host's accept loop
|
||||
// is sequential, a pairing connection would queue behind the live session).
|
||||
#if !os(tvOS)
|
||||
// macOS presents BOTH pairing UIs from here, picking by mode (the console UI's screen is
|
||||
// gamepad-navigable; PairSheet's Form is not). iOS hides this sheet in gamepad mode
|
||||
// instead — there the pair screen is one of the shell's in-place layers, exactly like
|
||||
// settings and add-host (see `touchPairingTarget`).
|
||||
.sheet(item: touchPairingTarget) { host in
|
||||
#if os(macOS)
|
||||
if gamepadUIActive {
|
||||
GamepadPairView(host: host, onPaired: { handlePaired(host, fingerprint: $0) })
|
||||
.frame(width: 660, height: 620)
|
||||
} else {
|
||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||
}
|
||||
#else
|
||||
.sheet(item: $pairingTarget) { host in
|
||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||
#endif
|
||||
}
|
||||
.sheet(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
@@ -448,9 +382,9 @@ struct ContentView: View {
|
||||
// (the coverflow is a GeometryReader, ideal ≈ zero), so without a frame it collapses to a
|
||||
// tiny panel.
|
||||
#if os(macOS)
|
||||
.sheet(item: $libraryTarget) { shelf in
|
||||
.sheet(item: $libraryTarget) { host in
|
||||
NavigationStack {
|
||||
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
|
||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||
}
|
||||
.frame(minWidth: 940, minHeight: 620)
|
||||
}
|
||||
@@ -461,9 +395,9 @@ struct ContentView: View {
|
||||
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
|
||||
// controller arriving or leaving mid-browse hands the open library to whichever
|
||||
// presentation the new mode owns.
|
||||
.fullScreenCover(item: touchLibraryTarget) { shelf in
|
||||
.fullScreenCover(item: touchLibraryTarget) { host in
|
||||
NavigationStack {
|
||||
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
|
||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -475,141 +409,30 @@ struct ContentView: View {
|
||||
// budget (inline, they tip SwiftUI's per-expression limit — see the split sections idiom).
|
||||
|
||||
private var deepLinkNoticePresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { deepLinkNotice != nil && !consolePromptShowing },
|
||||
set: { if !$0 { deepLinkNotice = nil } })
|
||||
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
|
||||
}
|
||||
|
||||
/// True while the console prompt owns the modal state (see `consolePrompt`). Always false on
|
||||
/// tvOS, whose alerts the focus engine drives natively.
|
||||
private var consolePromptShowing: Bool {
|
||||
#if os(iOS) || os(macOS)
|
||||
consolePrompt != nil
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The modal state the console UI should present ITSELF, as a pad-navigable prompt, instead of
|
||||
/// letting a system alert take it. `.alert`/`.confirmationDialog` are UIKit/AppKit surfaces a
|
||||
/// controller cannot navigate, and these are not incidental prompts: "Pairing required" is the
|
||||
/// FIRST thing an unpaired host shows, "Connection failed" strands the console UI behind a
|
||||
/// modal only a finger can dismiss, and "Waiting for approval" owns the only Cancel for a
|
||||
/// connect that may never complete. One at a time, most-urgent first — a system alert stack
|
||||
/// would layer these, but a console shows one screen.
|
||||
///
|
||||
/// Gated on not STREAMING, not on `model.connection == nil`: a connection object exists well
|
||||
/// before a stream does, through exactly the handshakes these prompts belong to. Streaming is
|
||||
/// the one case that must stay with the system alert — there the pad belongs to
|
||||
/// `GamepadCapture` and is being forwarded to the host.
|
||||
private var consolePrompt: GamepadPrompt? {
|
||||
guard gamepadUIActive, model.phase != .streaming else { return nil }
|
||||
if let req = approvalChoice {
|
||||
return GamepadPrompt(
|
||||
id: "pairing-required",
|
||||
title: "Pairing required",
|
||||
message: "\(req.host.displayName) requires pairing. Request access and approve "
|
||||
+ "this device in the host's web console (port 47992 → Pairing) — no PIN "
|
||||
+ "needed. Or pair with the 4-digit PIN it can display.",
|
||||
actions: [
|
||||
// The follow-on presentation is deferred a tick exactly as the system dialog
|
||||
// does it, so this prompt is fully torn down before the next screen mounts —
|
||||
// two controller pollers overlapping for a frame is how one A press reaches
|
||||
// both.
|
||||
GamepadPromptAction(id: "request", title: "Request Access", isPrimary: true) {
|
||||
approvalChoice = nil
|
||||
DispatchQueue.main.async { requestAccess(req) }
|
||||
},
|
||||
GamepadPromptAction(id: "pin", title: "Pair with PIN…") {
|
||||
approvalChoice = nil
|
||||
DispatchQueue.main.async { pairingTarget = req.host }
|
||||
},
|
||||
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
|
||||
approvalChoice = nil
|
||||
},
|
||||
])
|
||||
}
|
||||
if let req = awaitingApproval {
|
||||
return GamepadPrompt(
|
||||
id: "awaiting-approval",
|
||||
title: "Waiting for approval",
|
||||
message: "Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s "
|
||||
+ "web console (port 47992 → Pairing). This device connects automatically "
|
||||
+ "once you approve it — no need to reconnect.",
|
||||
actions: [
|
||||
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
|
||||
awaitingApproval = nil
|
||||
model.disconnect()
|
||||
},
|
||||
],
|
||||
busy: true)
|
||||
}
|
||||
if connectionErrorReady {
|
||||
return GamepadPrompt(
|
||||
id: "connection-failed",
|
||||
title: "Connection failed",
|
||||
message: model.errorMessage ?? "",
|
||||
actions: [
|
||||
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
|
||||
model.errorMessage = nil
|
||||
},
|
||||
])
|
||||
}
|
||||
if let notice = deepLinkNotice {
|
||||
return GamepadPrompt(
|
||||
id: "cant-open",
|
||||
title: "Can't open",
|
||||
message: notice,
|
||||
actions: [
|
||||
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
|
||||
deepLinkNotice = nil
|
||||
},
|
||||
])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
|
||||
/// the library in place (see the cover's comment).
|
||||
private var touchLibraryTarget: Binding<LibraryTarget?> {
|
||||
private var touchLibraryTarget: Binding<StoredHost?> {
|
||||
Binding(
|
||||
get: { gamepadUIActive ? nil : libraryTarget },
|
||||
set: { libraryTarget = $0 })
|
||||
}
|
||||
|
||||
/// The pairing sheet's item. On iOS it hides while the gamepad shell presents the pair screen
|
||||
/// in place — the same proxy the library uses, and for the same reason: every writer keeps
|
||||
/// writing `pairingTarget`, and whichever presentation the current mode owns picks it up.
|
||||
/// macOS has no shell, so the sheet stays and switches its CONTENT by mode instead.
|
||||
private var touchPairingTarget: Binding<StoredHost?> {
|
||||
#if os(macOS)
|
||||
Binding(get: { pairingTarget }, set: { pairingTarget = $0 })
|
||||
#else
|
||||
Binding(
|
||||
get: { gamepadUIActive ? nil : pairingTarget },
|
||||
set: { pairingTarget = $0 })
|
||||
#endif
|
||||
}
|
||||
|
||||
private var approvalChoicePresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { approvalChoice != nil && !consolePromptShowing },
|
||||
set: { if !$0 { approvalChoice = nil } })
|
||||
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
|
||||
}
|
||||
|
||||
private var awaitingApprovalPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { awaitingApproval != nil && !consolePromptShowing },
|
||||
set: { if !$0 { awaitingApproval = nil } })
|
||||
Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
|
||||
}
|
||||
|
||||
/// Whether the "Connection failed" state is ready to be shown at all — shared by the system
|
||||
/// alert and the console prompt so the two can never disagree about the macOS deferral below.
|
||||
private var connectionErrorReady: Bool {
|
||||
guard model.errorMessage != nil else { return false }
|
||||
#if os(macOS)
|
||||
private var connectionErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
guard model.errorMessage != nil else { return false }
|
||||
#if os(macOS)
|
||||
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
|
||||
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
|
||||
// presenting it now strands the window fullscreen on the home screen after a
|
||||
@@ -618,14 +441,10 @@ struct ContentView: View {
|
||||
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
|
||||
// over the windowed home UI. Not gated when fullscreen is the user's own manual
|
||||
// choice (opt-out setting) — nothing is auto-exiting there to conflict with.
|
||||
if fullscreenForSession && isFullscreen { return false }
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
private var connectionErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { connectionErrorReady && !consolePromptShowing },
|
||||
if fullscreenForSession && isFullscreen { return false }
|
||||
#endif
|
||||
return true
|
||||
},
|
||||
set: { if !$0 { model.errorMessage = nil } })
|
||||
}
|
||||
|
||||
@@ -668,20 +487,10 @@ struct ContentView: View {
|
||||
?? "That link is malformed and was ignored."
|
||||
return
|
||||
}
|
||||
switch link.route {
|
||||
case .connect:
|
||||
break
|
||||
case .browse:
|
||||
// The reserved library route, now real: open the host's game library without starting
|
||||
// a session. `launch=`/`profile=` are meaningless on a browse (nothing streams until a
|
||||
// title is picked, and that connect resolves its own profile) — ignored, not refused,
|
||||
// per the unknown-parameter rule.
|
||||
openLibrary(from: link)
|
||||
return
|
||||
case .wake:
|
||||
// Still reserved: saying so beats silently connecting instead. (Shortcuts users have
|
||||
// the Wake Host intent, which never round-trips through a URL.)
|
||||
deepLinkNotice = "Punktfunk links can't do “wake” yet."
|
||||
guard link.route == .connect else {
|
||||
// `wake` and `browse` are reserved in the grammar and parse today; this build routes
|
||||
// neither, and saying so beats silently connecting instead.
|
||||
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
|
||||
return
|
||||
}
|
||||
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
|
||||
@@ -735,57 +544,6 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// `punktfunk://browse/<host-ref>` — jump into a host's game library. Drives the SAME
|
||||
/// `libraryTarget` every internal surface writes, so the link lands in whichever presentation
|
||||
/// the current mode owns: the gamepad console's in-place library screen, the touch cover, the
|
||||
/// macOS sheet, or tvOS's cover. Connect's posture minus the connect itself: a pin conflict
|
||||
/// refuses, a live session is never preempted, and an unsaved host can't be browsed — the
|
||||
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
|
||||
/// is saved (the notice says what to do instead).
|
||||
private func openLibrary(from link: DeepLink) {
|
||||
// A `profile=` on a browse link picks the shelf, exactly as it picks the settings on a
|
||||
// connect link — and refuses the same way (§10.6): an unknown or ambiguous reference must
|
||||
// never quietly degrade to the host's binding, which is a different shelf wearing the same
|
||||
// host's name.
|
||||
var selection = ProfileSelection.inherit
|
||||
if let reference = link.profile {
|
||||
let (profile, resolution) = profiles.catalog.resolve(reference)
|
||||
switch resolution {
|
||||
case .found:
|
||||
selection = .profile(profile?.id ?? "")
|
||||
case .notFound:
|
||||
deepLinkNotice = "No settings profile called “\(reference)” on this device."
|
||||
return
|
||||
case .ambiguous:
|
||||
deepLinkNotice = "More than one settings profile is called “\(reference)”. "
|
||||
+ "Rename one, or link to it by its id."
|
||||
return
|
||||
}
|
||||
}
|
||||
switch link.resolveHost(in: store.hosts) {
|
||||
case .known(let host):
|
||||
guard !link.pinConflict(with: host) else {
|
||||
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
|
||||
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
|
||||
return
|
||||
}
|
||||
guard model.phase == .idle else {
|
||||
let current = model.activeHost?.displayName ?? "a host"
|
||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||
return
|
||||
}
|
||||
libraryTarget = LibraryTarget(host: host, profile: selection)
|
||||
case .unknown(let address, _, let name, _):
|
||||
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
|
||||
+ "Add it with the + button first — a library can only be browsed on a saved host."
|
||||
case .ambiguous:
|
||||
deepLinkNotice = "More than one saved host is called “\(link.hostRef)”. "
|
||||
+ "Rename one, or link to it by its address."
|
||||
case .unresolvable:
|
||||
deepLinkNotice = "That host isn't saved on this device."
|
||||
}
|
||||
}
|
||||
|
||||
private var home: some View {
|
||||
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
||||
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
||||
@@ -818,11 +576,9 @@ struct ContentView: View {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
|
||||
onPaired: handlePaired, waker: waker,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle,
|
||||
promptActive: consolePromptShowing)
|
||||
launchTitle: launchTitle)
|
||||
} else {
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
@@ -837,11 +593,9 @@ struct ContentView: View {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
|
||||
onPaired: handlePaired, waker: waker,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle,
|
||||
promptActive: consolePromptShowing)
|
||||
launchTitle: launchTitle)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
||||
@@ -852,9 +606,9 @@ struct ContentView: View {
|
||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||
.onExitCommand { pairingTarget = nil }
|
||||
}
|
||||
.fullScreenCover(item: $libraryTarget) { shelf in
|
||||
.fullScreenCover(item: $libraryTarget) { host in
|
||||
NavigationStack {
|
||||
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
|
||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||
}
|
||||
.onExitCommand { libraryTarget = nil }
|
||||
}
|
||||
@@ -1152,7 +906,6 @@ struct ContentView: View {
|
||||
private var shortcutHintText: String {
|
||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||
+ " · Hold Play/Pause, or Select+X on a controller, for statistics"
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||
#endif
|
||||
@@ -1253,10 +1006,6 @@ struct ContentView: View {
|
||||
setting: PunktfunkConnection.GamepadType(
|
||||
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
|
||||
launchID: launchID,
|
||||
// Where a game exit returns to, when this connect launched a title: the shelf that
|
||||
// title was picked on — the host's own, or the pinned card whose profile this connect
|
||||
// is using. Ignored by the model unless there is a launchID.
|
||||
shelf: LibraryTarget(host: host, profile: profile),
|
||||
allowTofu: allowTofu,
|
||||
requestAccess: requestAccess,
|
||||
onUnreachable: onUnreachable)
|
||||
@@ -1312,13 +1061,9 @@ struct ContentView: View {
|
||||
|
||||
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
||||
/// asks the host to launch it.
|
||||
/// A title picked on a library shelf: dial its host, booting straight into that title — with
|
||||
/// the shelf's profile. A pinned card's shelf carries its card's profile as the one-off, so a
|
||||
/// launch made there streams with the profile the card promises; the host's own shelf carries
|
||||
/// `.inherit` and the binding decides, exactly as a plain card tap does.
|
||||
private func launchTitle(_ shelf: LibraryTarget, _ id: String) {
|
||||
private func launchTitle(_ host: StoredHost, _ id: String) {
|
||||
libraryTarget = nil
|
||||
connect(shelf.host, launchID: id, profile: shelf.profile)
|
||||
connect(host, launchID: id)
|
||||
}
|
||||
|
||||
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
||||
|
||||
@@ -13,8 +13,6 @@ import SwiftUI
|
||||
|
||||
struct GamepadAddHostView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
let onAdd: (StoredHost) -> Void
|
||||
@@ -50,7 +48,7 @@ struct GamepadAddHostView: View {
|
||||
isActive: controllerActive && editing == nil
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -63,28 +61,25 @@ struct GamepadAddHostView: View {
|
||||
if !compact {
|
||||
Text("Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else.")
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
bottomTray
|
||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||
.padding(.horizontal, compact ? 12 : 18)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.background { GamepadTrayBlur(edge: .bottom) }
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
||||
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
|
||||
@@ -153,28 +148,17 @@ struct GamepadAddHostView: View {
|
||||
// binding on appear — new identity forces a rewire to the new field.
|
||||
.id(editing)
|
||||
GamepadHintBar(hints: [
|
||||
// "Type" names what A does to the key under the keyboard's cursor. There is
|
||||
// no tap equivalent — a touch user types by tapping the keycap itself — so
|
||||
// this one cell stays a label.
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
||||
action: { backspace(editing) }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { closeKeyboard() }),
|
||||
.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
} else {
|
||||
GamepadHintBar(hints: [
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
||||
action: { if let focusID { activate(id: focusID) } }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
|
||||
action: { performClose() }),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -207,7 +191,7 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
let m = GamepadFormMetrics.self
|
||||
return HStack(spacing: 14) {
|
||||
if row.isAction {
|
||||
Label("Add Host", systemImage: "plus.circle.fill")
|
||||
@@ -284,15 +268,6 @@ struct GamepadAddHostView: View {
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
|
||||
}
|
||||
|
||||
/// The legend's Delete cell (iOS/macOS). Applied to the field's binding rather than routed
|
||||
/// into `GamepadKeyboard`: the keyboard's X does exactly this to the same binding, and
|
||||
/// reaching into its state to trigger it would need a whole callback channel for one edit.
|
||||
private func backspace(_ id: String) {
|
||||
let binding = editingBinding(id)
|
||||
guard !binding.wrappedValue.isEmpty else { return }
|
||||
binding.wrappedValue.removeLast()
|
||||
}
|
||||
|
||||
private func editingBinding(_ id: String) -> Binding<String> {
|
||||
switch id {
|
||||
case "name": return $name
|
||||
|
||||
@@ -191,16 +191,6 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
#if os(iOS) || os(macOS)
|
||||
// A hardware keyboard drives the same cursor as the pad — arrows step, Return activates,
|
||||
// Esc backs out (iPad on a Magic Keyboard, couch Mac). tvOS routes arrows through the
|
||||
// focus engine instead, which owns navigation there.
|
||||
.gamepadKeyNavigation(
|
||||
active: isActive,
|
||||
onMove: { move($0) },
|
||||
onConfirm: { activate() },
|
||||
onBack: onBack)
|
||||
#endif
|
||||
.onAppear {
|
||||
reconcile()
|
||||
wire()
|
||||
@@ -442,8 +432,8 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card
|
||||
/// itself only grows, since it is already facing you. Each card carries its own delay (see
|
||||
/// `entrance(_:)`) — that stagger is what makes the strip read as one gesture instead of a
|
||||
/// simultaneous flash, and it is the same hinge language the coverflow's own recede speaks, so the
|
||||
/// arrival and the scrolling feel like one object.
|
||||
/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede
|
||||
/// speaks, so the arrival and the scrolling feel like one object.
|
||||
///
|
||||
/// ⚠️ APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
|
||||
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
|
||||
@@ -452,23 +442,6 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// collapsed into its focused look as the entrance ended — arriving as a jump. Underneath, the
|
||||
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
|
||||
///
|
||||
/// ⚠️ NO `rotation3DEffect` HERE, however much the drum language invites one. It was the cause of
|
||||
/// the strip's "flash as the cards settle": a real 3D transform renders the card through an
|
||||
/// offscreen layer, and a card carries translucent glass, which resolves differently in there —
|
||||
/// so every card sat at the wrong fill for as long as the master animation ran and then snapped
|
||||
/// to its true one in a SINGLE frame the moment SwiftUI dropped that layer.
|
||||
///
|
||||
/// Measured on an iPad Pro 13": the centred tile held #4a3d87 for twelve frames in which nothing
|
||||
/// moved, then stepped to #423970 (−23 blue) in one. It is the ANIMATION ending, not the motion:
|
||||
/// stretching the timeline from 1.02 s to 2.82 s moved the step from 0.70 s to 2.50 s after the
|
||||
/// launcher appeared — the same 0.32 s before the end both times. Removing the rotation removed
|
||||
/// the step outright; `compositingGroup()` above or below the transforms did nothing.
|
||||
///
|
||||
/// So the turn is PROJECTED instead: `cos(angle)` as a horizontal squeeze is exactly the
|
||||
/// orthographic projection of a Y-axis rotation, hinged on the edge the card fans from. Affine,
|
||||
/// so no offscreen pass and no layer to drop — and it reads as the same gesture, losing only the
|
||||
/// perspective trapezoid, which at these card sizes was never what sold the motion.
|
||||
///
|
||||
/// Transforms only — nothing here touches layout, so the scroll view's snapping and the tvOS
|
||||
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
|
||||
/// unstaggered cross-fade.
|
||||
@@ -512,15 +485,14 @@ struct CardEntrance: ViewModifier, Animatable {
|
||||
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
|
||||
// it instead of swinging the opposite way.
|
||||
let away = reduceMotion ? 0 : 1 - travel
|
||||
// The turn, projected rather than rendered in 3D — see the type's note on the flash.
|
||||
// `cos` of the angle IS the orthographic projection of a Y-axis rotation, and hinging it
|
||||
// on the edge the card fans from restores the direction that the rotation's sign carried
|
||||
// (cos is even, so the sign alone would read the same both ways).
|
||||
let turn = cos(Angle.degrees(64 * away).radians)
|
||||
return content
|
||||
.opacity(reduceMotion ? raw : fade)
|
||||
.scaleEffect(1 - 0.26 * away)
|
||||
.scaleEffect(x: turn, y: 1, anchor: side < 0 ? .trailing : .leading)
|
||||
.rotation3DEffect(
|
||||
.degrees(side * -64 * away),
|
||||
axis: (x: 0, y: 1, z: 0),
|
||||
anchor: .center,
|
||||
perspective: 0.65)
|
||||
.offset(y: 34 * away)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,36 +5,20 @@
|
||||
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS — where the same screens are driven by
|
||||
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
|
||||
|
||||
import Glur
|
||||
import GlurBackdrop
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
/// The glyph a button wears in a legend: the ACTIVE controller's own (Xbox "A", DualSense ✕, …)
|
||||
/// via `sfSymbolsName` while one is attached, else the glyph of the last pad this device ever saw
|
||||
/// (`GamepadManager.lastKnownKind` → `GamepadGlyphs`), else the caller's generic fallback.
|
||||
///
|
||||
/// The middle rung is the whole point. `active` is nil whenever the pad sleeps, disconnects or
|
||||
/// runs flat — and permanently under `gamepadUIMode == "always"`, which puts the console UI up
|
||||
/// with no pad by design — and the fallbacks are letter glyphs, so a DualSense user's ✕/◯ legends
|
||||
/// used to turn into A/B the moment the controller dozed off. The remembered kind keeps the
|
||||
/// legends speaking the pad the user actually owns. The `fallback` still covers the genuinely
|
||||
/// unknown case: a fresh install that has never seen a controller, and any button outside the six
|
||||
/// `GamepadButtonRole` names.
|
||||
///
|
||||
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||||
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
|
||||
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
|
||||
@MainActor
|
||||
func buttonGlyph(
|
||||
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
||||
) -> String {
|
||||
let manager = GamepadManager.shared
|
||||
if let live = manager.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName {
|
||||
return live
|
||||
}
|
||||
guard let role = GamepadButtonRole(keyPath: button) else { return fallback }
|
||||
return GamepadGlyphs.symbol(role, for: manager.lastKnownKind)
|
||||
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
||||
?? fallback
|
||||
}
|
||||
|
||||
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher
|
||||
@@ -84,251 +68,43 @@ func gamepadTitleSize(compact: Bool) -> CGFloat {
|
||||
}
|
||||
|
||||
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
|
||||
/// GamepadAddHostView) — one set of numbers so the screens read as the same surface, at the size
|
||||
/// the screen they are on calls for.
|
||||
///
|
||||
/// Three tiers, not two. The phone numbers used to serve every non-TV device, so an iPad Pro drew
|
||||
/// a settings list at iPhone scale in the middle of a 13" display — the field verdict was that the
|
||||
/// sizing "does not adapt to larger screens". `pad` sits between the in-hand and 10-foot sets.
|
||||
///
|
||||
/// Chosen from the SIZE CLASSES rather than the device idiom, so an iPad running a narrow Stage
|
||||
/// Manager or Split View window correctly gets the in-hand numbers — the window is what the user
|
||||
/// is reading, not the panel it sits on.
|
||||
struct GamepadFormMetrics {
|
||||
/// Which set this is, for the few things that are a KIND of layout rather than a number.
|
||||
enum Tier { case phone, pad, tv }
|
||||
|
||||
let tier: Tier
|
||||
let headerFont: CGFloat
|
||||
let labelFont: CGFloat
|
||||
let valueFont: CGFloat
|
||||
let iconFont: CGFloat
|
||||
let iconWidth: CGFloat
|
||||
let chevronFont: CGFloat
|
||||
let rowHPad: CGFloat
|
||||
let rowVPad: CGFloat
|
||||
let rowCorner: CGFloat
|
||||
let rowMaxWidth: CGFloat
|
||||
let detailFont: CGFloat
|
||||
/// GamepadAddHostView) — one set of numbers so the two screens read as the same surface,
|
||||
/// sized for the couch on tvOS and for the hand elsewhere.
|
||||
enum GamepadFormMetrics {
|
||||
#if os(tvOS)
|
||||
static let headerFont: CGFloat = 17
|
||||
static let labelFont: CGFloat = 23
|
||||
static let valueFont: CGFloat = 21
|
||||
static let iconFont: CGFloat = 24
|
||||
static let iconWidth: CGFloat = 40
|
||||
static let chevronFont: CGFloat = 16
|
||||
static let rowHPad: CGFloat = 24
|
||||
static let rowVPad: CGFloat = 19
|
||||
static let rowCorner: CGFloat = 18
|
||||
static let rowMaxWidth: CGFloat = 920
|
||||
static let detailFont: CGFloat = 19
|
||||
static let bandWidth: CGFloat = 380
|
||||
#else
|
||||
static let headerFont: CGFloat = 12
|
||||
static let labelFont: CGFloat = 16
|
||||
static let valueFont: CGFloat = 15
|
||||
static let iconFont: CGFloat = 17
|
||||
static let iconWidth: CGFloat = 28
|
||||
static let chevronFont: CGFloat = 12
|
||||
static let rowHPad: CGFloat = 16
|
||||
static let rowVPad: CGFloat = 13
|
||||
static let rowCorner: CGFloat = 14
|
||||
static let rowMaxWidth: CGFloat = 620
|
||||
static let detailFont: CGFloat = 13
|
||||
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
|
||||
let bandWidth: CGFloat
|
||||
/// The settings screen's section-tab pills.
|
||||
let tabFont: CGFloat
|
||||
/// The pinned controls legend (GamepadHintBar).
|
||||
let hintGlyphFont: CGFloat
|
||||
let hintTextFont: CGFloat
|
||||
let hintPad: CGFloat
|
||||
|
||||
/// In-hand: a phone, or any window narrow enough to read like one.
|
||||
static let phone = GamepadFormMetrics(
|
||||
tier: .phone,
|
||||
headerFont: 12, labelFont: 16, valueFont: 15, iconFont: 17, iconWidth: 28,
|
||||
chevronFont: 12, rowHPad: 16, rowVPad: 13, rowCorner: 14, rowMaxWidth: 620,
|
||||
detailFont: 13, bandWidth: 240,
|
||||
tabFont: 13, hintGlyphFont: 19, hintTextFont: 14, hintPad: 13)
|
||||
|
||||
/// A tablet-sized window — an arm's length away rather than in the palm.
|
||||
static let pad = GamepadFormMetrics(
|
||||
tier: .pad,
|
||||
headerFont: 14, labelFont: 20, valueFont: 19, iconFont: 21, iconWidth: 34,
|
||||
chevronFont: 14, rowHPad: 20, rowVPad: 16, rowCorner: 16, rowMaxWidth: 820,
|
||||
detailFont: 16, bandWidth: 320,
|
||||
tabFont: 16, hintGlyphFont: 23, hintTextFont: 17, hintPad: 15)
|
||||
|
||||
/// 10-foot.
|
||||
static let tv = GamepadFormMetrics(
|
||||
tier: .tv,
|
||||
headerFont: 17, labelFont: 23, valueFont: 21, iconFont: 24, iconWidth: 40,
|
||||
chevronFont: 16, rowHPad: 24, rowVPad: 19, rowCorner: 18, rowMaxWidth: 920,
|
||||
detailFont: 19, bandWidth: 380,
|
||||
tabFont: 17, hintGlyphFont: 27, hintTextFont: 20, hintPad: 18)
|
||||
|
||||
/// What a screen gets before anything publishes a tier — and the only tier tvOS and macOS ever
|
||||
/// use (an Apple TV is always 10-foot; a Mac window is read at desk distance).
|
||||
static var platformDefault: GamepadFormMetrics {
|
||||
#if os(tvOS)
|
||||
tv
|
||||
#else
|
||||
phone
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The tier a window's size classes call for. REGULAR on both axes is the tablet case.
|
||||
static func forWindow(
|
||||
h: UserInterfaceSizeClass?, v: UserInterfaceSizeClass?
|
||||
) -> GamepadFormMetrics {
|
||||
h == .regular && v == .regular ? .pad : .phone
|
||||
}
|
||||
static let bandWidth: CGFloat = 240
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct GamepadMetricsKey: EnvironmentKey {
|
||||
static let defaultValue = GamepadFormMetrics.platformDefault
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The form metrics for the screen currently drawing. Published from ContentView — the app
|
||||
/// ROOT — rather than only from `gamepadPaletteInk`, because a screen that applies that
|
||||
/// modifier itself sits ABOVE its own copy: its `@Environment` resolves against its parent, so
|
||||
/// it would read the bare default instead of its own window's tier.
|
||||
var gamepadMetrics: GamepadFormMetrics {
|
||||
get { self[GamepadMetricsKey.self] }
|
||||
set { self[GamepadMetricsKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
private struct DisplayBottomInsetKey: EnvironmentKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The display's bottom safe-area inset — the home-indicator strip — measured by
|
||||
/// `DisplayBottomInsetProbe` and published from ContentView. 0 until UIKit's first callback
|
||||
/// lands (the legend keeps its plain margin for that first frame) and always 0 on
|
||||
/// macOS/tvOS, where nothing publishes it.
|
||||
var displayBottomInset: CGFloat {
|
||||
get { self[DisplayBottomInsetKey.self] }
|
||||
set { self[DisplayBottomInsetKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Reports the hosting window's bottom safe-area inset from UIKit's OWN callbacks — never
|
||||
/// during a SwiftUI render.
|
||||
///
|
||||
/// This number has a history of wrong spellings, each failing silently:
|
||||
/// - a `GeometryReader` carrying `.ignoresSafeArea()` — a proxy reports NO insets for an edge it
|
||||
/// has been told to ignore, so that spelling can only ever answer 0;
|
||||
/// - `.ignoresSafeArea(.container, edges: .bottom)` on `safeAreaInset` CONTENT, which does not
|
||||
/// move content the inset mechanism itself placed; and
|
||||
/// - asking UIKit for the key window (`UIApplication.shared.connectedScenes…`) DURING body,
|
||||
/// which answered correctly and then KILLED the calling view: on an iPad (never the
|
||||
/// simulator) the walk re-enters UIKit layout mid-render and the view's update graph is
|
||||
/// silently severed — every later `@State` write lands in storage without ever re-running
|
||||
/// `body` again, which is how Settings and Add Host stopped opening while their triggers
|
||||
/// kept firing. No AttributeGraph warning, no log line; found by bisecting builds on glass.
|
||||
/// So: UIKit tells THIS view when the window or its insets change, on UIKit's schedule, and the
|
||||
/// answer hops out of the current update before anyone in SwiftUI reads it.
|
||||
struct DisplayBottomInsetProbe: UIViewRepresentable {
|
||||
let onChange: (CGFloat) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> ProbeView {
|
||||
let view = ProbeView()
|
||||
view.onChange = onChange
|
||||
// Mounted as a full-size `.background`; it must never eat a touch meant for the UI.
|
||||
view.isUserInteractionEnabled = false
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: ProbeView, context: Context) {
|
||||
view.onChange = onChange
|
||||
}
|
||||
|
||||
final class ProbeView: UIView {
|
||||
var onChange: ((CGFloat) -> Void)?
|
||||
private var last: CGFloat?
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
report()
|
||||
}
|
||||
|
||||
override func safeAreaInsetsDidChange() {
|
||||
super.safeAreaInsetsDidChange()
|
||||
report()
|
||||
}
|
||||
|
||||
// Rotation reshuffles the window's insets without necessarily touching this view's own.
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
report()
|
||||
}
|
||||
|
||||
private func report() {
|
||||
// The WINDOW's inset, not this view's: the probe sits inside the safe area, so its
|
||||
// own inset is 0 — the number the legend needs is the strip the window reserves.
|
||||
guard let bottom = window?.safeAreaInsets.bottom, bottom != last else { return }
|
||||
last = bottom
|
||||
let onChange = onChange
|
||||
// Out of the current UIKit/SwiftUI update before any state write.
|
||||
DispatchQueue.main.async { onChange?(bottom) }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The bottom padding that puts a pinned legend the same distance from the bottom of the DISPLAY
|
||||
/// as it sits from the leading edge — so it lands on the diagonal of the display's rounded corner,
|
||||
/// which is what the corner asks for.
|
||||
///
|
||||
/// A `safeAreaInset` places its content INSIDE the safe area, so a plain margin stacks on top of
|
||||
/// the device's own bottom inset and the pill ends up two to three times further from the bottom
|
||||
/// than from the left. On a tablet this therefore goes NEGATIVE, pulling the pill back down
|
||||
/// through the home-indicator strip; the pill is left-aligned and an iPad's indicator is a short
|
||||
/// bar in the middle, so the two never meet.
|
||||
///
|
||||
/// Phones keep the plain margin. Their inset is the taller indicator bar and their legend runs
|
||||
/// most of the width, so sitting it that low would cross the indicator rather than tuck beside it.
|
||||
///
|
||||
/// `displayBottom` is `\.displayBottomInset` — measured by `DisplayBottomInsetProbe`, NEVER asked
|
||||
/// of UIKit here: this runs during body, and a key-window walk mid-render severs the calling
|
||||
/// view's updates (see the probe's comment). Pure arithmetic only.
|
||||
func gamepadLegendBottomPadding(
|
||||
_ margin: CGFloat, tier: GamepadFormMetrics.Tier, displayBottom: CGFloat
|
||||
) -> CGFloat {
|
||||
guard tier == .pad else { return margin }
|
||||
// Floored at -inset: at worst the pill sits flush with the physical edge, never past it.
|
||||
return max(-displayBottom, margin - displayBottom)
|
||||
}
|
||||
|
||||
/// The tray gradient blur, back — as a real progressive BACKDROP blur this time.
|
||||
///
|
||||
/// GamepadTrayScrim did this with `.ultraThinMaterial`, and a material by definition lifts and
|
||||
/// tints whatever it blurs: it read grey over the aurora, and washed with the palette's ground it
|
||||
/// read coloured, which is why 2590238b deleted it. Glur's `GlurView` blurs the backdrop through
|
||||
/// a gradient with NO material stage on top, so the rows soften as they slide under the pinned
|
||||
/// title and legend and nothing carries a colour. It is the library's PRIVATE-API product
|
||||
/// (`GlurBackdrop`) — the public `.glur()` modifier is a shader on a view's own content and
|
||||
/// silently no-ops over platform-backed views like ScrollView, so it cannot reach a backdrop at
|
||||
/// all. Hit testing is disabled inside GlurView; the band never eats a touch.
|
||||
///
|
||||
/// Mounted exactly where the scrim was: `.background` of each form screen's safe-area tray.
|
||||
struct GamepadTrayBlur: View {
|
||||
let edge: VerticalEdge
|
||||
|
||||
var body: some View {
|
||||
// offset 0 puts the ramp's LITERAL ZERO exactly at the band's content edge, so nothing
|
||||
// in the open field is touched — which is why, unlike the scrim, this band takes NO
|
||||
// content-side overhang. The scrim's -44/-72 runway existed because a material carries
|
||||
// body at every alpha and had to dissolve OUTSIDE the tray; carrying those numbers over
|
||||
// here blurred fully-visible rows at rest (field verdict on the first cut). Full
|
||||
// strength lands at 60% of the band, so the tray's own text always sits on the strong
|
||||
// region while the ramp still reads as a gradient, not an edge.
|
||||
GlurView(
|
||||
radius: 14, offset: 0, interpolation: 0.6,
|
||||
direction: edge == .top ? .up : .down)
|
||||
// Full-bleed by LAYOUT, not `.ignoresSafeArea()`: safe-area expansion resolves a
|
||||
// beat after insertion (outside any geometry group and outside this view's own
|
||||
// transaction), which reads as a visible pop. 80 pt clears every inset on every
|
||||
// device, and backgrounds never clip — the overhang simply draws.
|
||||
.padding(edge == .top ? .top : .bottom, -80)
|
||||
.padding(.horizontal, -80)
|
||||
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
|
||||
// geometry would ride the push's transaction and visibly grow into place. The
|
||||
// layer's own fade/slide still carries the band; only its SHAPE is pinned.
|
||||
.transaction { $0.animation = nil }
|
||||
}
|
||||
}
|
||||
|
||||
/// One glyph + label cell in a hint bar.
|
||||
struct GamepadHint: Identifiable {
|
||||
let glyph: String
|
||||
let text: String
|
||||
/// What tapping/clicking this cell does — the same thing its button does. Optional because a
|
||||
/// few legend cells NAME an input rather than an action ("↔ Adjust" is the stick itself;
|
||||
/// there is no single thing a tap on it could mean), and those stay inert labels.
|
||||
var action: (() -> Void)? = nil
|
||||
var id: String { glyph + text }
|
||||
}
|
||||
|
||||
@@ -338,75 +114,39 @@ struct GamepadHint: Identifiable {
|
||||
/// the backdrop instead of dissolving into it.
|
||||
struct GamepadHintBar: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Sized with the screen it pins to — a legend at phone scale on a 13" iPad is the same
|
||||
/// mismatch the form rows had (see GamepadFormMetrics).
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
let hints: [GamepadHint]
|
||||
|
||||
// 10-foot legend on tvOS, in-hand sizes elsewhere.
|
||||
#if os(tvOS)
|
||||
private static let glyphFont: CGFloat = 27
|
||||
private static let textFont: CGFloat = 20
|
||||
private static let pad: CGFloat = 18
|
||||
#else
|
||||
private static let glyphFont: CGFloat = 19
|
||||
private static let textFont: CGFloat = 14
|
||||
private static let pad: CGFloat = 13
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 18) {
|
||||
ForEach(hints) { hint in
|
||||
cell(hint)
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: hint.glyph)
|
||||
.font(.system(size: Self.glyphFont))
|
||||
.foregroundStyle(ink.fg)
|
||||
Text(hint.text)
|
||||
}
|
||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||
}
|
||||
}
|
||||
.font(.geist(metrics.hintTextFont, .semibold, relativeTo: .subheadline))
|
||||
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(ink.fg(0.85))
|
||||
.padding(metrics.hintPad)
|
||||
.padding(Self.pad)
|
||||
.consoleGlass(Capsule())
|
||||
// The hairline is DECORATION and sits on top of the cells, so it must never take a touch.
|
||||
// Spelled out rather than left to defaults, because a swallowed touch in this bar is
|
||||
// invisible — the legend simply stops doing anything.
|
||||
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1).allowsHitTesting(false))
|
||||
}
|
||||
|
||||
/// A cell is a button where it has somewhere to go, and a plain label otherwise (see the type
|
||||
/// comment for why tvOS is always the latter).
|
||||
@ViewBuilder private func cell(_ hint: GamepadHint) -> some View {
|
||||
#if os(tvOS)
|
||||
label(hint)
|
||||
#else
|
||||
if let action = hint.action {
|
||||
Button(action: action) { label(hint) }
|
||||
.buttonStyle(HintCellStyle())
|
||||
.accessibilityLabel(hint.text)
|
||||
} else {
|
||||
label(hint)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func label(_ hint: GamepadHint) -> some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: hint.glyph)
|
||||
.font(.system(size: metrics.hintGlyphFont))
|
||||
.foregroundStyle(ink.fg)
|
||||
Text(hint.text)
|
||||
}
|
||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||
// The tappable area covers the gap between glyph and label, not just their painted
|
||||
// pixels — a legend cell is small enough already.
|
||||
.contentShape(Rectangle())
|
||||
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// Press feedback for a legend cell. Deliberately quiet — the bar is chrome, and a cell that lit
|
||||
/// up like a primary button would pull the eye off the content it describes.
|
||||
///
|
||||
/// `contentShape` sits BELOW the scale so the hit region stays the unscaled layout bounds: a press
|
||||
/// animation that shrinks the artwork must never move the target out from under a resting finger,
|
||||
/// or the touch-up lands outside and SwiftUI discards the tap.
|
||||
private struct HintCellStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.opacity(configuration.isPressed ? 0.55 : 1)
|
||||
.scaleEffect(configuration.isPressed ? 0.94 : 1)
|
||||
.animation(.smooth(duration: 0.14), value: configuration.isPressed)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind
|
||||
/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` — a continuous
|
||||
/// silk of colour whose control points wander on slow, out-of-phase sinusoids — finished with an
|
||||
@@ -480,20 +220,14 @@ struct GamepadScreenBackground: View {
|
||||
colorField(at: t, palette: palette)
|
||||
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||
// Calm = col·0.6 + ground·0.4. Over the OPAQUE ground beneath, `.opacity` already
|
||||
// lerps toward it, so this layer alone IS the whole calm mix.
|
||||
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply…
|
||||
.opacity(1 - 0.4 * calmMix)
|
||||
// A further plusLighter wash of the ground, which lets a DARK palette's bright pools
|
||||
// come down to meet its ground rather than merely fading toward it.
|
||||
//
|
||||
// Suppressed on a pale palette (the factor goes to 0), because there it was destroying
|
||||
// the setting: a pale ground is near-white, so ADDING 0.4 of it on top of a field
|
||||
// already mixed 0.4 toward that same ground saturated the form screens to flat white —
|
||||
// the field ask was "in bright mode the sub-screens are basically just white". Written
|
||||
// as a factor rather than an `if` so the layer stays mounted and the calm chase keeps
|
||||
// animating instead of popping when a screen is pushed.
|
||||
// …and a plusLighter wash of the palette's own ground IS the add. Chosen so the
|
||||
// ground lands exactly where it was and the bright pools come down to meet it.
|
||||
// Mounted unconditionally — at opacity 0 a plusLighter layer contributes nothing,
|
||||
// and an always-present layer is what lets the mix animate instead of popping.
|
||||
Self.color(palette.ground)
|
||||
.opacity(0.4 * calmMix * (palette.light ? 0 : 1))
|
||||
.opacity(0.4 * calmMix)
|
||||
.blendMode(.plusLighter)
|
||||
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
|
||||
// pooled light. Soft (extends past the frame) so the corners deepen rather than
|
||||
@@ -629,6 +363,59 @@ private struct LegacyBlobField: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
|
||||
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
|
||||
/// render interleaved. Pure blur — a dark material faded out by a gradient mask, no dark tint — so
|
||||
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
|
||||
struct GamepadTrayScrim: View {
|
||||
let edge: VerticalEdge
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
|
||||
var body: some View {
|
||||
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
|
||||
let toContent: UnitPoint = edge == .top ? .bottom : .top
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
// Force the frost to match the PALETTE, not the system appearance: the tray exists
|
||||
// to keep the pinned title legible, so it has to frost dark under white ink and
|
||||
// light under dark ink.
|
||||
.environment(\.colorScheme, ink.isLight ? .light : .dark)
|
||||
// Sink the material's grey luminance lift toward the palette's shade (black on a
|
||||
// dark field — field ask: the frost read GREY over the aurora). Inside the mask, so
|
||||
// the tint dissolves with the blur.
|
||||
.overlay(ink.shade(0.35))
|
||||
// Fade the whole blur out toward the content so it dissolves rather than ending on a
|
||||
// line. The strong region sits deep (0.65) because the first stretch of the gradient
|
||||
// now runs over the fixed 80 pt outer overhang below.
|
||||
.mask {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .black, location: 0),
|
||||
.init(color: .black.opacity(0.92), location: 0.65),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: fromEdge, endPoint: toContent)
|
||||
}
|
||||
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
||||
// text always sits on the strong part, rows blur out before they reach it. The bottom
|
||||
// gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and
|
||||
// the field verdict on the short reach was rows colliding visibly with the legend.
|
||||
.padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72)
|
||||
// Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a
|
||||
// beat after insertion (outside any geometry group and outside this view's own
|
||||
// transaction), which is exactly the pop the field kept seeing — vertically first,
|
||||
// then, once the vertical runway became padding, on the X axis alone (the landscape
|
||||
// side insets). 80 pt clears every inset on every device; backgrounds never clip,
|
||||
// so the overhang simply draws.
|
||||
.padding(edge == .top ? .top : .bottom, -80)
|
||||
.padding(.horizontal, -80)
|
||||
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
|
||||
// geometry would ride the push's transaction and visibly grow into place. The
|
||||
// layer's own fade/slide still carries the scrim; only its SHAPE is pinned.
|
||||
.transaction { $0.animation = nil }
|
||||
}
|
||||
}
|
||||
|
||||
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
|
||||
/// of glows over a deep indigo base — deliberately not near-black, because Liquid Glass refracts
|
||||
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
|
||||
|
||||
@@ -65,24 +65,10 @@ private struct HomeTile: Identifiable {
|
||||
|
||||
struct GamepadHomeView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Published by ContentView at the app ROOT, so this reads its own window's tier — this screen
|
||||
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
/// The home-indicator strip's height, measured by DisplayBottomInsetProbe and published from
|
||||
/// ContentView — an environment READ is safe in body; asking UIKit for it here is not (see
|
||||
/// the probe's comment: a key-window walk mid-render severed this very view's updates).
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@ObservedObject var store: HostStore
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
@Binding var libraryTarget: LibraryTarget?
|
||||
/// The host awaiting a PIN ceremony, if any. Owned by ContentView (a connect attempt sets it,
|
||||
/// as does the trust card's "Pair with PIN instead"), presented here as a shell screen —
|
||||
/// PairSheet's `Form` is unreachable with a controller on iOS/macOS, which made pairing the
|
||||
/// one thing a console-UI user simply could not do. See GamepadPairView.
|
||||
@Binding var pairingTarget: StoredHost?
|
||||
/// Pin the verified fingerprint and connect — ContentView's `handlePaired`.
|
||||
let onPaired: (StoredHost, Data) -> Void
|
||||
@Binding var libraryTarget: StoredHost?
|
||||
/// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's
|
||||
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
|
||||
@ObservedObject var waker: HostWaker
|
||||
@@ -90,12 +76,7 @@ struct GamepadHomeView: View {
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
|
||||
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
|
||||
let launchTitle: (LibraryTarget, String) -> Void
|
||||
/// A console prompt (GamepadPromptView) is up over the home — it polls the same controller, so
|
||||
/// this screen must stand down for as long as it is. Same handoff contract as the connect
|
||||
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
|
||||
/// modal and a single A press reaches both.
|
||||
var promptActive = false
|
||||
let launchTitle: (StoredHost, String) -> Void
|
||||
|
||||
/// The profile catalog — pinned host+profile combos render as their own tiles here, which is
|
||||
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
|
||||
@@ -232,38 +213,22 @@ struct GamepadHomeView: View {
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
legend
|
||||
GamepadHintBar(hints: hints)
|
||||
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
||||
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// The pinned controls legend, sitting the SAME distance from the leading and bottom edges of
|
||||
/// the DISPLAY — see `gamepadLegendBottomPadding` for why the bottom number is not simply the
|
||||
/// margin, and why measuring the inset (rather than trying to opt out of it) is what finally
|
||||
/// worked.
|
||||
private var legend: some View {
|
||||
GamepadHintBar(hints: hints)
|
||||
.padding(.leading, legendMargin)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
legendMargin, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
}
|
||||
|
||||
/// The legend pill's distance from the screen's leading and bottom edges.
|
||||
private var legendMargin: CGFloat { compact ? 12 : 18 }
|
||||
|
||||
#if os(iOS)
|
||||
/// The screen the shell shows over the launcher — derived from the same triggers every
|
||||
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
|
||||
private var topScreen: GamepadScreen? {
|
||||
// Pairing leads: it is a ceremony blocking a connect the user already asked for, and it
|
||||
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
|
||||
// it has to win. Backing out of it reveals whatever it interrupted.
|
||||
if let host = pairingTarget { return .pair(host) }
|
||||
if showSettings { return .settings }
|
||||
if showAddHost { return .addHost }
|
||||
if let shelf = libraryTarget { return .library(shelf) }
|
||||
if let host = libraryTarget { return .library(host) }
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -283,16 +248,10 @@ struct GamepadHomeView: View {
|
||||
onAdd: { store.add($0) },
|
||||
close: { if !transitioning { showAddHost = false } },
|
||||
controllerActive: active)
|
||||
case .pair(let host):
|
||||
GamepadPairView(
|
||||
host: host,
|
||||
onPaired: { onPaired(host, $0) },
|
||||
close: { if !transitioning { pairingTarget = nil } },
|
||||
controllerActive: active)
|
||||
case .library(let shelf):
|
||||
case .library(let host):
|
||||
GamepadLibraryScreen(
|
||||
store: store, target: shelf,
|
||||
onLaunch: { launchTitle(shelf, $0) },
|
||||
store: store, host: host,
|
||||
onLaunch: { launchTitle(host, $0) },
|
||||
close: { if !transitioning { libraryTarget = nil } },
|
||||
controllerActive: active)
|
||||
}
|
||||
@@ -335,14 +294,11 @@ struct GamepadHomeView: View {
|
||||
/// transition's input drop, during which NOBODY polls.
|
||||
private var homeOwnsController: Bool {
|
||||
#if os(iOS)
|
||||
topScreen == nil && !transitioning && !promptActive
|
||||
topScreen == nil && !transitioning
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
#else
|
||||
// `pairingTarget` too: macOS presents the pair screen as a sheet and tvOS as a cover, and
|
||||
// either way the launcher underneath must stop consuming the pad — the pair screen's own
|
||||
// list is polling the same controller.
|
||||
libraryTarget == nil && pairingTarget == nil && !showSettings && !showAddHost
|
||||
&& !promptActive && waker.waking == nil && model.phase != .connecting
|
||||
libraryTarget == nil && !showSettings && !showAddHost
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -456,22 +412,13 @@ struct GamepadHomeView: View {
|
||||
case .rescan: "Rescan"
|
||||
default: nil
|
||||
}
|
||||
// Every cell's action re-resolves the selection when it FIRES rather than closing over the
|
||||
// one this render saw: the legend is rebuilt on selection changes, but a tap landing in
|
||||
// the same frame as a carousel move would otherwise activate the tile that was selected a
|
||||
// moment ago — the one failure mode a launcher cannot afford.
|
||||
var hints = [GamepadHint(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"),
|
||||
action: { tiles.first { $0.id == selection }?.activate() })]
|
||||
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
if libraryEnabled, selected?.hasLibrary == true {
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
|
||||
action: { openLibraryForSelected() }))
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||
}
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
|
||||
action: { showSettings = true }))
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings"))
|
||||
return hints
|
||||
}
|
||||
|
||||
@@ -501,10 +448,9 @@ struct GamepadHomeView: View {
|
||||
isPaired: host.pinnedSHA256 != nil,
|
||||
isConnecting: connecting,
|
||||
filled: true,
|
||||
// A pinned card reaches the library too, and gets its OWN shelf: browsing is
|
||||
// this card's connect with a title picked first, not a host-level action like
|
||||
// wake or forget.
|
||||
hasLibrary: true,
|
||||
// A pinned card is a shortcut, not a second host — Y (library) stays on the
|
||||
// host's own tile, where the host-level actions live.
|
||||
hasLibrary: profile == nil,
|
||||
osChain: host.osChain,
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !online && !host.wakeMacs.isEmpty,
|
||||
@@ -540,14 +486,12 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
|
||||
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
|
||||
/// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its
|
||||
/// own shelf: the selection already names which card Y was pressed on, and that card's profile
|
||||
/// is what its launches run with.
|
||||
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
|
||||
private func openLibraryForSelected() {
|
||||
guard libraryEnabled, case .saved(let id, let profileID) = selection,
|
||||
guard libraryEnabled, case .saved(let id, let profile) = selection, profile == nil,
|
||||
let host = store.hosts.first(where: { $0.id == id })
|
||||
else { return }
|
||||
libraryTarget = LibraryTarget(host: host, profile: ProfileSelection(profileID: profileID))
|
||||
libraryTarget = host
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,21 +573,11 @@ private struct GamepadHostTile: View {
|
||||
}
|
||||
.padding(Self.pad)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
// Console tile — a brand wash marks a saved host as primary; discovered / Add-Host tiles
|
||||
// stay neutral with a dashed edge. The surface clips to the shape itself.
|
||||
//
|
||||
// `forceMaterial`: these tiles are the one console surface that gets TRANSFORMED while it
|
||||
// animates — `CardEntrance` swings each card in on a `rotation3DEffect` under an opacity
|
||||
// ramp, and the carousel's `.scrollTransition` keeps scaling and rotating the neighbours
|
||||
// forever after. Liquid Glass samples the backdrop through its own layer and cannot do
|
||||
// that under a 3D transform, so it drew one way through the swing and snapped to another
|
||||
// as the card landed — on glass it read as the tiles being swapped out for different ones
|
||||
// at the end of their entrance. A material composites flat, so the card looks the same at
|
||||
// every frame of the travel. (tvOS already takes this path for its own reasons.)
|
||||
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
||||
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
||||
tint: tile.filled ? ink.accent(0.20) : nil,
|
||||
forceMaterial: true)
|
||||
tint: tile.filled ? ink.accent(0.20) : nil)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||
.strokeBorder(
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// Hardware-keyboard navigation for the gamepad UI (iOS/iPadOS/macOS): arrows move, Return/Space
|
||||
// activate, Esc backs out.
|
||||
//
|
||||
// Asked for by a field user on an iPad ("select games with keyboard arrows, enter to launch"). An
|
||||
// iPad on a Magic Keyboard and a couch Mac are the same situation the console layout was built
|
||||
// for — a screen driven from a distance with a fixed set of directional inputs — and the whole
|
||||
// navigation model (a cursor, a confirm, a back) already exists here for the controller. A
|
||||
// keyboard is just a third input onto it, alongside the pad poll and touch.
|
||||
//
|
||||
// tvOS is excluded: the focus engine already routes hardware-keyboard arrows into focus moves
|
||||
// there, and these screens hand it navigation authority on purpose.
|
||||
//
|
||||
// The view must be FOCUSED to receive key presses, so this takes focus on appear. That is safe on
|
||||
// exactly these screens because the gamepad UI has no system text fields to steal it from —
|
||||
// GamepadKeyboard is a custom grid of keycaps, not a `TextField`.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
extension View {
|
||||
/// Route arrows / Return / Esc into the same handlers the controller poll drives.
|
||||
///
|
||||
/// `active` mirrors the caller's `isActive` controller gate: a screen that has handed the pad
|
||||
/// to something on top must not keep eating key presses either, or a covered launcher
|
||||
/// navigates behind the screen in front of it.
|
||||
func gamepadKeyNavigation(
|
||||
active: Bool = true,
|
||||
onMove: @escaping (GamepadMenuInput.Direction) -> Void,
|
||||
onConfirm: @escaping () -> Void,
|
||||
onBack: (() -> Void)? = nil
|
||||
) -> some View {
|
||||
modifier(GamepadKeyNav(active: active, onMove: onMove, onConfirm: onConfirm, onBack: onBack))
|
||||
}
|
||||
}
|
||||
|
||||
private struct GamepadKeyNav: ViewModifier {
|
||||
let active: Bool
|
||||
let onMove: (GamepadMenuInput.Direction) -> Void
|
||||
let onConfirm: () -> Void
|
||||
let onBack: (() -> Void)?
|
||||
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.focusable(active)
|
||||
// No focus ring: these screens draw their own cursor (the centred card, the focused
|
||||
// row), and a system ring around the whole scroll view on top of it reads as a bug.
|
||||
.focusEffectDisabled()
|
||||
.focused($focused)
|
||||
// Claim focus on appear, and re-claim it whenever this screen becomes the active one
|
||||
// again — a pushed screen popping off leaves the one underneath unfocused.
|
||||
.onAppear { focused = active }
|
||||
.onChange(of: active) { _, nowActive in
|
||||
if nowActive { focused = true }
|
||||
}
|
||||
.onKeyPress(.upArrow) { handle { onMove(.up) } }
|
||||
.onKeyPress(.downArrow) { handle { onMove(.down) } }
|
||||
.onKeyPress(.leftArrow) { handle { onMove(.left) } }
|
||||
.onKeyPress(.rightArrow) { handle { onMove(.right) } }
|
||||
.onKeyPress(.return) { handle(onConfirm) }
|
||||
.onKeyPress(.space) { handle(onConfirm) }
|
||||
.onKeyPress(.escape) {
|
||||
guard let onBack else { return .ignored }
|
||||
return handle(onBack)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a handler only while this screen owns input, and report back whether the press was
|
||||
/// consumed. `.ignored` matters: an unhandled Esc still has to reach the `.cancelAction`
|
||||
/// shortcut that closes a macOS sheet (see GamepadAddHostView's hidden Cancel button).
|
||||
private func handle(_ action: () -> Void) -> KeyPress.Result {
|
||||
guard active else { return .ignored }
|
||||
action()
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -12,26 +12,24 @@ import SwiftUI
|
||||
struct GamepadLibraryScreen: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@ObservedObject var store: HostStore
|
||||
let target: LibraryTarget
|
||||
let host: StoredHost
|
||||
let onLaunch: (String) -> Void
|
||||
let close: () -> Void
|
||||
var controllerActive = true
|
||||
|
||||
/// `.compact` in a landscape phone window — tighter chrome, like every gamepad screen.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
/// Resolves a pinned shelf's profile name for the title.
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
|
||||
var body: some View {
|
||||
LibraryView(
|
||||
store: store, target: target, onLaunch: onLaunch,
|
||||
store: store, host: host, onLaunch: onLaunch,
|
||||
onClose: close, controllerActive: controllerActive)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
// Leading, like every gamepad heading — no close chrome, B is the exit (the
|
||||
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
|
||||
Text("\(target.title(in: profiles)) — Library")
|
||||
Text("\(host.displayName) — Library")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
@@ -40,7 +38,7 @@ struct GamepadLibraryScreen: View {
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
// A hardware keyboard's Esc still closes, without chrome.
|
||||
.background {
|
||||
|
||||
@@ -119,22 +119,6 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
.sensoryFeedback(.selection, trigger: adjustTick)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
#if os(iOS) || os(macOS)
|
||||
// Hardware keyboard: up/down step the focus bar, left/right adjust the focused row's
|
||||
// value (exactly what the stick does), Return activates, Esc backs out.
|
||||
.gamepadKeyNavigation(
|
||||
active: isActive,
|
||||
onMove: { direction in
|
||||
switch direction {
|
||||
case .up: step(by: -1)
|
||||
case .down: step(by: 1)
|
||||
case .left: adjust(by: -1)
|
||||
case .right: adjust(by: 1)
|
||||
}
|
||||
},
|
||||
onConfirm: { activate() },
|
||||
onBack: onBack)
|
||||
#endif
|
||||
.onAppear {
|
||||
reconcile()
|
||||
wire()
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
// The gamepad UI's answer to a system alert / confirmation dialog (iOS/iPadOS/macOS).
|
||||
//
|
||||
// `.alert` and `.confirmationDialog` are UIKit/AppKit surfaces. A game controller cannot move
|
||||
// through their buttons or press one — so on iOS/macOS every prompt in the connect path was a dead
|
||||
// end for a pad-only user, and they are not incidental prompts:
|
||||
//
|
||||
// - "Pairing required" (Request Access / Pair with PIN…) is the FIRST thing an unpaired host
|
||||
// shows. Pairing was unreachable before it even got to the PIN.
|
||||
// - "Connection failed" strands the console UI behind a modal only a finger can dismiss.
|
||||
// - "Waiting for approval" owns the only Cancel for a connect that may never complete.
|
||||
//
|
||||
// tvOS keeps the system alerts: the focus engine drives them natively there, which is the whole
|
||||
// reason this gap was tvOS-invisible.
|
||||
//
|
||||
// Deliberately NOT built on GamepadMenuList: that is a ScrollView (right for a settings screen of
|
||||
// unknown length, wrong for two buttons in a card, where it would need an invented height and
|
||||
// could clip). A prompt has two or three actions, so it owns a plain VStack and a cursor.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
/// One choice in a console prompt.
|
||||
struct GamepadPromptAction: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
/// This is the action B (and Esc) performs, and the one the cursor opens on. Exactly one
|
||||
/// action should carry it — `GamepadPrompt` falls back to the LAST action when none does,
|
||||
/// which matches how a system alert treats its cancel role.
|
||||
var isCancel = false
|
||||
/// Drawn as the primary, accent-tinted row. At most one.
|
||||
var isPrimary = false
|
||||
let run: () -> Void
|
||||
}
|
||||
|
||||
/// A prompt to show over the console UI: what happened, and what can be done about it.
|
||||
struct GamepadPrompt: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let message: String
|
||||
let actions: [GamepadPromptAction]
|
||||
/// A wait with no outcome yet (the delegated-approval hold) shows a spinner beside the title —
|
||||
/// the prompt is the UI for something still in flight, not a report that it finished.
|
||||
var busy = false
|
||||
}
|
||||
|
||||
/// The prompt, worn as the console's own modal: a dimmed field, a glass card, a focus list of
|
||||
/// actions, and the same legend every other gamepad screen carries.
|
||||
struct GamepadPromptView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
let prompt: GamepadPrompt
|
||||
|
||||
@State private var cursor = 0
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
/// `.sensoryFeedback` counters — device ticks for confirm and for a refused move at an end.
|
||||
@State private var activateTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Swallows touch to the launcher behind it, which is also gated out of the controller
|
||||
// poll for as long as this is up (ContentView's `promptActive`).
|
||||
Rectangle()
|
||||
.fill(.black.opacity(0.55))
|
||||
.ignoresSafeArea()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {}
|
||||
card
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
// A prompt is exactly where a keyboard user gets stuck, so it takes arrows/Return/Esc too.
|
||||
.gamepadKeyNavigation(
|
||||
onMove: { direction in
|
||||
switch direction {
|
||||
case .up: step(by: -1)
|
||||
case .down: step(by: 1)
|
||||
case .left, .right: break
|
||||
}
|
||||
},
|
||||
onConfirm: { activate() },
|
||||
onBack: { back() })
|
||||
.onAppear {
|
||||
cursor = prompt.actions.firstIndex(where: \.isCancel) ?? max(prompt.actions.count - 1, 0)
|
||||
wire()
|
||||
input.start()
|
||||
}
|
||||
// The prompt's identity is stable across a message change (same `id`), so re-wire rather
|
||||
// than rely on a remount: the stored closures captured the OLD actions array.
|
||||
.onChange(of: prompt.actions.map(\.id)) { _, _ in
|
||||
cursor = min(cursor, max(prompt.actions.count - 1, 0))
|
||||
wire()
|
||||
}
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private var card: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 10) {
|
||||
if prompt.busy {
|
||||
ProgressView().controlSize(.small).tint(ink.fg(0.8))
|
||||
}
|
||||
Text(prompt.title)
|
||||
.font(.geist(compact ? 19 : 22, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(ink.fg)
|
||||
}
|
||||
Text(prompt.message)
|
||||
.font(.geist(metrics.detailFont, relativeTo: .callout))
|
||||
.foregroundStyle(ink.fg(0.62))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
VStack(spacing: 6) {
|
||||
ForEach(Array(prompt.actions.enumerated()), id: \.element.id) { idx, action in
|
||||
actionRow(action, focused: idx == cursor)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(idx) }
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
GamepadHintBar(hints: hints)
|
||||
}
|
||||
.padding(compact ? 20 : 26)
|
||||
.frame(maxWidth: 460)
|
||||
.consoleGlass(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(ink.fg(0.12), lineWidth: 1)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
private func actionRow(_ action: GamepadPromptAction, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
return Text(action.title)
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(action.isPrimary ? ink.accent : ink.fg)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: focused ? ink.accent(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
}
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = [.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
||||
action: { activate() })]
|
||||
// Only where B has somewhere to go: a one-action prompt ("OK") is dismissed by that
|
||||
// action, and B does it too — naming it twice would just be noise.
|
||||
if prompt.actions.count > 1, let cancel = prompt.actions.first(where: \.isCancel) {
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: cancel.title,
|
||||
action: { back() }))
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
private func wire() {
|
||||
input.onMove = { direction in
|
||||
switch direction {
|
||||
case .up: step(by: -1)
|
||||
case .down: step(by: 1)
|
||||
// A prompt's actions are a vertical list; left/right have nothing to mean here, and
|
||||
// silently treating them as up/down would make a nudged stick pick a different button.
|
||||
case .left, .right: break
|
||||
}
|
||||
}
|
||||
input.onConfirm = { activate() }
|
||||
input.onBack = { back() }
|
||||
}
|
||||
|
||||
private func step(by delta: Int) {
|
||||
let target = cursor + delta
|
||||
guard target >= 0, target < prompt.actions.count else {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
return
|
||||
}
|
||||
cursor = target
|
||||
haptics.move()
|
||||
}
|
||||
|
||||
private func activate() {
|
||||
guard cursor >= 0, cursor < prompt.actions.count else { return }
|
||||
activateTick &+= 1
|
||||
haptics.confirm()
|
||||
prompt.actions[cursor].run()
|
||||
}
|
||||
|
||||
/// B: the cancel action, else the last one — the same fallback a system alert applies when
|
||||
/// nothing carries the cancel role, so B always has a way out rather than doing nothing.
|
||||
private func back() {
|
||||
guard let action = prompt.actions.first(where: \.isCancel) ?? prompt.actions.last
|
||||
else { return }
|
||||
activateTick &+= 1
|
||||
haptics.confirm()
|
||||
action.run()
|
||||
}
|
||||
|
||||
/// Touch fallback matching the rest of the gamepad UI: a tap focuses AND activates.
|
||||
private func tap(_ idx: Int) {
|
||||
guard idx >= 0, idx < prompt.actions.count else { return }
|
||||
cursor = idx
|
||||
activate()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -21,17 +21,13 @@ import SwiftUI
|
||||
enum GamepadScreen: Identifiable {
|
||||
case settings
|
||||
case addHost
|
||||
case pair(StoredHost)
|
||||
case library(LibraryTarget)
|
||||
case library(StoredHost)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .settings: return "settings"
|
||||
case .addHost: return "addHost"
|
||||
case .pair(let host): return "pair-\(host.id.uuidString)"
|
||||
// Keyed on the SHELF, not the host: a host and each of its pinned cards open different
|
||||
// libraries, and sharing an id would let one stand in for another mid-transition.
|
||||
case .library(let shelf): return "library-\(shelf.id)"
|
||||
case .library(let host): return "library-\(host.id.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +35,7 @@ enum GamepadScreen: Identifiable {
|
||||
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
||||
var isForm: Bool {
|
||||
switch self {
|
||||
case .settings, .addHost, .pair: return true
|
||||
case .settings, .addHost: return true
|
||||
case .library: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ struct HomeView: View {
|
||||
@Binding var showAddHost: Bool
|
||||
@Binding var pairingTarget: StoredHost?
|
||||
@Binding var speedTestTarget: StoredHost?
|
||||
@Binding var libraryTarget: LibraryTarget?
|
||||
@Binding var libraryTarget: StoredHost?
|
||||
#if !os(macOS)
|
||||
@Binding var showSettings: Bool
|
||||
#endif
|
||||
@@ -34,9 +34,8 @@ struct HomeView: View {
|
||||
let connectDiscovered: (DiscoveredHost) -> Void
|
||||
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
||||
let onPaired: (StoredHost, Data) -> Void
|
||||
/// Picked a title in the (experimental) library — start a session that launches it, with the
|
||||
/// shelf's profile (a pinned card's own; the host's binding on its primary card).
|
||||
let onLaunchTitle: (LibraryTarget, String) -> Void
|
||||
/// Picked a title in the (experimental) library — start a session that launches it.
|
||||
let onLaunchTitle: (StoredHost, String) -> Void
|
||||
/// Explicit Wake-on-LAN of an offline host — fires the packet and waits for it to come online
|
||||
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
|
||||
let wake: (StoredHost) -> Void
|
||||
@@ -155,8 +154,8 @@ struct HomeView: View {
|
||||
.navigationDestination(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
}
|
||||
.navigationDestination(item: $libraryTarget) { shelf in
|
||||
LibraryView(store: store, target: shelf, onLaunch: { onLaunchTitle(shelf, $0) })
|
||||
.navigationDestination(item: $libraryTarget) { host in
|
||||
LibraryView(store: store, host: host, onLaunch: { onLaunchTitle(host, $0) })
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
@@ -264,13 +263,9 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View {
|
||||
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
|
||||
// A pinned card connects with ITS profile; the primary card follows the binding.
|
||||
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
|
||||
// …and browsing is that same connect with a title picked first, so a pinned card opens its
|
||||
// OWN shelf: every launch off it carries the card's profile rather than the host's binding.
|
||||
let onBrowseLibrary: (() -> Void)? = libraryEnabled
|
||||
? { libraryTarget = LibraryTarget(host: host, profile: selection) }
|
||||
: nil
|
||||
return HostCardView(
|
||||
host: host,
|
||||
isOnline: isOnline(host),
|
||||
|
||||
@@ -219,13 +219,6 @@ struct HostCardView: View {
|
||||
// the way to remove the shortcut itself. Unpinning touches neither the profile nor
|
||||
// the host's default binding.
|
||||
connectWithMenu(menu)
|
||||
// Browsing IS a connect-shaped action — it is this card's connect with a title picked
|
||||
// first — so a pinned card offers it and opens its own shelf, whose launches carry the
|
||||
// pinned profile. (Pair / speed test / wake / forget stay on the host's card: those
|
||||
// are about the machine, and a shortcut has no business claiming them.)
|
||||
if let onBrowseLibrary {
|
||||
Button("Browse Library…", action: onBrowseLibrary)
|
||||
}
|
||||
if LinkClipboard.isAvailable {
|
||||
Button("Copy Link") { menu.copyLink(pinned.id) }
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ struct LibraryCoverflowView: View {
|
||||
) -> some View {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
icon: game.iconToken, onLoaded: { artSettled += 1 })
|
||||
onLoaded: { artSettled += 1 })
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) {
|
||||
@@ -204,19 +204,13 @@ struct LibraryCoverflowView: View {
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = []
|
||||
if let onLaunch {
|
||||
if onLaunch != nil {
|
||||
// You *open* a launcher and *launch* a game — the hint follows the focused entry.
|
||||
let opens = games.first { $0.id == selection }?.isLauncher == true
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch",
|
||||
// Reads `selection` when it fires, not when the legend was built (see the
|
||||
// launcher's twin) — and does nothing with no title centred, which is exactly
|
||||
// what A does.
|
||||
action: { if let id = selection { onLaunch(id) } }))
|
||||
hints.append(
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
|
||||
}
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close",
|
||||
action: { onDismiss?() }))
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
|
||||
return hints
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,53 +6,11 @@
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Which library shelf is open: a host, and — when it was opened from a PINNED host+profile card
|
||||
/// (design/client-settings-profiles.md §5.2a) — that card's profile, which every title launched off
|
||||
/// the shelf then runs with, exactly as the card's own tap would.
|
||||
///
|
||||
/// One value rather than a host plus a profile carried beside it: a host and its pinned cards are
|
||||
/// different cards on the grid, so "which library" is not answered by the host alone. That is also
|
||||
/// why `id` folds the profile in — a presentation keyed on the host would not re-present when you
|
||||
/// move between a host's own shelf and one of its pins.
|
||||
struct LibraryTarget: Identifiable, Hashable {
|
||||
let host: StoredHost
|
||||
/// `.inherit` from the host's own card (its binding decides, as it always has); `.profile` from
|
||||
/// a pinned card. `.defaults` never reaches here — nothing opens a library "with the globals".
|
||||
var profile: ProfileSelection = .inherit
|
||||
|
||||
var id: String {
|
||||
switch profile {
|
||||
case .inherit: host.id.uuidString
|
||||
case .defaults: "\(host.id.uuidString)#defaults"
|
||||
case .profile(let id): "\(host.id.uuidString)#\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
/// The pinned profile's id, if this shelf belongs to a pinned card.
|
||||
var pinnedProfileID: String? {
|
||||
if case .profile(let id) = profile { return id }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// What the screen calls itself: the host, and the profile when a pinned card opened it — the
|
||||
/// same `host · profile` shape that card wears, so which shelf you are on is on screen rather
|
||||
/// than remembered from the card you pressed. A pin whose profile has since been deleted
|
||||
/// resolves as no profile everywhere else, and reads as the plain host here.
|
||||
@MainActor func title(in catalog: ProfileStore) -> String {
|
||||
guard let id = pinnedProfileID, let profile = catalog.profile(id: id) else {
|
||||
return host.displayName
|
||||
}
|
||||
return "\(host.displayName) \u{b7} \(profile.name)"
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryView: View {
|
||||
@ObservedObject var store: HostStore
|
||||
/// The shelf being browsed — the host, plus the pinned profile when a pinned card opened it.
|
||||
let target: LibraryTarget
|
||||
let host: StoredHost
|
||||
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
|
||||
/// through). `nil` ⇒ browse-only (cards aren't tappable). The PROFILE a launch runs with is the
|
||||
/// caller's to apply: it holds `target` and connects with `target.profile`.
|
||||
/// through). `nil` ⇒ browse-only (cards aren't tappable).
|
||||
var onLaunch: ((String) -> Void)? = nil
|
||||
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil — every sheet/cover
|
||||
/// presentation — falls back to the environment dismiss.
|
||||
@@ -62,12 +20,6 @@ struct LibraryView: View {
|
||||
/// default (their being up IS the launcher's gate).
|
||||
var controllerActive = true
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// Resolves a pinned shelf's profile NAME for the title (the target carries only its id).
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
|
||||
/// The host this shelf belongs to — every fetch, every poster URL and the launch itself address
|
||||
/// it, and a pinned shelf is the same host seen through one of its cards.
|
||||
private var host: StoredHost { target.host }
|
||||
|
||||
@State private var games: [GameEntry] = []
|
||||
@State private var loading = false
|
||||
@@ -75,13 +27,6 @@ struct LibraryView: View {
|
||||
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
|
||||
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
|
||||
@State private var artLoader: LibraryArtLoader?
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
|
||||
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
|
||||
/// they didn't ask for.
|
||||
@State private var keyCursor: String?
|
||||
@State private var gridWidth: CGFloat = 0
|
||||
#endif
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
|
||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||
@@ -98,7 +43,7 @@ struct LibraryView: View {
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle("\(target.title(in: profiles)) — Library")
|
||||
.navigationTitle("\(host.displayName) — Library")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
@@ -175,103 +120,34 @@ struct LibraryView: View {
|
||||
let launchers = games.filter(\.isLauncher)
|
||||
let titles = games.filter { !$0.isLauncher }
|
||||
let both = !launchers.isEmpty && !titles.isEmpty
|
||||
return ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !launchers.isEmpty {
|
||||
if both { sectionHeader("Launchers") }
|
||||
tiles(launchers)
|
||||
}
|
||||
if !titles.isEmpty {
|
||||
if both { sectionHeader("Games") }
|
||||
tiles(titles)
|
||||
}
|
||||
return ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !launchers.isEmpty {
|
||||
if both { sectionHeader("Launchers") }
|
||||
tiles(launchers)
|
||||
}
|
||||
.padding()
|
||||
#if os(iOS) || os(macOS)
|
||||
// The grid's own width, reported without affecting layout — a GeometryReader
|
||||
// SIBLING inside a ScrollView would claim the whole viewport. It's what tells the
|
||||
// keyboard cursor how many columns `.adaptive` actually produced, so it is only
|
||||
// measured where that cursor exists.
|
||||
.background {
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.onAppear { gridWidth = geo.size.width }
|
||||
.onChange(of: geo.size.width) { _, w in gridWidth = w }
|
||||
}
|
||||
if !titles.isEmpty {
|
||||
if both { sectionHeader("Games") }
|
||||
tiles(titles)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(iOS) || os(macOS)
|
||||
// Hardware keyboard: arrows pick a title, Return launches it — a field ask from an
|
||||
// iPad user on a Magic Keyboard. The gamepad UI's coverflow has had this via the
|
||||
// controller all along; this is the same thing for the plain grid, which is what an
|
||||
// iPad with a keyboard and NO pad actually sees.
|
||||
.gamepadKeyNavigation(
|
||||
active: onLaunch != nil,
|
||||
onMove: { direction in
|
||||
guard let next = gridNav(launchers: launchers, titles: titles)
|
||||
.move(from: keyCursor, direction) else { return }
|
||||
keyCursor = next
|
||||
withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(next, anchor: .center) }
|
||||
},
|
||||
onConfirm: {
|
||||
guard let onLaunch, let id = keyCursor else { return }
|
||||
onLaunch(id)
|
||||
})
|
||||
#endif
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The keyboard cursor's model over the two grid sections. Rebuilt per press from the live
|
||||
/// sections so it can never point into a stale list.
|
||||
private func gridNav(launchers: [GameEntry], titles: [GameEntry]) -> LibraryGridNav {
|
||||
LibraryGridNav(
|
||||
sections: [launchers, titles].filter { !$0.isEmpty }.map { $0.map(\.id) },
|
||||
columns: columnCount)
|
||||
}
|
||||
|
||||
/// How many columns `.adaptive(minimum:spacing:)` fits into the measured width — the same
|
||||
/// arithmetic the layout does, so up/down move exactly one visual row rather than a guess.
|
||||
/// Falls back to one column before the first measurement lands.
|
||||
private var columnCount: Int {
|
||||
let minimum: CGFloat = 130 // matches `columns` below on iOS/macOS
|
||||
let spacing: CGFloat = 18
|
||||
// The VStack's `.padding()` is inside the measured width, so take it back off.
|
||||
let usable = gridWidth - 32
|
||||
guard usable > 0 else { return 1 }
|
||||
return max(1, Int((usable + spacing) / (minimum + spacing)))
|
||||
}
|
||||
#endif
|
||||
|
||||
private func tiles(_ entries: [GameEntry]) -> some View {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(entries) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: {
|
||||
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(game.id)
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
|
||||
.id(game.id)
|
||||
GameCard(game: game, artLoader: artLoader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the keyboard cursor is on this tile (always false where there is no keyboard
|
||||
/// navigation to have moved it).
|
||||
private func isKeyCursor(_ game: GameEntry) -> Bool {
|
||||
#if os(iOS) || os(macOS)
|
||||
keyCursor == game.id
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private func sectionHeader(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
||||
@@ -388,24 +264,13 @@ private struct LibraryBackCatcher: View {
|
||||
private struct GameCard: View {
|
||||
let game: GameEntry
|
||||
let artLoader: LibraryArtLoader?
|
||||
/// The hardware-keyboard cursor is on this tile — drawn as an accent ring, since the plain
|
||||
/// grid has no other way to say "Return launches THIS one".
|
||||
var selected = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
icon: game.iconToken)
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay {
|
||||
if selected {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(.tint, lineWidth: 3)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
}
|
||||
|
||||
@@ -71,9 +71,6 @@ struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let loader: LibraryArtLoader?
|
||||
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
|
||||
/// no cover art by design, so for those the mark IS the poster — see `placeholder`.
|
||||
var icon: String?
|
||||
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
||||
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
||||
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
||||
@@ -124,26 +121,11 @@ struct PosterImage: View {
|
||||
private var placeholder: some View {
|
||||
ZStack {
|
||||
Rectangle().fill(.quaternary)
|
||||
// A launcher's brand mark, drawn at poster size and tinted like the text it replaces.
|
||||
// `scaledToFit` inside a fraction of the card keeps a non-square master (the Steam mark
|
||||
// is 496×512, Playnite's 1024×1024) in its own aspect ratio rather than stretched.
|
||||
// Falling back to the title is the pre-icon design, so an unshipped mark loses nothing.
|
||||
if let mark = launcherIconImage(for: icon) {
|
||||
GeometryReader { geo in
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: geo.size.width * 0.44, height: geo.size.height * 0.44)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
} else {
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6).
|
||||
// Deliberately thin: every action already has an internal entry point — the deep-link router
|
||||
// (connect / connect-and-launch / connect-with-a-profile, and the `browse` route into a host's
|
||||
// library), the in-process end-session hook, and the existing Wake-on-LAN path — so these
|
||||
// intents only wrap them.
|
||||
// (connect / connect-and-launch / connect-with-a-profile), the in-process end-session hook, and
|
||||
// the existing Wake-on-LAN path — so these intents only wrap them.
|
||||
//
|
||||
// Connect and Wake compile on macOS and tvOS too: AppIntents is genuinely available there
|
||||
// (macOS 13+ / tvOS 16+), and "Stream Desktop with Work" from Spotlight on a Mac is part of the
|
||||
@@ -52,28 +51,6 @@ struct ConnectToHostIntent: AppIntent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Jump straight into a host's game library — no session. Foregrounds the app and routes the
|
||||
/// `browse` route through the same `.onOpenURL` path a widget tap uses, which drives the one
|
||||
/// `libraryTarget` every surface shares — so the shortcut lands in whichever library presentation
|
||||
/// the current mode owns: the gamepad console's library screen when the gamepad UI is active, the
|
||||
/// touch/desktop library otherwise. A session starts only when a title is picked there.
|
||||
struct OpenLibraryIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Open Game Library"
|
||||
static let description = IntentDescription(
|
||||
"Open a host's game library in Punktfunk, without starting a stream.")
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Host") var host: HostEntity
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
let url = DeepLink.browse(host: host.id).url
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake a sleeping host (magic packet). No `openAppWhenRun` — usable in automations ("when I get
|
||||
/// home, wake the tower") without foregrounding the app.
|
||||
struct WakeHostIntent: AppIntent {
|
||||
@@ -120,13 +97,6 @@ struct PunktfunkShortcuts: AppShortcutsProvider {
|
||||
"Stream \(\.$host) with \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Connect", systemImageName: "play.tv.fill")
|
||||
AppShortcut(
|
||||
intent: OpenLibraryIntent(),
|
||||
phrases: [
|
||||
"Open \(\.$host) library in \(.applicationName)",
|
||||
"Show \(\.$host) games in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Game Library", systemImageName: "square.grid.2x2.fill")
|
||||
AppShortcut(
|
||||
intent: WakeHostIntent(),
|
||||
phrases: [
|
||||
|
||||
@@ -43,27 +43,10 @@ enum ScreenshotMode {
|
||||
/// readiness ping for the capture script.
|
||||
struct ScreenshotHostView: View {
|
||||
let scene: ShotScene
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
#endif
|
||||
|
||||
/// The gamepad UI's form-metric tier, published here for the same reason ContentView does it:
|
||||
/// this harness mounts those screens DIRECTLY, with no ContentView in the tree, so without it
|
||||
/// an iPad capture renders every gamepad screen at iPhone scale — a capture that doesn't look
|
||||
/// like the app.
|
||||
private var gamepadMetrics: GamepadFormMetrics {
|
||||
#if os(iOS)
|
||||
.forWindow(h: hSizeClass, v: vSizeClass)
|
||||
#else
|
||||
.platformDefault
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
scene.make()
|
||||
.environment(\.colorScheme, scene.colorScheme)
|
||||
.environment(\.gamepadMetrics, gamepadMetrics)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
|
||||
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
|
||||
|
||||
@@ -242,8 +242,7 @@ private struct ShotGamepadHome: View {
|
||||
var body: some View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
}
|
||||
}
|
||||
@@ -301,8 +300,7 @@ private struct ShotConnect: View {
|
||||
if gamepadUI {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
|
||||
@@ -70,14 +70,9 @@ final class SessionModel: ObservableObject {
|
||||
/// session ends depends on where it came FROM: a title launched out of the library belongs back
|
||||
/// in that library when its game exits, not on the host-selection screen.
|
||||
private var launchedTitleID: String?
|
||||
/// WHICH library shelf that title was launched from — a host's own, or one of its pinned
|
||||
/// host+profile cards (§5.2a). The host alone would not answer it: a pinned card's shelf
|
||||
/// launches with that card's profile, so returning to the host's default shelf would quietly
|
||||
/// change what the next title streams with.
|
||||
private var launchedShelf: LibraryTarget?
|
||||
/// Set when a session ended because its game exited and it began as a library launch: the
|
||||
/// shelf to reopen. The view layer consumes it and sets it back to nil.
|
||||
@Published var returnToLibrary: LibraryTarget?
|
||||
/// Set when a session ended because its game exited and it began as a library launch: the host
|
||||
/// whose library to reopen. The view layer consumes it and sets it back to nil.
|
||||
@Published var returnToLibrary: StoredHost?
|
||||
/// The settings THIS session runs on — the globals with its profile overlaid, resolved once at
|
||||
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
|
||||
/// the readers that live in PunktfunkKit and can't see this model.
|
||||
@@ -280,9 +275,6 @@ final class SessionModel: ObservableObject {
|
||||
func connect(to host: StoredHost, effective: EffectiveSettings,
|
||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||
launchID: String? = nil,
|
||||
/// The library shelf `launchID` was picked on, so a game exit can return to it.
|
||||
/// Only meaningful alongside a `launchID`; nil for a plain desktop connect.
|
||||
shelf: LibraryTarget? = nil,
|
||||
allowTofu: Bool = false,
|
||||
autoTrust: Bool = false,
|
||||
requestAccess: Bool = false,
|
||||
@@ -291,7 +283,6 @@ final class SessionModel: ObservableObject {
|
||||
phase = .connecting
|
||||
activeHost = host
|
||||
launchedTitleID = launchID
|
||||
launchedShelf = shelf
|
||||
errorMessage = nil
|
||||
settings = effective
|
||||
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
|
||||
@@ -672,7 +663,6 @@ final class SessionModel: ObservableObject {
|
||||
activeHost = nil
|
||||
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
|
||||
launchedTitleID = nil
|
||||
launchedShelf = nil
|
||||
phase = .idle
|
||||
fps = 0
|
||||
mbps = 0
|
||||
@@ -702,16 +692,13 @@ final class SessionModel: ObservableObject {
|
||||
// a plain desktop session has no library to return to.
|
||||
let host = activeHost
|
||||
let cameFromLibrary = launchedTitleID != nil
|
||||
// The shelf it came off — falling back to the host's own if a caller launched a title
|
||||
// without naming one, which is what that launch effectively browsed.
|
||||
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
|
||||
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
||||
switch reason {
|
||||
case .gameExited:
|
||||
// The player quit their own game. Not a failure, and they are probably after the next
|
||||
// title — so no banner, and back to the library it came from.
|
||||
if cameFromLibrary, host != nil, let shelf {
|
||||
returnToLibrary = shelf
|
||||
if cameFromLibrary, let host {
|
||||
returnToLibrary = host
|
||||
}
|
||||
case .hostEnded, .local:
|
||||
// Someone asked for this: an operator "End" on the host, or our own close racing in.
|
||||
|
||||
@@ -54,15 +54,6 @@ struct AcknowledgementsView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Swift packages")
|
||||
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||
Text("Punktfunk uses Glur (progressive backdrop blur), "
|
||||
+ "© 2023 João Gabriel, under the MIT License.")
|
||||
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||
Text(
|
||||
|
||||
@@ -66,20 +66,22 @@ struct GamepadOptionBand: View {
|
||||
rotation: drumPosition,
|
||||
target: drumPosition,
|
||||
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
|
||||
radius: width * 0.72,
|
||||
width: width)
|
||||
radius: width * 0.72)
|
||||
}
|
||||
}
|
||||
.frame(width: width)
|
||||
.clipped()
|
||||
// NO `.mask` here. The soft edges used to be a gradient mask over the whole band, and a
|
||||
// mask RASTERISES what it covers — which flattens `rotation3DEffect`'s perspective, so the
|
||||
// drum was being composited as a flat sideways slide rather than a turning cylinder. That
|
||||
// is the "3D effect isn't what it should be" the field kept seeing: the geometry was
|
||||
// always right, and the mask was throwing the projection away every frame.
|
||||
//
|
||||
// The same soft edge is folded into each option's own opacity instead (see `Drum.option`),
|
||||
// which costs nothing and leaves the projection intact.
|
||||
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut.
|
||||
.mask {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: .black, location: 0.12),
|
||||
.init(color: .black, location: 0.88),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
}
|
||||
.onChange(of: selection) { old, new in step(from: old, to: new) }
|
||||
// The options list itself can mutate under the drum (a custom resolution appears, a
|
||||
// controller connects, the buffer options re-derive from a new refresh rate) — re-seat
|
||||
@@ -129,9 +131,6 @@ private struct Drum: View, Animatable {
|
||||
let target: Double
|
||||
/// Drum radius in points (from the band width — see the caller).
|
||||
let radius: Double
|
||||
/// The band's own width — the stage the options turn on, and what the edge fade is measured
|
||||
/// against now that the container no longer carries a mask.
|
||||
let width: Double
|
||||
|
||||
var animatableData: Double {
|
||||
get { rotation }
|
||||
@@ -141,17 +140,6 @@ private struct Drum: View, Animatable {
|
||||
/// Angular pitch between adjacent options on the drum.
|
||||
private static let stepAngle = 34.0 * .pi / 180.0
|
||||
|
||||
// Neighbours exist only while the drum is MOVING, and that is not a compromise — it is the
|
||||
// documented field fix this file was written around. Showing them at rest was tried (to make a
|
||||
// settled row look more like a cylinder) and immediately reproduced the original defect: on the
|
||||
// simulator, "This device · 2752 × 2064" rendered with "280 ×" sitting on top of it, and
|
||||
// "Automatic" with "10 Mbps" through it. A long value and its neighbour occupy the same
|
||||
// pixels, and no opacity low enough to fix that is high enough to be worth drawing.
|
||||
//
|
||||
// The cylinder is meant to be READ WHILE IT TURNS. What was actually broken is fixed above:
|
||||
// the band used to mask itself, and the mask rasterised the drum and threw its perspective
|
||||
// away every frame, so the turn never looked like a turn.
|
||||
|
||||
var body: some View {
|
||||
let flight = min(1, abs(rotation - target) * 3)
|
||||
let content = ZStack {
|
||||
@@ -159,18 +147,14 @@ private struct Drum: View, Animatable {
|
||||
// Plain signed distance — the band is linear, so option i has ONE home and the
|
||||
// ends are the ends (nothing waits beyond the last option).
|
||||
let d = Double(i) - rotation
|
||||
// Only the facing option at rest; its neighbours join it for the travel (see the
|
||||
// note on `restingNeighbour`'s removal above).
|
||||
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
|
||||
option(i, distance: d, gate: flight)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Flatten the transform stack — the 10-foot GPU already made these rows drop Liquid
|
||||
// Glass, and several projected texts per step is the same class of cost. It costs the
|
||||
// projection (a rasterised layer has no perspective), which is the trade tvOS already
|
||||
// makes elsewhere on this screen.
|
||||
// Flatten the transform stack while travelling — the 10-foot GPU already made these
|
||||
// rows drop Liquid Glass, and five projected texts per step is the same class of cost.
|
||||
content.drawingGroup()
|
||||
#else
|
||||
content
|
||||
@@ -180,30 +164,17 @@ private struct Drum: View, Animatable {
|
||||
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
|
||||
let angle = d * Self.stepAngle
|
||||
let depth = cos(angle)
|
||||
let x = radius * sin(angle)
|
||||
// The facing option never gates: a resting row still shows its value.
|
||||
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) * edgeFade(x)
|
||||
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
|
||||
Text(options[i])
|
||||
.lineLimit(1)
|
||||
.fixedSize() // never let a turning label re-wrap to the band's width mid-flight
|
||||
.scaleEffect(0.70 + 0.30 * depth)
|
||||
// Foreshorten the label as it turns away — this is what sells the cylinder.
|
||||
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.55)
|
||||
.offset(x: x)
|
||||
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4)
|
||||
.offset(x: radius * sin(angle))
|
||||
.opacity(alpha)
|
||||
.zIndex(depth)
|
||||
}
|
||||
|
||||
/// The soft edge, per option, replacing the container mask that used to flatten the
|
||||
/// projection: full strength through the middle of the band, dissolving to nothing by the
|
||||
/// time an option reaches its rim, so the drum never ends on a cut.
|
||||
private func edgeFade(_ x: Double) -> Double {
|
||||
let halfWidth = width / 2
|
||||
guard halfWidth > 0 else { return 1 }
|
||||
let fadeStart = halfWidth * 0.55
|
||||
guard abs(x) > fadeStart else { return 1 }
|
||||
return max(0, min(1, (halfWidth - abs(x)) / (halfWidth - fadeStart)))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -46,8 +46,6 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
@@ -144,7 +142,7 @@ struct GamepadSettingsView: View {
|
||||
isActive: controllerActive
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -163,12 +161,12 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(focusedDetail)
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
@@ -177,13 +175,10 @@ struct GamepadSettingsView: View {
|
||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.trailing, 22)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .bottom) }
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
// The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real
|
||||
// colour and luminance to lens without the launcher's contrast, and the palette setting
|
||||
@@ -259,18 +254,10 @@ struct GamepadSettingsView: View {
|
||||
private func pill(_ t: GpSettingsTab) -> some View {
|
||||
let selected = t == tab
|
||||
return Text(t.rawValue)
|
||||
.font(.geist(compact ? 12 : metrics.tabFont, .semibold, relativeTo: .footnote))
|
||||
// `onAccent`, not `fg` — the selected pill is FILLED with the palette accent, and
|
||||
// `onAccent` is the colour picked (by the accent's own luminance) to read on top of
|
||||
// it; its doc calls out "a filled pill's label" for exactly this surface. Using the
|
||||
// foreground meant white-on-white wherever a palette's accent is pale: Graphite's is
|
||||
// a light grey (luma ≈ 0.80), so its selected tab was unreadable.
|
||||
.foregroundStyle(selected ? ink.onAccent : ink.fg(0.55))
|
||||
// Proportional to the row metrics rather than fixed, so the strip grows with the
|
||||
// fields under it — a tab bar at phone scale above iPad-scale rows was half the
|
||||
// "does not adapt to larger screens" complaint.
|
||||
.padding(.horizontal, metrics.rowHPad * 0.8)
|
||||
.padding(.vertical, metrics.rowVPad * 0.55)
|
||||
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
|
||||
.foregroundStyle(selected ? ink.fg : ink.fg(0.55))
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
// One shared capsule that MOVES between pills, rather than one per pill fading
|
||||
// in and out — the highlight travels the way the press did. A Liquid Glass
|
||||
@@ -341,39 +328,26 @@ struct GamepadSettingsView: View {
|
||||
// shoulders exist at all (see `showsSectionHint`).
|
||||
let sections: [GamepadHint] = showsSectionHint
|
||||
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
text: "Section", action: { step(tabBy: 1) })]
|
||||
text: "Section")]
|
||||
: []
|
||||
// A dimmed row takes neither, so offering them would be the same lie the row itself
|
||||
// used to tell — only Done remains, and the detail line says what to turn on first.
|
||||
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
|
||||
return sections
|
||||
+ [.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { back() })]
|
||||
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
}
|
||||
return sections + [
|
||||
// The stick itself, not an action — nothing to tap (see GamepadHint.action).
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change",
|
||||
action: { if let focusID { activate(id: focusID) } }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { back() }),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
]
|
||||
}
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { back() })]
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
|
||||
}
|
||||
return [
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin",
|
||||
action: { if let focusID { activate(id: focusID) } }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { back() }),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -391,7 +365,7 @@ struct GamepadSettingsView: View {
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
let m = GamepadFormMetrics.self
|
||||
// No section header: the tab strip names the section now, and repeating it above the
|
||||
// first row of every tab was just a second label saying the same word.
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -469,9 +443,9 @@ struct GamepadSettingsView: View {
|
||||
/// narrows the stage.
|
||||
private var bandWidth: CGFloat {
|
||||
#if os(iOS)
|
||||
hSizeClass == .compact && vSizeClass == .regular ? 170 : metrics.bandWidth
|
||||
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
|
||||
#else
|
||||
metrics.bandWidth
|
||||
GamepadFormMetrics.bandWidth
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||