Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65996621d8 | ||
|
|
c48e60fbb7 | ||
|
|
70684e5079 | ||
|
|
3f8a70dd45 | ||
|
|
2a67c02f7e | ||
|
|
79ee308a7a | ||
|
|
58ee74cb58 | ||
|
|
6ae2ea6708 | ||
|
|
00d4026054 | ||
|
|
dd20a17edb | ||
|
|
8ff2c2e1c6 | ||
|
|
883c317872 | ||
|
|
72777119fd | ||
|
|
81b4f76c4d | ||
|
|
ec44496285 | ||
|
|
d74639de70 | ||
|
|
d4dd5f7a3d | ||
|
|
ea762b849d | ||
|
|
76e8bd1b98 | ||
|
|
fbdad8d917 | ||
|
|
78ad675507 | ||
|
|
b25e6eda91 | ||
|
|
c79d9397fe | ||
|
|
3edb01f1b8 | ||
|
|
5a7f7f0fc5 | ||
|
|
25b08916b6 | ||
|
|
35ba64ca0f | ||
|
|
5f71aeb024 | ||
|
|
e1adc5d6d7 | ||
|
|
76a271b97a | ||
|
|
b53568c99f | ||
|
|
db0637928b | ||
|
|
22bc81238d | ||
|
|
de6b9e94ec | ||
|
|
5ebe840320 | ||
|
|
4b1ce6b905 | ||
|
|
a11c672bea | ||
|
|
cbd0e9664d | ||
|
|
66df1624b6 | ||
|
|
6f07bd94d3 | ||
|
|
d2085879da | ||
|
|
19f637ea6e | ||
|
|
4a0d0ce587 | ||
|
|
0d94ef0dbe | ||
|
|
a1b8627e70 | ||
|
|
91fa32fbb6 | ||
|
|
defdfbdb58 | ||
|
|
8103958169 | ||
|
|
ce8f3e9eaf | ||
|
|
bd383f1820 | ||
|
|
8728d90e01 | ||
|
|
3d4a659959 | ||
|
|
a418d2852a | ||
|
|
110ac9b663 | ||
|
|
1d6f4760f3 | ||
|
|
9dfbc2f895 | ||
|
|
56adb47026 | ||
|
|
52a9d02355 | ||
|
|
e5ca213339 | ||
|
|
e5416646f9 | ||
|
|
b79d90b463 | ||
|
|
8983ec04b9 | ||
|
|
d27e62f7c9 | ||
|
|
0a72959ef7 | ||
|
|
2d223274fc | ||
|
|
173be61213 | ||
|
|
2032c48ffa | ||
|
|
9a52c279f1 | ||
|
|
5be494f490 | ||
|
|
0d5e5b436b | ||
|
|
3a48cc2470 | ||
|
|
64a392634e | ||
|
|
35285afafc | ||
|
|
0d0e7e6861 | ||
|
|
143454590f | ||
|
|
9409d0a04c | ||
|
|
212bdc3b08 | ||
|
|
45cb525035 | ||
|
|
6fed1510ba | ||
|
|
4fd240deab | ||
|
|
e32bd30c85 | ||
|
|
2f1ef44191 | ||
|
|
8ee224e5db | ||
|
|
e8499e6131 | ||
|
|
a10bde39bb | ||
|
|
b5f91d50bb | ||
|
|
ed3d236ab8 |
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assert that a builder image's :latest is the SAME manifest as its content key, and
|
||||
# re-point it when it isn't.
|
||||
#
|
||||
# This is what we do instead of pinning consumers by @sha256: digest
|
||||
# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The
|
||||
# content key is a hash of the ci/ tree, so "which image should :latest be?" has an
|
||||
# answer derivable from the commit alone. Checking it on every run turns :latest from a
|
||||
# tag someone remembered to move into a function of the tree.
|
||||
#
|
||||
# Two different things make them diverge and neither is distinguishable from here:
|
||||
#
|
||||
# - Someone overwrote :latest out of band. Post-fix that needs the push credential,
|
||||
# but it is exactly the H-6 attack and it must not pass silently.
|
||||
# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and
|
||||
# nothing re-points :latest — it stays on the newer build forever while every
|
||||
# consumer pulls a builder that does not match the tree it is building. That bug
|
||||
# predates this script.
|
||||
#
|
||||
# Both are repaired identically, so: repair, and shout. Failing the build instead would
|
||||
# turn a legitimate revert into a red main with no way forward.
|
||||
#
|
||||
# Reads go to the anonymous port, the single write to the authenticated one.
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:?usage: reconcile-latest.sh <image> <content-key>}"
|
||||
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
|
||||
: "${CI_REGISTRY:?CI_REGISTRY not set}"
|
||||
: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}"
|
||||
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}"
|
||||
|
||||
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
|
||||
# Digest of a tag, or empty if the tag does not exist. Never fails the script itself —
|
||||
# "missing" is a state this has to reason about, not an error to abort on.
|
||||
digest_of() {
|
||||
curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \
|
||||
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true
|
||||
}
|
||||
|
||||
key_digest=$(digest_of "$KEY")
|
||||
latest_digest=$(digest_of latest)
|
||||
|
||||
if [ -z "$key_digest" ]; then
|
||||
echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$key_digest" = "$latest_digest" ]; then
|
||||
echo "$IMAGE:latest == :$KEY ($key_digest)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
|
||||
echo " was: ${latest_digest:-<no :latest tag>}"
|
||||
echo " wanted: $key_digest (:$KEY)"
|
||||
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \
|
||||
--data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest"
|
||||
|
||||
now=$(digest_of latest)
|
||||
[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; }
|
||||
echo "$IMAGE:latest re-pointed to $key_digest"
|
||||
@@ -41,9 +41,23 @@ jobs:
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
# Through the ENVIRONMENT, never interpolated into the script body. A `${{ }}` expansion
|
||||
# is a raw textual substitution performed BEFORE the shell sees the line, so a
|
||||
# workflow_dispatch input containing shell syntax executes as this step — and this is the
|
||||
# step holding UPDATE_MANIFEST_KEY, the Ed25519 key every host pins to decide whether an
|
||||
# update is real (2026-08-05 review H-6). As `$INPUT_TAG` it is only ever data.
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.tag }}"
|
||||
TAG="$INPUT_TAG"
|
||||
# Shape-check before the value reaches a URL or a filename: tags are `vX.Y.Z[-suffix]`.
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;;
|
||||
*) echo "refusing to publish for a tag that is not vX.Y.Z: $TAG" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$TAG" in
|
||||
*[!A-Za-z0-9.+_-]*) echo "tag has characters no release tag has: $TAG" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$TAG" in
|
||||
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
|
||||
esac
|
||||
@@ -67,4 +81,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }}
|
||||
run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}"
|
||||
# Same reasoning as the publish step above: the input is data in the environment, never
|
||||
# text spliced into the command line.
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: bash scripts/ci/discord-announce.sh "$INPUT_TAG"
|
||||
|
||||
@@ -29,4 +29,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tier-3 GPU stream benchmark
|
||||
run: bash scripts/bench/gpu-stream.sh "${{ inputs.mode || '1920x1080x120' }}" 12
|
||||
# Through the environment, not interpolated into the command line: a `${{ }}` expansion is
|
||||
# substituted before the shell parses the line, so an input carrying shell syntax would run
|
||||
# as this step (2026-08-05 review H-6).
|
||||
env:
|
||||
BENCH_MODE: ${{ inputs.mode || '1920x1080x120' }}
|
||||
run: bash scripts/bench/gpu-stream.sh "$BENCH_MODE" 12
|
||||
|
||||
@@ -46,7 +46,10 @@ env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
PACKAGE: punktfunk-decky # generic-registry package name
|
||||
PLUGIN: punktfunk # plugin.json "name" == zip top-level dir
|
||||
# The plugin's ON-DISK dir == the zip's top-level dir. Deliberately NOT plugin.json "name"
|
||||
# (that is the brand-cased label Decky lists, and it locates a plugin by matching it, not by
|
||||
# the folder) — see clients/decky/scripts/package.sh.
|
||||
PLUGIN: punktfunk
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
|
||||
+107
-21
@@ -3,13 +3,18 @@
|
||||
# Two very different image families now:
|
||||
#
|
||||
# BUILDER images (punktfunk-rust-ci{,-noble,-arm64cross}, punktfunk-fedora{,44}-rpm)
|
||||
# live on the LAN registry (home-ci-core, 192.168.1.58:5010 — unom/infra
|
||||
# runners/ci-core/) and are CONTENT-KEYED: the tag is a hash of what they are built
|
||||
# from (the ci/ tree, + rust-toolchain.toml for the cross image), and a build only
|
||||
# happens when that key has no manifest yet. A push that doesn't touch ci/ costs one
|
||||
# curl per image (~seconds), pushes nothing over the WAN, and mints no per-SHA tag
|
||||
# debris on the runners — the failure mode that filled the fleet's disks. `:latest`
|
||||
# is re-pushed alongside every new key and is what the consuming workflows pin.
|
||||
# live on the LAN registry (home-ci-core — unom/infra runners/ci-core/) and are
|
||||
# CONTENT-KEYED: the tag is a hash of what they are built from (the ci/ tree, +
|
||||
# rust-toolchain.toml for the cross image), and a build only happens when that key
|
||||
# has no manifest yet. A push that doesn't touch ci/ costs one curl per image
|
||||
# (~seconds), pushes nothing over the WAN, and mints no per-SHA tag debris on the
|
||||
# runners — the failure mode that filled the fleet's disks. `:latest` is re-pushed
|
||||
# alongside every new key and is what the consuming workflows pin.
|
||||
#
|
||||
# READS come from :5010 and need no credential. WRITES go to :5011 and need
|
||||
# CI_REGISTRY_PASSWORD. Same store behind both — a registry keys by repository name,
|
||||
# not by the host:port the client used — so an image pushed to :5011 is the same
|
||||
# image every consumer pulls from :5010.
|
||||
#
|
||||
# APP images (punktfunk-web, punktfunk-docs) are deployables: they keep going to the
|
||||
# Gitea registry (git.unom.io) with :latest + :sha-<8> (+ :vX.Y.Z on tags), because
|
||||
@@ -17,8 +22,38 @@
|
||||
#
|
||||
# Host and clients are intentionally NOT containerized (see CLAUDE.md "What's left").
|
||||
#
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images only —
|
||||
# the LAN registry is unauthenticated inside the LAN).
|
||||
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (app images).
|
||||
# CI_REGISTRY_PASSWORD: repo Actions secret, the LAN registry's push credential for user
|
||||
# `ci`. Generated on ci-core into /srv/ci/stack/registry-secret; rotate in both places.
|
||||
#
|
||||
# --- security-review-2026-08-05 H-6, FIXED 2026-08-05 -------------------------------
|
||||
# The registry used to accept anonymous pushes from any LAN peer, and every
|
||||
# secret-bearing job in this repo runs INSIDE an image pulled from it. Attacker
|
||||
# position #1 of the project's own threat model did not need to break any signing
|
||||
# logic: push one tag, and the next android.yml run executes their code in the same job
|
||||
# that does `echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > release.jks`. Same shape for
|
||||
# rpm.yml (RPM_GPG_PRIVATE_KEY) and android-promote.yml (SERVICE_ACCOUNT_JSON).
|
||||
#
|
||||
# The infra half is done (unom/infra runners/ci-core/): :5010 serves GET/HEAD only and
|
||||
# refuses everything else with 405, :5011 demands basic auth on every request. The half
|
||||
# in this file is done below: pushes and release-tag manifest PUTs authenticate.
|
||||
#
|
||||
# ⚠ On the second half as the review originally worded it — "pin consumers by @sha256:
|
||||
# digest". We deliberately do something else, because after authentication the digest
|
||||
# pin no longer buys what it was meant to buy. The set of people who can overwrite a tag
|
||||
# is now exactly the set who can push to main and edit a pinned digest in this very
|
||||
# file: a pin defends against nobody it did not already trust, while costing a
|
||||
# two-commit dance on every ci/ change (~3x a month) during which consumers silently run
|
||||
# a builder image that predates the ci/ change they are testing.
|
||||
#
|
||||
# What actually closes the residual gap — a tag quietly overwritten out of band — is
|
||||
# making :latest a CHECKED function of the tree instead of a tag someone remembered to
|
||||
# move. The "Reconcile :latest" step below asserts on every run that :latest and
|
||||
# :ck-$KEY are the same digest, repairs it when they are not, and says so loudly. That
|
||||
# catches an out-of-band overwrite on the next push to main, needs no churn, and fixes
|
||||
# a real pre-existing bug on the side: reverting ci/ used to leave :latest pointing at
|
||||
# the newer build forever. Revisit inline digest pins if the push credential ever leaves
|
||||
# the maintainer trust set.
|
||||
#
|
||||
# Bootstrap note: consuming workflows pull <LAN>/punktfunk-rust-ci:latest, so the LAN
|
||||
# registry must hold a seeded :latest once (done 2026-07-29 from the last Gitea-registry
|
||||
@@ -42,7 +77,10 @@ on:
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
# Read port (anonymous, GET/HEAD only) and write port (basic auth). Two doors onto
|
||||
# one store; see the header.
|
||||
CI_REGISTRY: 192.168.1.58:5010
|
||||
CI_REGISTRY_PUSH: 192.168.1.58:5011
|
||||
|
||||
jobs:
|
||||
builders:
|
||||
@@ -98,21 +136,40 @@ jobs:
|
||||
echo "hit=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Tagged for the WRITE port: :5010 refuses a push outright, so a tag that names it
|
||||
# can only fail. Consumers still pull the identical image from :5010.
|
||||
- name: Build
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
# --pull is cheap now: base images come through the ci-core pull-through mirror.
|
||||
run: |
|
||||
docker build --pull ${{ matrix.buildargs }} \
|
||||
-f "${{ matrix.dockerfile }}" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY/${{ matrix.image }}:latest" \
|
||||
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY" \
|
||||
-t "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest" \
|
||||
ci
|
||||
|
||||
- name: Log in to the LAN registry
|
||||
run: |
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Push
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:$KEY"
|
||||
docker push "$CI_REGISTRY/${{ matrix.image }}:latest"
|
||||
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:$KEY"
|
||||
docker push "$CI_REGISTRY_PUSH/${{ matrix.image }}:latest"
|
||||
|
||||
# :latest must be whatever ci/ says it is, on every run — not only on the runs that
|
||||
# happened to build. Two things break that: an out-of-band overwrite (the H-6
|
||||
# attack, now only reachable by someone holding the push credential), and a plain
|
||||
# revert of ci/, which leaves :latest on the newer build because the older key is
|
||||
# already a cache hit and nothing re-points it. Both look identical from here and
|
||||
# both are repaired the same way, so repair and shout rather than fail the build.
|
||||
- name: Reconcile :latest with the content key
|
||||
run: .gitea/scripts/reconcile-latest.sh "${{ matrix.image }}" "$KEY"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
# A release pins reproducible builder images without any rebuild: copy the key's
|
||||
# manifest to a vX.Y.Z tag via the registry API (no image bytes move).
|
||||
@@ -124,8 +181,19 @@ jobs:
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$KEY"
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
|
||||
--data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY_PUSH/v2/${{ matrix.image }}/manifests/$GITHUB_REF_NAME"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
# Today the job container is ephemeral (the ubuntu-24.04 label is a docker://
|
||||
# image), so the credential docker login wrote would die with it anyway. Don't
|
||||
# make that a load-bearing assumption about a runner label somebody may change to
|
||||
# a host runner later.
|
||||
- name: Log out of the LAN registry
|
||||
if: always()
|
||||
run: docker logout "$CI_REGISTRY_PUSH" || true
|
||||
|
||||
# The aarch64 CROSS builder — a SEPARATE job because it is `FROM punktfunk-rust-ci:latest`
|
||||
# (the LAN copy) and so must not race the matrix entry that publishes that base. Consumed
|
||||
@@ -164,15 +232,26 @@ jobs:
|
||||
run: |
|
||||
docker build --pull \
|
||||
-f ci/rust-ci-arm64cross.Dockerfile \
|
||||
-t "$CI_REGISTRY/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY/$IMAGE:latest" \
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:$KEY" \
|
||||
-t "$CI_REGISTRY_PUSH/$IMAGE:latest" \
|
||||
.
|
||||
|
||||
- name: Log in to the LAN registry
|
||||
run: |
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY_PUSH" -u ci --password-stdin
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Push
|
||||
if: steps.exists.outputs.hit == 'false'
|
||||
run: |
|
||||
docker push "$CI_REGISTRY/$IMAGE:$KEY"
|
||||
docker push "$CI_REGISTRY/$IMAGE:latest"
|
||||
docker push "$CI_REGISTRY_PUSH/$IMAGE:$KEY"
|
||||
docker push "$CI_REGISTRY_PUSH/$IMAGE:latest"
|
||||
|
||||
- name: Reconcile :latest with the content key
|
||||
run: .gitea/scripts/reconcile-latest.sh "$IMAGE" "$KEY"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Tag for release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -182,8 +261,15 @@ jobs:
|
||||
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
||||
curl -sf -H "$ACCEPT" -o /tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
|
||||
curl -sf -X PUT -H "Content-Type: $MT" --data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
|
||||
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $MT" \
|
||||
--data-binary @/tmp/manifest.json \
|
||||
"http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/$GITHUB_REF_NAME"
|
||||
env:
|
||||
CI_REGISTRY_PASSWORD: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Log out of the LAN registry
|
||||
if: always()
|
||||
run: docker logout "$CI_REGISTRY_PUSH" || true
|
||||
|
||||
# Deployable app images — unchanged flow, Gitea registry, per-SHA + release tags.
|
||||
apps:
|
||||
|
||||
@@ -38,10 +38,18 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Pinned syft (keep in sync with the version validated against this repo; bump deliberately).
|
||||
#
|
||||
# The BINARY version was pinned; the INSTALLER was not — it was fetched from `main` and piped
|
||||
# into a shell, so whatever that branch happened to say at job time ran here, with the job's
|
||||
# environment (2026-08-05 review H-6). Pinning the script to the same tag as the binary makes
|
||||
# the whole step reproducible: bump the tag in both places together.
|
||||
- name: Install syft
|
||||
env:
|
||||
SYFT_VERSION: v1.49.0
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
|
||||
| sh -s -- -b /usr/local/bin v1.49.0
|
||||
set -euo pipefail
|
||||
curl -sSfL "https://raw.githubusercontent.com/anchore/syft/${SYFT_VERSION}/install.sh" \
|
||||
| sh -s -- -b /usr/local/bin "$SYFT_VERSION"
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
|
||||
Generated
+19
@@ -2893,6 +2893,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"wasapi",
|
||||
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3346,6 +3347,8 @@ dependencies = [
|
||||
"opus",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"uac-host",
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4985,6 +4988,14 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uac-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"usbfs-iso",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5064,6 +5075,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbfs-iso"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usbip-sim"
|
||||
version = "0.8.0"
|
||||
|
||||
+157
-9
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.23.0"
|
||||
"version": "0.24.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1052,7 +1052,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Fetch one cover-art image for a library entry",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —\nit's what the user's Steam client already shows for it), the public Steam CDN's flat URL\nconvention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host\ncan't predict, in which case this 404s and the client falls through to its next art candidate).\nOnly Steam ids are backed today; any other store 404s.",
|
||||
"description": "Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams\nthe image bytes. Any id stored in the host's catalog (manual entries, provider-synced entries,\nand a library plugin's claimed-store entries) serves its local art file. A Steam title falls back\nto the in-host scanner's resolver: the host's own local Steam cache first (exact — it's what the\nuser's Steam client already shows for it), the public Steam CDN's flat URL convention second\n(newer titles' CDN assets can live at a per-asset-hash path the host can't predict, in which case\nthis 404s and the client falls through to its next art candidate).",
|
||||
"operationId": "getLibraryArt",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1307,7 +1307,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "Replace a provider's library entries (declarative reconcile)",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.",
|
||||
"description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.\n\n`?store=` additionally **claims** that store for the provider: its entries then surface with\ndeterministic `<store>:<external_id>` ids and the store's own badge, instead of opaque\n`custom:<id>` ones — which is what lets a library plugin reproduce the entries an in-host scanner\nused to produce, right down to the GameStream app ids and client-side art caches. One provider\nper store; a second claimant gets 409. While a claim is held the matching built-in scanner is\nsuppressed, so the two never double-list. The claim is released by `DELETE`, not by an empty\nreconcile (a store can legitimately have zero installed titles).",
|
||||
"operationId": "reconcileProviderEntries",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1318,6 +1318,15 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "store",
|
||||
"in": "query",
|
||||
"description": "Claim this store for the provider ([a-z0-9_-], `custom`/`manual` reserved)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@@ -1348,7 +1357,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider id or payload",
|
||||
"description": "Invalid provider id, store id, or payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -1367,6 +1376,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "That store is already claimed by another provider",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not persist the catalog",
|
||||
"content": {
|
||||
@@ -4159,7 +4178,8 @@
|
||||
"tier",
|
||||
"platforms",
|
||||
"compatible",
|
||||
"update_available"
|
||||
"update_available",
|
||||
"categories"
|
||||
],
|
||||
"properties": {
|
||||
"author": {
|
||||
@@ -4172,6 +4192,13 @@
|
||||
],
|
||||
"description": "A revocation covering the catalogued version — do not offer this without shouting."
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "What kind of plugin this is — the console filters Browse by these, and the Game sources\nsurface's \"Add a source\" rail shows exactly the `library` ones (design D5/D6)."
|
||||
},
|
||||
"compatible": {
|
||||
"type": "boolean",
|
||||
"description": "Can this host install it?"
|
||||
@@ -4179,6 +4206,13 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"detected": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "Whether the launcher this plugin scans looks **installed on this host** (design D8), from the\nindex's own existence probes. `null` = the entry declares no probes for this platform, which\nthe console renders as \"unknown\" rather than \"not installed\"."
|
||||
},
|
||||
"homepage": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4365,6 +4399,17 @@
|
||||
],
|
||||
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
|
||||
},
|
||||
"store": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4409,6 +4454,10 @@
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4467,6 +4516,17 @@
|
||||
"type": "object",
|
||||
"description": "What an operator (or a provider plugin) can tell the host about recognizing a title — the wire\nhalf of [`DetectSpec`], and the only part of it that is ever accepted from outside.\n\nDeliberately a **subset**: the store-derived signals (a Steam appid, a launcher's environment\nmarker) are things the host discovers for itself and would be meaningless — or dangerous — to take\non someone's word. What is left is what a provider genuinely knows and the host cannot guess: where\nthe title is installed, which executable is the game, what the process is called. All three are\noptional; supplying none is the same as supplying no hint at all.\n\nNever returned by the catalog API — see the module docs on why detect data does not cross the wire\noutbound.",
|
||||
"properties": {
|
||||
"env_marker": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EnvMarker",
|
||||
"description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]."
|
||||
}
|
||||
]
|
||||
},
|
||||
"exe": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -4487,6 +4547,15 @@
|
||||
"null"
|
||||
],
|
||||
"description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]."
|
||||
},
|
||||
"steam_appid": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "The Steam appid, for a title Steam itself installed (D3). On Linux this is the **sharpest**\nsignal that exists — Steam wraps every launch, native or Proton, in\n`reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's — so without it a\nsteam plugin's lease tracking would degrade from reaper-exact to install-dir prefix matching.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4715,6 +4784,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EnvMarker": {
|
||||
"type": "object",
|
||||
"description": "An environment variable a launcher stamps onto the game's process, identifying it.\n\nSerializable because it is now half of the inbound [`DetectHint`] too (D3) — a library plugin\nthat knows its launcher's marker (Heroic's `HEROIC_APP_NAME`, load-bearing under Proton) has to\nbe able to say so, since after extraction the host no longer reads that launcher's files itself.",
|
||||
"required": [
|
||||
"key"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The variable name (e.g. `HEROIC_GAME_ID`).",
|
||||
"example": "HEROIC_APP_NAME"
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time."
|
||||
}
|
||||
}
|
||||
},
|
||||
"EventKind": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -5165,6 +5255,10 @@
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]."
|
||||
},
|
||||
"store": {
|
||||
"type": "string",
|
||||
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
||||
@@ -5296,6 +5390,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GameRole": {
|
||||
"type": "string",
|
||||
"description": "What a library entry *is* — an ordinary title, or the launcher application itself (Steam Big\nPicture, Heroic, Playnite fullscreen). Purely a presentation hint: a launcher entry launches,\nleases and lists exactly like a game (design D4), and clients that don't know the field render it\nas a plain tile. Serde-default `game` and skip-serialized when default, so the wire is unchanged\nfor every entry that doesn't opt in.",
|
||||
"enum": [
|
||||
"game",
|
||||
"launcher"
|
||||
]
|
||||
},
|
||||
"GameSession": {
|
||||
"type": "string",
|
||||
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
|
||||
@@ -6334,6 +6436,13 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "What KIND of plugin this is (`^[a-z][a-z0-9-]{0,31}$`), top-level rather than under `ui`\nbecause it describes the plugin, not its surface. The console knows one value today —\n`library` — which it filters **out of the nav**: six installed scanner plugins would otherwise\nflood the sidebar, and their real entry point is the Game sources surface (design D5). A\nlibrary plugin that genuinely wants its own page (rom-manager, which is much more than a\nscanner) simply omits the category."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)."
|
||||
@@ -6366,6 +6475,13 @@
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The plugin's kind — see [`PluginRegistration::category`]."
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6604,6 +6720,10 @@
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/components/schemas/GameRole",
|
||||
"description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -6780,26 +6900,46 @@
|
||||
},
|
||||
"ScannerInfo": {
|
||||
"type": "object",
|
||||
"description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.",
|
||||
"description": "One **game source** on this host, with its enable state — the unit the console renders a toggle\nfor. A source is either a scanner compiled into this build or a plugin that reconciles entries in\n(WP2.6); the console treats them identically, which is what makes the extraction invisible.",
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"enabled"
|
||||
"enabled",
|
||||
"origin"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this host runs the scanner (default true)."
|
||||
"description": "Whether this host runs the source (default true)."
|
||||
},
|
||||
"entries": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "How many entries this source currently contributes. `None` for a built-in scanner, whose\ncount would mean walking every launcher's files just to render a toggle.",
|
||||
"minimum": 0
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.",
|
||||
"description": "Stable source id — the same string this source's entries carry in their `store` field. For a\nplugin source it is also its provider id and its store claim: one string, by construction, so\na user's disabled state survives a built-in scanner being replaced by its plugin.",
|
||||
"example": "steam"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Human-facing name for the console toggle.",
|
||||
"example": "Steam"
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/components/schemas/SourceOrigin",
|
||||
"description": "Where the source comes from: `builtin` (a scanner in this host build) or `plugin`."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The provider id backing a `plugin` source — absent for a built-in scanner."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6962,6 +7102,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SourceOrigin": {
|
||||
"type": "string",
|
||||
"description": "Where a [`ScannerInfo`] comes from.",
|
||||
"enum": [
|
||||
"builtin",
|
||||
"plugin"
|
||||
]
|
||||
},
|
||||
"SourceView": {
|
||||
"type": "object",
|
||||
"description": "A configured catalog source and how its last refresh went.",
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -47,6 +48,7 @@ import android.widget.Toast
|
||||
import io.unom.punktfunk.kit.link.DeepLinkResult
|
||||
import io.unom.punktfunk.kit.link.DeepLinks
|
||||
import io.unom.punktfunk.kit.link.HostResolution
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.Tab
|
||||
@@ -61,6 +63,11 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// so the stream screen never re-reads the store behind its own connect's back.
|
||||
var session by remember { mutableStateOf<ActiveSession?>(null) }
|
||||
var tab by remember { mutableStateOf(Tab.Connect) }
|
||||
// Set when a session ends because its game exited and it began as a library launch: the host
|
||||
// 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 reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
||||
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
||||
@@ -98,6 +105,13 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// The console backdrop's colour family, published once from the live settings rather than
|
||||
// threaded through every screen that draws a backdrop. Because it is read from the SAME
|
||||
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
|
||||
// the field behind that very row.
|
||||
CompositionLocalProvider(
|
||||
LocalGamepadPalette provides GamepadPalette.named(settings.uiPalette),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = session,
|
||||
transitionSpec = {
|
||||
@@ -107,7 +121,20 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
) { active ->
|
||||
if (active != null) {
|
||||
// Immersive: the stream takes the whole screen, no bottom bar.
|
||||
StreamScreen(active, onDisconnect = { session = null })
|
||||
StreamScreen(active) { reason ->
|
||||
// A game launched from a library exiting is a normal finish, and the player is
|
||||
// almost certainly after the next title — so send them back to that library rather
|
||||
// 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.
|
||||
reopenLibraryHostId =
|
||||
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
|
||||
active.hostId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
session = null
|
||||
}
|
||||
} else if (gamepadUi) {
|
||||
GamepadShell(
|
||||
settings = settings,
|
||||
@@ -115,6 +142,8 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
onConnected = { session = it },
|
||||
deepLink = pendingLink,
|
||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||
reopenLibraryHostId = reopenLibraryHostId,
|
||||
onReopenLibraryHandled = { reopenLibraryHostId = null },
|
||||
)
|
||||
} else {
|
||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||
@@ -201,8 +230,16 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The console backdrop's colour family for everything under [App] — provided from the live
|
||||
* settings so a change on the gamepad settings screen recolours every backdrop at once. Defaults
|
||||
* to the brand violet, which is also what a preview or a test composition gets.
|
||||
*/
|
||||
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
|
||||
|
||||
/** Which console screen the gamepad shell is showing. */
|
||||
private enum class GamepadScreen { Home, Settings, Library }
|
||||
|
||||
@@ -218,11 +255,32 @@ fun GamepadShell(
|
||||
onConnected: (ActiveSession) -> Unit,
|
||||
deepLink: String? = null,
|
||||
onDeepLinkHandled: () -> Unit = {},
|
||||
/**
|
||||
* Open this saved host's library instead of Home on the way in — set when a game launched from
|
||||
* it has just exited. Null (the default) starts on Home exactly as before.
|
||||
*/
|
||||
reopenLibraryHostId: String? = null,
|
||||
onReopenLibraryHandled: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
||||
|
||||
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
|
||||
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
|
||||
// A host that has since been forgotten simply leaves us on Home rather than failing.
|
||||
LaunchedEffect(reopenLibraryHostId) {
|
||||
val id = reopenLibraryHostId ?: return@LaunchedEffect
|
||||
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
|
||||
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
|
||||
// either order happens to work — but this one cannot be broken by a later edit that adds a
|
||||
// suspending call. A host that has since been forgotten just leaves us on Home.
|
||||
KnownHostStore(context).all()
|
||||
.firstOrNull { it.id == id }
|
||||
?.let { libraryHost = it; screen = GamepadScreen.Library }
|
||||
onReopenLibraryHandled()
|
||||
}
|
||||
|
||||
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
|
||||
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
|
||||
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
|
||||
|
||||
@@ -168,8 +168,7 @@ fun ConnectScreen(
|
||||
lnpPrompt = false
|
||||
// The browse started while blocked (its sockets failed or received nothing) — restart it
|
||||
// now that the grant makes them work.
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
discovery.restart()
|
||||
} else {
|
||||
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
|
||||
}
|
||||
@@ -191,12 +190,27 @@ fun ConnectScreen(
|
||||
// or otherwise notify the app — this observer is what turns the grant into a live discovery.
|
||||
DisposableEffect(Unit) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
// Whether we've actually been away. ON_RESUME also fires on first entry, right after the
|
||||
// effect below starts the browse — restarting it there would be pure churn.
|
||||
var wasPaused = false
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> wasPaused = true
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
if (!lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.restart()
|
||||
} else if (wasPaused) {
|
||||
// Coming back from the background: the browse may have been sitting idle
|
||||
// (or had its multicast socket torn out from under it) while we were away,
|
||||
// and its own re-query interval has kept doubling. Re-arm and ask again,
|
||||
// so returning to the screen is enough — no app restart.
|
||||
discovery.restart()
|
||||
}
|
||||
wasPaused = false
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
@@ -1009,20 +1023,28 @@ fun ConnectScreen(
|
||||
// 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.
|
||||
if (lnpGranted && !connecting && discovered.isEmpty()) {
|
||||
// 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,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
|
||||
// answer "can this phone drive this pad's audio endpoint at all", and gating
|
||||
// that behind a live session would make it depend on the very thing one wants
|
||||
// to rule out when a session misbehaves. DualSense only — the DS4 has no
|
||||
// 4-channel haptics device.
|
||||
if (model != DsDevice.Model.DUALSHOCK4) {
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
var result by remember { mutableStateOf<String?>(null) }
|
||||
result?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
enabled = !testing,
|
||||
onClick = {
|
||||
testing = true
|
||||
result = null
|
||||
Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be
|
||||
// shared with another transfer engine, and that applies to
|
||||
// this test as much as to the real path.
|
||||
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) {
|
||||
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
conn?.close()
|
||||
val msg = when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 -> "Could not open the pad's audio interface. " +
|
||||
"Some kernels refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
result = msg
|
||||
testing = false
|
||||
}
|
||||
}, "pf-pad-selftest-ui").start()
|
||||
},
|
||||
) {
|
||||
Text(if (testing) "Testing…" else "Test haptics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -23,6 +25,9 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -31,7 +36,9 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -86,32 +93,53 @@ private val auroraBlobs = listOf(
|
||||
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
|
||||
)
|
||||
|
||||
/** The deep base the field sits on — and, scaled, the [calm] lift that flattens it. */
|
||||
private val auroraBase = Color(0xFF131126)
|
||||
|
||||
/**
|
||||
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
|
||||
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
|
||||
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
|
||||
* The living console backdrop: soft brand-family blobs drifting over a deep base on slow, seamless
|
||||
* loops, finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose
|
||||
* approximation of the Apple client's MeshGradient aurora — same colour family, same "ambience,
|
||||
* never content" role, and the same [GamepadPalette] setting recolours both.
|
||||
*
|
||||
* [calm] is what the FORM screens wear: the pools dim onto the base 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.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) {
|
||||
val palette = LocalGamepadPalette.current
|
||||
val animated = animationsEnabled()
|
||||
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 angle by transition.animateFloat(
|
||||
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
|
||||
// Tinting is per-frame-cheap but not free, and the palette changes about once a year.
|
||||
val blobs = remember(palette.id) { auroraBlobs.map { it to palette.tint(it.color) } }
|
||||
val base = remember(palette.id) { palette.tint(auroraBase) }
|
||||
Canvas(modifier) {
|
||||
drawRect(Color.Black)
|
||||
drawRect(if (calm) base else Color.Black)
|
||||
val span = max(size.width, size.height)
|
||||
for (b in auroraBlobs) {
|
||||
for ((b, tinted) in blobs) {
|
||||
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 base
|
||||
// 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(b.color.copy(alpha = b.alpha), Color.Transparent),
|
||||
colors = listOf(tinted.copy(alpha = alpha), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = r,
|
||||
),
|
||||
@@ -120,10 +148,15 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
blendMode = BlendMode.Plus,
|
||||
)
|
||||
}
|
||||
// Cinematic vignette: pool light centre, sink the corners.
|
||||
// Cinematic vignette: pool light centre, sink the corners. 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 to black just eats them. (Matches the Apple client and the desktop console.)
|
||||
drawRect(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = if (calm) 0.22f else 0.44f),
|
||||
),
|
||||
center = Offset(size.width / 2, size.height / 2),
|
||||
radius = span * 0.92f,
|
||||
),
|
||||
@@ -141,33 +174,96 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
|
||||
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
|
||||
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
|
||||
* `false` when the user has turned animations off system-wide (Developer options' animator duration
|
||||
* scale, or the accessibility "Remove animations" switch, which sets the same global). Read once
|
||||
* per composition — it needs a settings trip to the system, and it changes about never.
|
||||
*/
|
||||
@Composable
|
||||
private fun animationsEnabled(): Boolean {
|
||||
val context = LocalContext.current
|
||||
return remember {
|
||||
runCatching {
|
||||
android.provider.Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
) != 0f
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backdrop for the console FORM screens (settings, add-host). It used to be a STILL deep-indigo
|
||||
* base with two soft glows; it is now the launcher's own living field at `calm`, which keeps that
|
||||
* colour and luminance under the glass rows, honours the palette setting on every screen rather
|
||||
* than only the launcher, and leaves nothing in the console UI backed by a static image. Mirrors
|
||||
* the Apple client's GamepadFormBackground, which made the same substitution.
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
Canvas(modifier) {
|
||||
val span = max(size.width, size.height)
|
||||
drawRect(Color(0xFF131126))
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xE6635AAE), Color.Transparent),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.24f, size.height * 0.12f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color(0xBF343E96), Color.Transparent),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
),
|
||||
center = Offset(size.width * 0.82f, size.height * 0.9f),
|
||||
radius = span * 0.7f,
|
||||
)
|
||||
GamepadAuroraBackground(modifier, calm = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* The horizontal section switcher above a console list. Purely presentational — the SCREEN owns
|
||||
* which tab is selected and what the shoulders do. Scrollable so a narrow phone in landscape never
|
||||
* has to squeeze the pills, and the selected one is always brought into view whether it was reached
|
||||
* by shoulder button or tap.
|
||||
*/
|
||||
@Composable
|
||||
fun ConsoleTabStrip(
|
||||
titles: List<String>,
|
||||
selected: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* The strip itself holds the cursor (the caller moved focus UP out of its list). Draws a ring
|
||||
* on the selected pill so it's clear left/right now walks sections rather than values — the
|
||||
* route a D-pad remote, which has no shoulder buttons, needs.
|
||||
*/
|
||||
focused: Boolean = false,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(selected) {
|
||||
runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) }
|
||||
}
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = ConsoleEdgeInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(titles) { i, title ->
|
||||
val active = i == selected
|
||||
val background by animateColorAsState(
|
||||
if (active) Color(0xD96656F2) else Color(0x14FFFFFF),
|
||||
tween(180),
|
||||
label = "tabBg",
|
||||
)
|
||||
val ink by animateColorAsState(
|
||||
Color.White.copy(alpha = if (active) 1f else 0.55f),
|
||||
tween(180),
|
||||
label = "tabInk",
|
||||
)
|
||||
val ring by animateColorAsState(
|
||||
Color.White.copy(alpha = if (active && focused) 0.85f else 0f),
|
||||
tween(180),
|
||||
label = "tabRing",
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(background)
|
||||
.border(1.5.dp, ring, RoundedCornerShape(50))
|
||||
.clickable { onSelect(i) }
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +272,7 @@ fun GamepadFormBackground(modifier: Modifier = Modifier) {
|
||||
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
|
||||
* it cross-fades between screens.
|
||||
*/
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
|
||||
val ConsoleLegendInset = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp)
|
||||
|
||||
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
|
||||
val ConsoleEdgeInset = 24.dp
|
||||
@@ -471,7 +567,12 @@ fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, haze
|
||||
Row(
|
||||
modifier = frosted
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
// The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a
|
||||
// screen whose legend grew a cell) it scrolls rather than running off the edge and
|
||||
// silently eating the last hint — which is exactly what the settings screen's new
|
||||
// Section cell did on a 360 dp phone.
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
|
||||
@@ -152,8 +152,9 @@ fun GamepadNavEffect(
|
||||
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
|
||||
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
|
||||
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
|
||||
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
|
||||
* one layer": close the keyboard, then the screen).
|
||||
* [onSecondary] is Y, and [onShoulder] is L1 (-1) / R1 (+1) — a step SIDEWAYS out of the list, which
|
||||
* the settings screen uses for its section tabs. B is left to MainActivity's BACK remap → the
|
||||
* screen's BackHandler (so B "peels one layer": close the keyboard, then the screen).
|
||||
*/
|
||||
@Composable
|
||||
fun GamepadNavEffect2D(
|
||||
@@ -162,6 +163,7 @@ fun GamepadNavEffect2D(
|
||||
onActivate: () -> Unit,
|
||||
onTertiary: () -> Unit = {},
|
||||
onSecondary: () -> Unit = {},
|
||||
onShoulder: (Int) -> Unit = {},
|
||||
) {
|
||||
val activity = LocalContext.current as? MainActivity ?: return
|
||||
val state = remember { NavInputState() }
|
||||
@@ -169,6 +171,7 @@ fun GamepadNavEffect2D(
|
||||
val currentOnActivate by rememberUpdatedState(onActivate)
|
||||
val currentOnTertiary by rememberUpdatedState(onTertiary)
|
||||
val currentOnSecondary by rememberUpdatedState(onSecondary)
|
||||
val currentOnShoulder by rememberUpdatedState(onShoulder)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||
@@ -196,7 +199,10 @@ fun GamepadNavEffect2D(
|
||||
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 }
|
||||
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
|
||||
// Edge-only, no auto-repeat: a held shoulder shouldn't spin through the tabs.
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// The console (gamepad) UI's background colour families.
|
||||
//
|
||||
// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale
|
||||
// applied to the ONE field GamepadAuroraBackground already draws, so every palette inherits its
|
||||
// structure (dark base, bright drifting pools) and the brand default is exactly the shipped look —
|
||||
// `violet` is the identity transform.
|
||||
//
|
||||
// The table and the `tint` maths are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
|
||||
// Apple client's `GamepadPalette.swift` under the same ids, so the shared `ui_palette` setting
|
||||
// names the same colour family on every client. Keep the three copies in step: a palette added
|
||||
// here without the others is a value the other clients will silently render as Violet.
|
||||
|
||||
/**
|
||||
* One background colour family. [hueDegrees] rotates about the grey axis (positive runs
|
||||
* red → green → blue) and [saturation] scales saturation about luminance.
|
||||
*/
|
||||
class GamepadPalette(
|
||||
/** The stored `ui_palette` value ([Settings.uiPalette]). */
|
||||
val id: String,
|
||||
/** What the settings row shows. */
|
||||
val name: String,
|
||||
val hueDegrees: Double,
|
||||
val saturation: Double,
|
||||
) {
|
||||
/** True for the identity transform, so the default path skips the per-colour work. */
|
||||
val isIdentity: Boolean get() = hueDegrees == 0.0 && saturation == 1.0
|
||||
|
||||
/** Apply this palette to one packed sRGB colour, keeping its alpha. */
|
||||
fun tint(c: Color): Color {
|
||||
if (isIdentity) return c
|
||||
val (r, g, b) = tint(Triple(c.red.toDouble(), c.green.toDouble(), c.blue.toDouble()))
|
||||
return Color(r.toFloat(), g.toFloat(), b.toFloat(), c.alpha)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate `c` about the grey axis by [hueDegrees] (Rodrigues — the same rotation, in the same
|
||||
* orientation, that the desktop console's shader uses for its ±8° warm/cool sway) and scale
|
||||
* its saturation about luminance. Clamped, because a large rotation can push a channel out of
|
||||
* gamut.
|
||||
*/
|
||||
fun tint(c: Triple<Double, Double, Double>): Triple<Double, Double, Double> {
|
||||
val (r, g, b) = c
|
||||
val a = Math.toRadians(hueDegrees)
|
||||
val cs = cos(a)
|
||||
val sn = sin(a)
|
||||
val invSqrt3 = 1.0 / sqrt(3.0)
|
||||
val grey = (r + g + b) / 3.0 * (1.0 - cs)
|
||||
// The `sn` term is cross(k, c) with k = (1,1,1)/√3.
|
||||
val rr = r * cs + (b - g) * invSqrt3 * sn + grey
|
||||
val rg = g * cs + (r - b) * invSqrt3 * sn + grey
|
||||
val rb = b * cs + (g - r) * invSqrt3 * sn + grey
|
||||
val luma = 0.2126 * rr + 0.7152 * rg + 0.0722 * rb
|
||||
fun mix(v: Double) = (luma + (v - luma) * saturation).coerceIn(0.0, 1.0)
|
||||
return Triple(mix(rr), mix(rg), mix(rb))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The six shipped palettes, in cycling order: the brand violet, then cool → warm, then
|
||||
* the neutral.
|
||||
*/
|
||||
val ALL = listOf(
|
||||
GamepadPalette("violet", "Violet", 0.0, 1.0),
|
||||
GamepadPalette("tide", "Tide", -70.0, 1.0),
|
||||
GamepadPalette("forest", "Forest", -130.0, 0.9),
|
||||
GamepadPalette("ember", "Ember", 105.0, 1.0),
|
||||
GamepadPalette("rose", "Rose", 60.0, 0.95),
|
||||
GamepadPalette("graphite", "Graphite", 0.0, 0.12),
|
||||
)
|
||||
|
||||
/**
|
||||
* The palette stored under [id], falling back to the brand default — an unknown name is a
|
||||
* palette a newer client shipped, not a reason to draw nothing.
|
||||
*/
|
||||
fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0]
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -63,10 +64,35 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
|
||||
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
|
||||
// L1/R1 change SECTION, B closes. Both write the same SharedPreferences, so values round-trip with
|
||||
// the touch settings.
|
||||
//
|
||||
// The rows are split across SECTION TABS ([GpTab]) — a shoulder press on a pad, a tap on a phone.
|
||||
// They used to be one long scroll with inline `Group · Subgroup` headers, which on a TV meant
|
||||
// walking past Display and Audio to reach the controller settings. The tab names match the desktop
|
||||
// console's and the Apple client's, so a setting is found under the same word wherever you look.
|
||||
|
||||
/**
|
||||
* The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
|
||||
* match `pf-console-ui`'s `TABS` and the Apple client's `GpSettingsTab`.
|
||||
*/
|
||||
enum class GpTab(val title: String) {
|
||||
STREAM("Stream"),
|
||||
VIDEO("Video"),
|
||||
AUDIO("Audio"),
|
||||
CONTROLLER("Controller"),
|
||||
INTERFACE("Interface"),
|
||||
PROFILES("Profiles"),
|
||||
}
|
||||
|
||||
internal class GpRow(
|
||||
val id: String,
|
||||
val tab: GpTab,
|
||||
/**
|
||||
* A sub-heading above this row, for the few tabs that hold more than one group. Most rows have
|
||||
* none: the tab pill already names the section, and repeating it would be a second label
|
||||
* saying the same word.
|
||||
*/
|
||||
val header: String?,
|
||||
val label: String,
|
||||
val value: String,
|
||||
@@ -133,10 +159,34 @@ 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) }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
// Which section is showing, and where each one's focus was when it was last left — a detour
|
||||
// into another tab shouldn't lose your place.
|
||||
var tab by remember { mutableStateOf(GpTab.STREAM) }
|
||||
// True while the STRIP holds the cursor rather than the list. Up from the first row moves
|
||||
// here and Down goes back — the only route to the sections on a D-pad remote, which has no
|
||||
// shoulder buttons at all (and is exactly what a TV box ships with).
|
||||
var tabFocused by remember { mutableStateOf(false) }
|
||||
val tabFocus = remember { mutableStateMapOf<GpTab, Int>() }
|
||||
val rows = allRows.filter { it.tab == tab }
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0)
|
||||
|
||||
// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
|
||||
fun selectTab(next: GpTab) {
|
||||
if (next == tab) return
|
||||
tabFocus[tab] = focus
|
||||
tab = next
|
||||
// Clamp: a tab's length follows the hardware and the catalog, so a remembered index can
|
||||
// outlive the row it pointed at.
|
||||
focus = (tabFocus[next] ?: 0)
|
||||
.coerceIn(0, (allRows.count { it.tab == next } - 1).coerceAtLeast(0))
|
||||
}
|
||||
fun stepTab(delta: Int) {
|
||||
val all = GpTab.entries
|
||||
selectTab(all[((all.indexOf(tab) + delta) % all.size + all.size) % all.size])
|
||||
}
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
// value text slides in its AnimatedContent, so the motion matches the button press.
|
||||
var adjustDir by remember { mutableIntStateOf(1) }
|
||||
@@ -151,20 +201,28 @@ fun GamepadSettingsScreen(
|
||||
active = navActive && pinProfile == null,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
|
||||
// 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 -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
NavDir.UP -> if (focus > 0) focus-- else tabFocused = true
|
||||
NavDir.DOWN -> if (tabFocused) tabFocused = false else if (focus < rows.lastIndex) focus++
|
||||
// On the strip, left/right walks sections; on a row it steps the value. A disabled
|
||||
// row is INERT, not just dim — the step is refused instead of writing a setting
|
||||
// that has nothing to act on (see `liveRow`).
|
||||
NavDir.LEFT ->
|
||||
if (tabFocused) stepTab(-1) else { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
|
||||
NavDir.RIGHT ->
|
||||
if (tabFocused) stepTab(1) else { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
|
||||
}
|
||||
},
|
||||
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
|
||||
// A on the strip drops into the section you picked, which is what "confirm" means there.
|
||||
onActivate = {
|
||||
if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() }
|
||||
},
|
||||
// The shoulders work from either place — a real pad never has to visit the strip.
|
||||
onShoulder = { delta -> stepTab(delta) },
|
||||
)
|
||||
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
|
||||
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
|
||||
// +1 accounts for the heading being item 0.
|
||||
LaunchedEffect(focus) {
|
||||
LaunchedEffect(focus, tab) {
|
||||
runCatching {
|
||||
val itemIndex = focus + 1
|
||||
val info = listState.layoutInfo
|
||||
@@ -183,9 +241,21 @@ fun GamepadSettingsScreen(
|
||||
// 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().systemBarsPadding(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
@@ -196,12 +266,19 @@ fun GamepadSettingsScreen(
|
||||
ConsoleHeader("Default settings", horizontalInset = false)
|
||||
}
|
||||
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
|
||||
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
|
||||
// its detail explains itself) but never flips it.
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
})
|
||||
SettingRowView(
|
||||
row,
|
||||
focused = index == focus && !tabFocused,
|
||||
adjustDir = adjustDir,
|
||||
onClick = {
|
||||
// Same inertness as the pad path above — tapping a dimmed row focuses it
|
||||
// (so its detail explains itself) but never flips it.
|
||||
tabFocused = false
|
||||
if (focus != index) focus = index
|
||||
else if (row.enabled) { adjustDir = 1; row.activate() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,8 +295,23 @@ fun GamepadSettingsScreen(
|
||||
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
|
||||
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
|
||||
val focused = rows.getOrNull(focus)
|
||||
// The shoulders always change section, so that cell leads on every row. Tappable too,
|
||||
// like the others — a user without a working pad can still reach every tab.
|
||||
// Advertise the shoulders only where they EXIST: a TV remote has none (its route is Up
|
||||
// into the strip) and a touch user taps a pill, so on those the cell would be both a
|
||||
// lie and the reason a 360 dp legend runs out of room. Defaults to the pad case off an
|
||||
// Activity (preview/tests), like GamepadHintBar's own glyph choice.
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
val sections = listOfNotNull(
|
||||
GamepadHint('⇄', Color(0xFF9A93C7), "Section", onClick = { stepTab(1) })
|
||||
.takeIf { padIsGamepad },
|
||||
)
|
||||
GamepadHintBar(
|
||||
when {
|
||||
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),
|
||||
)
|
||||
@@ -353,7 +445,8 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
|
||||
/** Build the console settings rows from the current [Settings], writing through [update].
|
||||
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
|
||||
* AV1 codec entry (see `codecOptionsFor`). */
|
||||
* AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one
|
||||
* tab at a time. */
|
||||
internal fun buildSettingsRows(
|
||||
s: Settings,
|
||||
hasBodyVibrator: Boolean,
|
||||
@@ -361,12 +454,12 @@ internal fun buildSettingsRows(
|
||||
update: (Settings) -> Unit,
|
||||
): List<GpRow> {
|
||||
fun <T> choice(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
|
||||
): GpRow {
|
||||
val idx = options.indexOfFirst { it.first == current }
|
||||
return GpRow(
|
||||
id, header, label,
|
||||
id, tab, header, label,
|
||||
value = options.getOrNull(idx)?.second ?: "—",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
@@ -385,10 +478,10 @@ internal fun buildSettingsRows(
|
||||
)
|
||||
}
|
||||
fun toggle(
|
||||
id: String, header: String?, label: String, detail: String,
|
||||
id: String, tab: GpTab, header: String?, label: String, detail: String,
|
||||
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
|
||||
): GpRow = GpRow(
|
||||
id, header, label,
|
||||
id, tab, header, label,
|
||||
value = if (value) "On" else "Off",
|
||||
detail = detail,
|
||||
enabled = enabled,
|
||||
@@ -397,36 +490,13 @@ internal fun buildSettingsRows(
|
||||
toggled = value,
|
||||
)
|
||||
|
||||
// Grouped and ordered by the cross-client category map (General / Display / Audio /
|
||||
// Controllers), with the same sub-section names the touch settings and the desktop clients use,
|
||||
// so a setting sits in the same place whichever surface you found it on. The ROWS stay the
|
||||
// couch-relevant subset: a pad can't drive a touch-input picker, and adding one for the sake of
|
||||
// symmetry would be parity in name only.
|
||||
// Grouped by the cross-client tab map (Stream / Video / Audio / Controller / Interface /
|
||||
// Profiles), so a setting sits under the same word whichever client you found it on. The ROWS
|
||||
// stay the couch-relevant subset: a pad can't drive a touch-input picker, and adding one for
|
||||
// the sake of symmetry would be parity in name only.
|
||||
return listOf(
|
||||
choice(
|
||||
"hud", "General · Statistics", "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", "General · Session", "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", "General · Library", "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", "General · Interface", "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
|
||||
choice(
|
||||
"resolution", "Display · Resolution", "Resolution",
|
||||
"resolution", GpTab.STREAM, null, "Resolution",
|
||||
"The host creates a virtual display at exactly this size — no scaling. " +
|
||||
"Custom sizes are typed in the touch settings.",
|
||||
// A custom size (typed in the touch settings) leads the list so it stays visible and
|
||||
@@ -440,55 +510,56 @@ internal fun buildSettingsRows(
|
||||
s.width to s.height,
|
||||
) { (w, h) -> update(s.copy(width = w, height = h)) },
|
||||
choice(
|
||||
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
|
||||
"refresh", GpTab.STREAM, null, "Refresh rate",
|
||||
"Frame rate the host renders and streams at.",
|
||||
REFRESH_OPTIONS, s.hz,
|
||||
) { update(s.copy(hz = it)) },
|
||||
|
||||
choice(
|
||||
"bitrate", "Display · Quality", "Bitrate",
|
||||
"bitrate", GpTab.STREAM, null, "Bitrate",
|
||||
"Automatic uses the host's default. A host's options (Up on its tile) can measure the " +
|
||||
"link and set an informed value.",
|
||||
BITRATE_OPTIONS, s.bitrateKbps,
|
||||
) { update(s.copy(bitrateKbps = it)) },
|
||||
choice(
|
||||
"codec", null, "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
|
||||
toggle(
|
||||
"lowLatency", "Display · Decoding", "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"compositor", "Display · Host output", "Compositor",
|
||||
"compositor", GpTab.STREAM, "Host output", "Compositor",
|
||||
"Which compositor drives the virtual output — honored only if available on the host.",
|
||||
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
|
||||
) { update(s.copy(compositor = it)) },
|
||||
|
||||
choice(
|
||||
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
|
||||
"codec", GpTab.VIDEO, null, "Video codec",
|
||||
"A preference — the host falls back if it can't encode this one.",
|
||||
codecOptionsFor(s.codec, av1Capable), s.codec,
|
||||
) { update(s.copy(codec = it)) },
|
||||
toggle(
|
||||
"hdr", GpTab.VIDEO, null, "10-bit HDR",
|
||||
"HDR10 — engages when the host sends HDR content and this display supports it.",
|
||||
s.hdrEnabled,
|
||||
) { update(s.copy(hdrEnabled = it)) },
|
||||
toggle(
|
||||
"lowLatency", GpTab.VIDEO, "Decoding", "Low-latency mode",
|
||||
"The fast pipeline (async decode + system tuning). On by default — turn off to fall back if the stream stutters or glitches.",
|
||||
s.lowLatencyMode,
|
||||
) { update(s.copy(lowLatencyMode = it)) },
|
||||
|
||||
choice(
|
||||
"audio", GpTab.AUDIO, null, "Audio channels",
|
||||
"The speaker layout requested from the host.",
|
||||
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
|
||||
) { update(s.copy(audioChannels = it)) },
|
||||
toggle(
|
||||
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
|
||||
"mic", GpTab.AUDIO, null, "Microphone",
|
||||
"Send this device's microphone to the host's virtual mic.",
|
||||
s.micEnabled,
|
||||
) { update(s.copy(micEnabled = it)) },
|
||||
toggle(
|
||||
"echoCancel", null, "Echo cancellation",
|
||||
"echoCancel", GpTab.AUDIO, null, "Echo cancellation",
|
||||
"Filter the stream's own audio out of the mic pickup. Applies while the microphone is on.",
|
||||
s.echoCancel,
|
||||
) { update(s.copy(echoCancel = it)) },
|
||||
|
||||
toggle(
|
||||
"padForward", "Controllers", "Forward controllers",
|
||||
"padForward", GpTab.CONTROLLER, null, "Forward controllers",
|
||||
"Send this device's controllers to the host. Turn it off when your controller " +
|
||||
"already reaches the host another way — USB passthrough such as VirtualHere — " +
|
||||
"so games don't see two of them.",
|
||||
@@ -499,18 +570,18 @@ internal fun buildSettingsRows(
|
||||
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
|
||||
// the pad rows kept stepping settings that had nothing to act on.
|
||||
choice(
|
||||
"padType", null, "Controller type",
|
||||
"padType", GpTab.CONTROLLER, null, "Controller type",
|
||||
"The virtual pad the host creates — Automatic matches this controller.",
|
||||
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(gamepad = it)) },
|
||||
choice(
|
||||
"systemButtons", null, "Guide button",
|
||||
"systemButtons", GpTab.CONTROLLER, null, "Guide button",
|
||||
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
|
||||
"sends them to the host whenever this device delivers them.",
|
||||
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(systemButtons = it)) },
|
||||
choice(
|
||||
"guideGesture", null, "Hold Select for guide",
|
||||
"guideGesture", GpTab.CONTROLLER, null, "Hold Select for guide",
|
||||
"Hold Select alone to press the host's guide button — keep holding for a " +
|
||||
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
|
||||
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
|
||||
@@ -518,7 +589,7 @@ internal fun buildSettingsRows(
|
||||
) + listOfNotNull(
|
||||
if (hasBodyVibrator) {
|
||||
toggle(
|
||||
"phoneRumble", null, "Rumble on this phone",
|
||||
"phoneRumble", GpTab.CONTROLLER, null, "Rumble on this phone",
|
||||
"Also play controller 1's rumble on this phone's own vibration motor — " +
|
||||
"for clip-on pads without rumble motors.",
|
||||
s.rumbleOnPhone,
|
||||
@@ -530,7 +601,7 @@ internal fun buildSettingsRows(
|
||||
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
|
||||
// nothing to do with this device's motor, and a TV box is where it matters most.
|
||||
toggle(
|
||||
"sc2", null, "Steam Controller 2 passthrough",
|
||||
"sc2", GpTab.CONTROLLER, "Passthrough", "Steam Controller 2 passthrough",
|
||||
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
|
||||
"it as-is — Steam on the host drives it like the physical pad.",
|
||||
s.sc2Capture, enabled = s.gamepadForwarding,
|
||||
@@ -540,20 +611,53 @@ internal fun buildSettingsRows(
|
||||
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
|
||||
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
|
||||
toggle(
|
||||
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
|
||||
"dsCapture", GpTab.CONTROLLER, null, "DualSense / DualShock passthrough (USB)",
|
||||
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
|
||||
"triggers, lightbar and gyro.",
|
||||
s.dsCapture, enabled = s.gamepadForwarding,
|
||||
) { update(s.copy(dsCapture = it)) },
|
||||
|
||||
// The palette leads Interface: it is the one row whose effect you can see while you step
|
||||
// it (the backdrop behind this very list recolours), so it wants to be the first thing
|
||||
// found in the section.
|
||||
choice(
|
||||
"palette", GpTab.INTERFACE, null, "Background",
|
||||
"The colour family this backdrop drifts through — it changes as you step, so pick by " +
|
||||
"looking. Appearance only.",
|
||||
GamepadPalette.ALL.map { it.id to it.name },
|
||||
GamepadPalette.named(s.uiPalette).id,
|
||||
) { update(s.copy(uiPalette = it)) },
|
||||
choice(
|
||||
"hud", GpTab.INTERFACE, null, "Statistics overlay",
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " +
|
||||
"A 3-finger tap cycles the tiers live.",
|
||||
STATS_VERBOSITY_OPTIONS, s.statsVerbosity,
|
||||
) { update(s.copy(statsVerbosity = it)) },
|
||||
toggle(
|
||||
"autoWake", GpTab.INTERFACE, null, "Auto-wake on connect",
|
||||
"Wake a saved host with Wake-on-LAN when it isn't seen on the network, then connect.",
|
||||
s.autoWakeEnabled,
|
||||
) { update(s.copy(autoWakeEnabled = it)) },
|
||||
toggle(
|
||||
"library", GpTab.INTERFACE, null, "Game library",
|
||||
"Browse a paired host's games with Y (experimental).",
|
||||
s.libraryEnabled,
|
||||
) { update(s.copy(libraryEnabled = it)) },
|
||||
toggle(
|
||||
"gamepadUI", GpTab.INTERFACE, null, "Controller-optimized UI",
|
||||
"Turn off to use the touch interface even with a controller connected.",
|
||||
s.gamepadUiEnabled,
|
||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
|
||||
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
|
||||
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
|
||||
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
|
||||
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
|
||||
* instead of a dead-looking empty tab. On a TV that phrasing changes: "touch interface" points
|
||||
* nowhere useful on a touchless device, so the strings name the actual route — the
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
@@ -574,7 +678,8 @@ private fun buildProfileRows(
|
||||
return listOf(
|
||||
GpRow(
|
||||
id = "noProfiles",
|
||||
header = "Profiles",
|
||||
tab = GpTab.PROFILES,
|
||||
header = null,
|
||||
label = "No profiles yet",
|
||||
value = "",
|
||||
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
|
||||
@@ -586,12 +691,13 @@ private fun buildProfileRows(
|
||||
),
|
||||
)
|
||||
}
|
||||
return profiles.mapIndexed { i, p ->
|
||||
return profiles.map { p ->
|
||||
// Counted straight off the host records, so it agrees with what the carousel renders.
|
||||
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
|
||||
GpRow(
|
||||
id = "profile:${p.id}",
|
||||
header = if (i == 0) "Profiles" else null,
|
||||
tab = GpTab.PROFILES,
|
||||
header = null,
|
||||
label = p.name,
|
||||
value = when (pins) {
|
||||
0 -> "Not pinned"
|
||||
|
||||
@@ -84,6 +84,9 @@ suspend fun connectToHost(
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
|
||||
// user with it off does not make the host provision endpoints it will never feed.
|
||||
settings.padHaptics || settings.padSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,14 @@ fun LibraryScreen(
|
||||
launching = false
|
||||
if (handle != 0L) {
|
||||
onLaunched(
|
||||
ActiveSession(handle, settings, host.clipboardSync),
|
||||
ActiveSession(
|
||||
handle,
|
||||
settings,
|
||||
host.clipboardSync,
|
||||
hostId = host.id,
|
||||
// Where to come back to when this game exits.
|
||||
launchedFromLibrary = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
else Toast.makeText(
|
||||
@@ -241,7 +248,22 @@ private fun Coverflow(
|
||||
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
|
||||
)
|
||||
|
||||
// Design D4: the launcher entries lead the strip (the client groups them at parse time).
|
||||
// A coverflow is one-dimensional, so instead of a second focus rail the heading names the
|
||||
// group the cursor is in and changes as it crosses the boundary. Only drawn when the
|
||||
// library actually has both groups — otherwise the screen is exactly what it was.
|
||||
val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher }
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||
if (bothGroups) {
|
||||
Text(
|
||||
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
letterSpacing = 2.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(coverWidth),
|
||||
@@ -305,7 +327,8 @@ private fun Coverflow(
|
||||
)
|
||||
if (current != null) {
|
||||
Text(
|
||||
if (current.isCustom) "CUSTOM" else "STEAM",
|
||||
if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER"
|
||||
else current.storeLabel.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
letterSpacing = 2.sp,
|
||||
@@ -339,8 +362,10 @@ 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 rarely has poster art. Naming the launcher says "opens Steam"; the title
|
||||
// would read as "a game whose cover failed to load".
|
||||
Text(
|
||||
game.title,
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
@@ -348,15 +373,18 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Store badge, top-start.
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
Text(
|
||||
if (game.isCustom) "Custom" else "Steam",
|
||||
game.storeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.background(
|
||||
if (game.isLauncher) MaterialTheme.colorScheme.primary
|
||||
else Color.Black.copy(alpha = 0.5f),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,16 @@ data class Settings(
|
||||
* client's `libraryEnabled`.
|
||||
*/
|
||||
val libraryEnabled: Boolean = true,
|
||||
/**
|
||||
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
|
||||
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
|
||||
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
|
||||
* desktop console's and the Apple client's under the same names. Presentation only: nothing
|
||||
* about a stream depends on it, so it is a device preference and never part of a profile.
|
||||
* An unknown value reads as the default rather than failing — a newer client may have shipped
|
||||
* a palette this build doesn't know.
|
||||
*/
|
||||
val uiPalette: String = "violet",
|
||||
/**
|
||||
* "Low-latency mode" — the master switch over the latency pipeline: the async decode loop
|
||||
* (native; burst-feed + present-newest-per-vsync, the Apple client's discipline), decoder ranking
|
||||
@@ -170,6 +180,26 @@ data class Settings(
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
|
||||
*
|
||||
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
|
||||
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
|
||||
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
|
||||
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
|
||||
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
|
||||
* ordinary rumble (tier C), which on this client already drives the same actuators.
|
||||
*/
|
||||
val padHaptics: Boolean = true,
|
||||
|
||||
/**
|
||||
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
|
||||
* the host sends the two as separate streams and either can play alone. Off by default: the
|
||||
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
|
||||
* duplicates audio they are already hearing.
|
||||
*/
|
||||
val padSpeaker: Boolean = false,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -264,6 +294,7 @@ class SettingsStore(context: Context) {
|
||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
|
||||
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
|
||||
@@ -271,6 +302,8 @@ class SettingsStore(context: Context) {
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
|
||||
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -301,6 +334,7 @@ class SettingsStore(context: Context) {
|
||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||
.putString(K_UI_PALETTE, s.uiPalette)
|
||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||
.putString(K_PRESENT_PRIORITY, s.presentPriority)
|
||||
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
|
||||
@@ -308,6 +342,8 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
|
||||
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -337,6 +373,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_TOUCH_MODE = "touch_mode"
|
||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||
const val K_LIBRARY = "library_enabled"
|
||||
const val K_UI_PALETTE = "ui_palette"
|
||||
|
||||
/**
|
||||
* Bumped AGAIN to restart every install at the new default (ON). History: the original
|
||||
@@ -355,6 +392,8 @@ class SettingsStore(context: Context) {
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_PAD_HAPTICS = "pad_haptics"
|
||||
const val K_PAD_SPEAKER = "pad_speaker"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
@@ -398,6 +437,96 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel [Settings.width]/[Settings.height] meaning "the native mode, narrowed so the picture
|
||||
* clears the display cutout and the rounded corners" — resolved at connect by [safeDisplayMode],
|
||||
* exactly as `0` is resolved by [nativeDisplayMode]. Negative, so it can never collide with a real
|
||||
* size; distinct from the UI's `-1` "Custom…" sentinel.
|
||||
*/
|
||||
const val SAFE_AREA_MODE = -2
|
||||
|
||||
/**
|
||||
* Safe-area stream geometry — the pure part, so it is unit-testable without a Display.
|
||||
*
|
||||
* The phone clips the picture in HARDWARE: the cutout (notch / punch-hole) and the four rounded
|
||||
* corners eat whatever the stream draws under them. [StreamScreen] deliberately draws edge-to-edge
|
||||
* (`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS`) and centres the video at its own aspect ratio
|
||||
* (`Modifier.aspectRatio`), so which pixels survive is decided purely by the mode's aspect:
|
||||
*
|
||||
* * A 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe
|
||||
* regions — which is why the presets have always "just worked".
|
||||
* * The NATIVE mode has the panel's own aspect, so it fills every pixel, cutout and corners
|
||||
* included. That is the mode that loses its corners.
|
||||
*
|
||||
* So asking the host for a mode narrower by the unsafe inset is the entire fix: the existing
|
||||
* aspect-fit centres it inside the safe region, and pointer mapping follows for free (MouseInput
|
||||
* derives the picture rect from the live video size, not from the window).
|
||||
*/
|
||||
object SafeArea {
|
||||
/** The host rejects odd dimensions and anything under 320 px wide (`validate_dimensions`). */
|
||||
const val MIN_WIDTH = 320
|
||||
|
||||
/**
|
||||
* [nativeWidth] reduced by [perSideInsetPx] on each side, even-floored and clamped to the
|
||||
* host's floor. Height is deliberately untouched: under aspect-fit only one axis can bind, and
|
||||
* on a landscape phone that axis is always the horizontal one — insetting height as well would
|
||||
* shrink the picture without uncovering anything.
|
||||
*/
|
||||
fun insetWidth(nativeWidth: Int, perSideInsetPx: Int): Int {
|
||||
val inset = perSideInsetPx.coerceAtLeast(0)
|
||||
return (nativeWidth - inset * 2).coerceAtLeast(MIN_WIDTH) / 2 * 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-side inset, in pixels, that the **landscape** stream must clear on this display.
|
||||
*
|
||||
* Two contributions, and the larger wins:
|
||||
* * **The cutout.** [DisplayCutout] is rotation-aware, so in landscape the housing shows up on
|
||||
* `left`/`right`. The settings screen may be portrait though, where the very same housing is
|
||||
* reported on `top`/`bottom` and the horizontal insets read zero — which would compute "no inset
|
||||
* needed" for exactly the devices that need one. The stream is always landscape, so a vertical
|
||||
* inset now becomes a horizontal one then: fall back to it.
|
||||
* * **The rounded corners.** These are NOT part of the cutout insets. For a FULL-HEIGHT picture the
|
||||
* horizontal clearance a corner of radius `r` needs is exactly `r`: at the topmost row the
|
||||
* display boundary sits at `x = r`, so anything left of that is clipped. Not conservative — it is
|
||||
* the precise requirement for a picture that spans the full height.
|
||||
*
|
||||
* `0` when the display has neither, which makes the safe mode identical to the native one.
|
||||
*/
|
||||
private fun displaySideInsetPx(context: Context): Int {
|
||||
val display = probeDisplay(context) ?: return 0
|
||||
var inset = 0
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
display.cutout?.let { cut ->
|
||||
val horizontal = maxOf(cut.safeInsetLeft, cut.safeInsetRight)
|
||||
val vertical = maxOf(cut.safeInsetTop, cut.safeInsetBottom)
|
||||
inset = maxOf(inset, if (horizontal > 0) horizontal else vertical)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
for (position in intArrayOf(
|
||||
android.view.RoundedCorner.POSITION_TOP_LEFT,
|
||||
android.view.RoundedCorner.POSITION_TOP_RIGHT,
|
||||
android.view.RoundedCorner.POSITION_BOTTOM_LEFT,
|
||||
android.view.RoundedCorner.POSITION_BOTTOM_RIGHT,
|
||||
)) {
|
||||
display.getRoundedCorner(position)?.let { inset = maxOf(inset, it.radius) }
|
||||
}
|
||||
}
|
||||
return inset
|
||||
}
|
||||
|
||||
/**
|
||||
* The native mode narrowed to clear the cutout and the rounded corners — the [SAFE_AREA_MODE]
|
||||
* resolution, as a landscape `(width, height, hz)`. Same height and refresh as [nativeDisplayMode];
|
||||
* only the width moves.
|
||||
*/
|
||||
fun safeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
val (w, h, hz) = nativeDisplayMode(context)
|
||||
return Triple(SafeArea.insetWidth(w, displaySideInsetPx(context)), h, hz)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this device's display can actually present HDR10, so we should advertise HDR to the
|
||||
* host. On an SDR panel we advertise `0` instead — the host then sends a proper 8-bit BT.709 stream
|
||||
@@ -432,12 +561,21 @@ fun displaySupportsHdr(context: Context): Boolean {
|
||||
return supported
|
||||
}
|
||||
|
||||
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
|
||||
/**
|
||||
* Resolve [Settings] (with its `0`=native and [SAFE_AREA_MODE] placeholders) to the concrete mode to
|
||||
* request. The safe-area sentinel is checked first because it resolves BOTH axes together — it is one
|
||||
* mode, not an independent width and height, and mixing half of it with a native height would ask
|
||||
* for a size neither sentinel means.
|
||||
*/
|
||||
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
|
||||
val native = nativeDisplayMode(context)
|
||||
val w = if (width > 0) width else native.first
|
||||
val h = if (height > 0) height else native.second
|
||||
val hz = if (hz > 0) hz else native.third
|
||||
val base = if (width == SAFE_AREA_MODE && height == SAFE_AREA_MODE) {
|
||||
safeDisplayMode(context)
|
||||
} else {
|
||||
nativeDisplayMode(context)
|
||||
}
|
||||
val w = if (width > 0) width else base.first
|
||||
val h = if (height > 0) height else base.second
|
||||
val hz = if (hz > 0) hz else base.third
|
||||
return Triple(w, h, hz)
|
||||
}
|
||||
|
||||
@@ -491,9 +629,10 @@ val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it)
|
||||
|
||||
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
|
||||
|
||||
/** (width, height, label). `(0,0)` = native display. */
|
||||
/** (width, height, label). `(0,0)` = native display; [SAFE_AREA_MODE] = native minus the cutout. */
|
||||
val RESOLUTION_OPTIONS = listOf(
|
||||
Triple(0, 0, "Native display"),
|
||||
Triple(SAFE_AREA_MODE, SAFE_AREA_MODE, "Native display (safe area)"),
|
||||
Triple(1280, 720, "1280 × 720"),
|
||||
Triple(1920, 1080, "1920 × 1080"),
|
||||
Triple(2560, 1440, "2560 × 1440"),
|
||||
|
||||
@@ -603,6 +603,10 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
||||
@Composable
|
||||
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
|
||||
val (nw, nh, nhz) = nativeDisplayMode(context)
|
||||
// The safe-area row carries its resolved size the same way the native row does. On a display with
|
||||
// no cutout and square corners this equals the native mode — the row stays, honestly showing that
|
||||
// it changes nothing here, rather than silently vanishing on some devices and not others.
|
||||
val (sw, sh, _) = safeDisplayMode(context)
|
||||
// "Custom…" picked while the stored size is still a preset — keeps the size fields visible
|
||||
// until an edit actually makes it custom (or a preset is re-picked). Custom itself is detected
|
||||
// from the stored size, never flagged (see [isCustomResolution]), so nothing new persists.
|
||||
@@ -611,7 +615,13 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
SettingsGroup("Resolution") {
|
||||
SettingDropdown(
|
||||
label = "Resolution",
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) } +
|
||||
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
|
||||
(w to h) to when (w) {
|
||||
0 -> "$lbl ($nw × $nh)"
|
||||
SAFE_AREA_MODE -> "$lbl ($sw × $sh)"
|
||||
else -> lbl
|
||||
}
|
||||
} +
|
||||
// The (-1, -1) sentinel can't collide with a real size; once a custom size is
|
||||
// stored its label carries the live value, like the native row carries ($nw × $nh).
|
||||
((-1 to -1) to if (s.isCustomResolution()) "Custom (${s.width} × ${s.height})" else "Custom…"),
|
||||
@@ -620,7 +630,10 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
|
||||
caption = "The host makes a display exactly this size — no scaling. Native follows " +
|
||||
"this device's panel.",
|
||||
) { (w, h) ->
|
||||
if (w < 0) {
|
||||
// ONLY -1 is "Custom…". The other negative value is the safe-area sentinel, which is a
|
||||
// stored mode like any preset — a blanket `w < 0` here would open the custom fields for it
|
||||
// and overwrite it with a concrete size.
|
||||
if (w == -1) {
|
||||
// Seed from the current *effective* size so the fields start from something
|
||||
// sensible (the resolved native mode, not the 0 × 0 placeholder).
|
||||
customPicked = true
|
||||
@@ -896,6 +909,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
enabled = s.gamepadForwarding,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
// Both only ever apply to a captured pad, so they follow that row and gate on it.
|
||||
ToggleRow(
|
||||
title = "Controller haptics",
|
||||
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
|
||||
"the pad keeps ordinary rumble for games that don't send them",
|
||||
checked = s.padHaptics,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Controller speaker",
|
||||
subtitle = "Play audio the game sends to the controller's own speaker",
|
||||
checked = s.padSpeaker,
|
||||
enabled = s.gamepadForwarding && s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,25 @@ import kotlin.math.roundToInt
|
||||
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow]`. Every read
|
||||
* is length-guarded, so an older native lib simply omits the lines it can't feed.
|
||||
*
|
||||
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
|
||||
* every tier, and the detailed tier names what was excluded on its own line. The principle is the
|
||||
* Apple client's: metrics report what Punktfunk controls, so the compositor's own latch and scanout
|
||||
* — which no client can pace under — is reported rather than charged. It also stops the HUD reading
|
||||
* worse than it is: the usual Android streaming overlays stop measuring at decode-complete, so a
|
||||
* headline that carried the compositor's wait was compared against numbers that never contained it.
|
||||
*
|
||||
* The RAW figures are not lost — the native 1 Hz `pf.present` logcat line keeps `paceMs`, `latchMs`
|
||||
* and `e2eMs` unshaved, so a HUD-off A/B and any cross-session comparison still work off the
|
||||
* untouched numbers.
|
||||
*
|
||||
* [verbosity] selects how many lines render (each tier a superset of the last — see
|
||||
* [StatsVerbosity]):
|
||||
* - [StatsVerbosity.COMPACT] — one line, `fps · end-to-end ms · Mb/s` (+ a loss flag).
|
||||
* - [StatsVerbosity.NORMAL] — the res/fps/Mb·s line, the end-to-end p50/p95 headline, and the
|
||||
* reliability counters (18–21) when nonzero.
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), and the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero).
|
||||
* - [StatsVerbosity.DETAILED] — also the decoder label, the video-feed descriptor (10–13), the
|
||||
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
|
||||
* and the excluded-floor line when one was measured.
|
||||
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
|
||||
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
|
||||
*/
|
||||
@@ -95,9 +107,15 @@ internal fun StatsOverlay(
|
||||
// equation gains its `display` term; otherwise (older lib / no callbacks) the endpoint
|
||||
// honestly stays capture→decoded — the equation always tiles the headline interval.
|
||||
val dispValid = s.size >= 26 && s[22] != 0.0
|
||||
// The OS present floor this window (see [osFloorMs]) is excluded from every shown
|
||||
// display / end-to-end number, at every tier — it is pipeline depth no client can pace
|
||||
// under, so charging it to Punktfunk made our HUD read worse than clients that simply
|
||||
// never measure it. 0.0 when unmeasured, which leaves the numbers exactly as raw as
|
||||
// they were.
|
||||
val floorMs = osFloorMs(s)
|
||||
val tag = if (skew) "" else " (same-host clock)"
|
||||
val (p50, p95, endpoint) = if (dispValid) {
|
||||
Triple(s[24], s[25], "capture→displayed")
|
||||
Triple(shave(s[24], floorMs), shave(s[25], floorMs), "capture→displayed")
|
||||
} else {
|
||||
Triple(s[2], s[3], "capture→decoded")
|
||||
}
|
||||
@@ -120,6 +138,11 @@ internal fun StatsOverlay(
|
||||
// dropping/serializing, an fps deficit is upstream.
|
||||
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
|
||||
val displayTerm = when {
|
||||
// Floor excluded: what remains of the `display` term is the half Punktfunk
|
||||
// owns (the presenter's pace wait), and the excluded line below carries the
|
||||
// latch — printing the split too would report the same milliseconds twice.
|
||||
dispValid && floorMs > 0 ->
|
||||
" + display ${"%.1f".format(shave(s[23], floorMs))}"
|
||||
dispValid && split ->
|
||||
" + display ${"%.1f".format(s[23])} " +
|
||||
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
|
||||
@@ -143,16 +166,14 @@ internal fun StatsOverlay(
|
||||
"= $hostTerms + $decodeTerm$displayTerm$presents",
|
||||
Color.White,
|
||||
)
|
||||
// Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS
|
||||
// pipeline floor off its shown display/end-to-end; Android shows raw. This twin
|
||||
// applies the same shave so iPhone↔Android HUD numbers compare directly.
|
||||
if (dispValid && hz > 0) {
|
||||
val shave = 2000.0 / hz
|
||||
// What the numbers above leave out, named — the Apple client's
|
||||
// `os present +N excluded` line, same wording so the two HUDs read alike.
|
||||
// (This replaces the old "≈ Apple-HUD equiv" twin: both clients now shave, and
|
||||
// Android's shave is measured rather than assumed at 2 refresh periods.)
|
||||
if (floorMs > 0) {
|
||||
statLine(
|
||||
"≈ Apple-HUD equiv: end-to-end " +
|
||||
"${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " +
|
||||
"${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (−2 refresh)",
|
||||
Color(0xFFA8D8B8),
|
||||
"os present +${"%.1f".format(floorMs)} excluded (display pipeline minimum)",
|
||||
Color(0xFF9AA6B8),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,6 +188,37 @@ private fun statLine(text: String, color: Color) {
|
||||
Text(text, color = color, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
}
|
||||
|
||||
/**
|
||||
* The OS present floor to exclude from the shown `display` / `end-to-end` numbers, ms — the
|
||||
* measured `latch` p50 at index 27, i.e. release→`OnFrameRendered`: SurfaceFlinger's own latch and
|
||||
* scanout. That is compositor pipeline depth no client can pace under, so it is reported as
|
||||
* excluded rather than charged to Punktfunk — the Apple client's policy since its presentation
|
||||
* rebuild, where the same floor is measured from the display link's vend lead.
|
||||
*
|
||||
* Measured, not assumed: the previous Android treatment used a fixed `2000/hz` twin, but the latch
|
||||
* varies with panel rate, tunnelled playback and the vendor's low-latency mode (~21 ms p50 observed
|
||||
* where the ~2-interval model predicts less), and this term self-adapts to all three. It is also
|
||||
* available on every render path — the presenter's and both legacy release-immediately ones — since
|
||||
* the release stamp it starts from is parked on every render, so it does not depend on
|
||||
* `presenterActive` (29).
|
||||
*
|
||||
* `0.0` means unmeasured — no display stage this window (an older native lib, API < 33, or a
|
||||
* platform that refused the callback), or no latch sample paired — and every caller then leaves its
|
||||
* number raw, which is the honest fallback: we exclude only what we actually measured.
|
||||
*/
|
||||
private fun osFloorMs(s: DoubleArray): Double {
|
||||
val dispValid = s.size >= 26 && s[22] != 0.0
|
||||
if (!dispValid || s.size < 28) return 0.0
|
||||
return s[27].coerceAtLeast(0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract the excluded [floorMs] from a shown latency [ms], clamped at zero — the percentiles are
|
||||
* drawn from different sample sets (a p50 latch against a p50/p95 end-to-end), so the difference can
|
||||
* legitimately go slightly negative on a well-paced window without anything being wrong.
|
||||
*/
|
||||
private fun shave(ms: Double, floorMs: Double): Double = (ms - floorMs).coerceAtLeast(0.0)
|
||||
|
||||
/**
|
||||
* The single [StatsVerbosity.COMPACT] line: `238 fps · 1.3 ms · 921 Mb/s`. The end-to-end p50 term
|
||||
* is dropped when no in-range latency sample landed (`latValid` false), and a loss flag
|
||||
@@ -174,8 +226,9 @@ private fun statLine(text: String, color: Color) {
|
||||
* one reliability signal worth surfacing even at the tersest tier.
|
||||
*/
|
||||
private fun compactLine(s: DoubleArray, latValid: Boolean): String {
|
||||
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window.
|
||||
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) s[24] else s[2]
|
||||
// Prefer the capture→displayed end-to-end (s[24]) when a render timestamp landed this window,
|
||||
// less the excluded OS present floor — the same number the richer tiers headline.
|
||||
val e2eP50 = if (s.size >= 26 && s[22] != 0.0) shave(s[24], osFloorMs(s)) else s[2]
|
||||
val parts = buildList {
|
||||
add("${s[0].roundToInt()} fps")
|
||||
if (latValid) add("${"%.1f".format(e2eP50)} ms")
|
||||
|
||||
@@ -73,6 +73,7 @@ import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -86,7 +87,7 @@ import kotlinx.coroutines.delay
|
||||
* the connect that produced this handle.
|
||||
*/
|
||||
@Composable
|
||||
fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> Unit) {
|
||||
val handle = session.handle
|
||||
val initialSettings = session.settings
|
||||
val micEnabled = initialSettings.micEnabled
|
||||
@@ -200,12 +201,32 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
if (NativeBridge.nativeSessionEnded(handle)) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
onDisconnect()
|
||||
// WHY it ended decides what the user is told. This used to show the "host may be
|
||||
// asleep" line for EVERY ending — including a game the player had just quit and a
|
||||
// session the host ended on purpose — which reads as a failure report for
|
||||
// something nobody did wrong. Only a connection that actually died says that now.
|
||||
val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle))
|
||||
when (reason) {
|
||||
SessionEndReason.LOST ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Connection lost — the host may be asleep. Wake it to reconnect.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
SessionEndReason.HOST_ERROR ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
"The host ended the session with an error.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
// Deliberate endings — the player quit the game, the host was stopped, or we
|
||||
// closed it. Leaving the stream IS the feedback; a toast would only add noise.
|
||||
SessionEndReason.GAME_EXITED,
|
||||
SessionEndReason.HOST_ENDED,
|
||||
SessionEndReason.LOCAL,
|
||||
SessionEndReason.NONE -> {}
|
||||
}
|
||||
onSessionEnded(reason)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
@@ -330,7 +351,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// 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
|
||||
// the same way the Back gesture does.
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) }
|
||||
router.onExitChord = { activity?.requestStreamExit?.invoke() }
|
||||
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
|
||||
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
|
||||
@@ -507,6 +528,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
|
||||
// audio device. Bound here rather than inside DsCapture because the session handle
|
||||
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
|
||||
// lifetime), this decides WHETHER.
|
||||
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
|
||||
ds.padAudio = object : DsCapture.PadAudioHook {
|
||||
override fun start(pad: Int, fd: Int) {
|
||||
val ok = NativeBridge.nativeStartPadAudio(
|
||||
handle,
|
||||
pad,
|
||||
fd,
|
||||
initialSettings.padHaptics,
|
||||
initialSettings.padSpeaker,
|
||||
)
|
||||
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
|
||||
}
|
||||
|
||||
// Returns only once the render thread is joined — DsCapture calls this before
|
||||
// closing the connection whose descriptor that thread borrows.
|
||||
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
|
||||
}
|
||||
}
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
@@ -595,7 +638,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
}
|
||||
|
||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) }
|
||||
|
||||
// Leaving the app (Home, task switch, screen off) MUST end the session. Android does not
|
||||
// suspend a process for going to background, so without this the native worker kept running and
|
||||
@@ -603,14 +646,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// host still saw a live client and held the session (and its display + encoder) open until the
|
||||
// OS eventually reclaimed the process, which on a TV box is effectively never.
|
||||
//
|
||||
// Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real
|
||||
// Route it through `onSessionEnded()` so the composable's `onDispose` above runs the one real
|
||||
// teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit",
|
||||
// so the host should linger the display and make coming straight back a fast reconnect.
|
||||
DisposableEffect(handle) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
onDisconnect()
|
||||
onSessionEnded(SessionEndReason.LOCAL)
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
|
||||
@@ -61,6 +61,16 @@ data class ActiveSession(
|
||||
* from "a different host" (a notice; a URL may never preempt a live session).
|
||||
*/
|
||||
val hostId: String? = null,
|
||||
/**
|
||||
* This session was started by launching a title from [hostId]'s library, rather than by
|
||||
* connecting to the host's desktop.
|
||||
*
|
||||
* Decides where the client goes when the session ENDS: a title launched out of a library
|
||||
* belongs back in that library when its game exits — one press from the next one — not on the
|
||||
* host-selection screen. Only meaningful together with a
|
||||
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
|
||||
*/
|
||||
val launchedFromLibrary: Boolean = false,
|
||||
)
|
||||
|
||||
/** Trust state of a host, shown as a colored pill on its card. */
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
// The console UI's background palettes. These assertions are the CONTRACT the Rust
|
||||
// (`pf-console-ui::library::tint`) and Swift (`GamepadPalette.tint`) ports have to reproduce — the
|
||||
// same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` value
|
||||
// names the same colour family on every client.
|
||||
class GamepadPaletteTest {
|
||||
/** The brightest pool of the field — the colour a palette is judged by. */
|
||||
private val violetPool = Triple(0.49, 0.39, 0.95)
|
||||
|
||||
/**
|
||||
* The brand default must be the IDENTITY transform. Every existing install already sees the
|
||||
* shipped violet backdrop, and a palette table that quietly restyled it would be a regression
|
||||
* dressed as a feature.
|
||||
*/
|
||||
@Test
|
||||
fun violetIsTheUntouchedShippedField() {
|
||||
val violet = GamepadPalette.named("violet")
|
||||
assertEquals("violet", GamepadPalette.ALL.first().id)
|
||||
assertTrue(violet.isIdentity)
|
||||
assertEquals(violetPool, violet.tint(violetPool))
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
assertEquals("violet", GamepadPalette.named("chartreuse").id)
|
||||
assertEquals("violet", GamepadPalette.named("").id)
|
||||
}
|
||||
|
||||
/** The ids and their order are the cross-client contract (strip order, and the L1/R1 cycle). */
|
||||
@Test
|
||||
fun tableMatchesTheOtherClients() {
|
||||
assertEquals(
|
||||
listOf("violet", "tide", "forest", "ember", "rose", "graphite"),
|
||||
GamepadPalette.ALL.map { it.id },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"),
|
||||
GamepadPalette.ALL.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A rotation moves the hue while roughly holding luminance, and the saturation scale collapses
|
||||
* toward grey — the same four checks the Rust and Swift tests make.
|
||||
*/
|
||||
@Test
|
||||
fun tintRotatesHueAndScalesSaturation() {
|
||||
assertTrue(violetPool.third > violetPool.first && violetPool.third > violetPool.second)
|
||||
|
||||
// +105° (Ember) turns the blue-dominant pool red-dominant…
|
||||
val ember = GamepadPalette.named("ember").tint(violetPool)
|
||||
assertTrue("$ember should be warm", ember.first > ember.third)
|
||||
// …−130° (Forest) turns it green-dominant…
|
||||
val forest = GamepadPalette.named("forest").tint(violetPool)
|
||||
assertTrue("$forest", forest.second > forest.first && forest.second > forest.third)
|
||||
// …and −70° (Tide) lands on a cyan whose green and blue both beat red.
|
||||
val tide = GamepadPalette.named("tide").tint(violetPool)
|
||||
assertTrue("$tide", tide.second > tide.first && tide.third > tide.first)
|
||||
|
||||
// Graphite's saturation scale leaves the channels nearly equal…
|
||||
val grey = GamepadPalette.named("graphite").tint(violetPool)
|
||||
val channels = listOf(grey.first, grey.second, grey.third)
|
||||
assertTrue("$grey", channels.max() - channels.min() < 0.08)
|
||||
// …at about the source's luminance (it desaturates, it doesn't dim).
|
||||
val luma = 0.2126 * violetPool.first + 0.7152 * violetPool.second + 0.0722 * violetPool.third
|
||||
assertEquals(luma, grey.second, 0.05)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every palette stays in gamut on every colour the field is built from — an out-of-range
|
||||
* channel would clamp differently on each platform's rasteriser.
|
||||
*/
|
||||
@Test
|
||||
fun everyPaletteStaysInGamut() {
|
||||
val field = listOf(
|
||||
Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72), Triple(0.30, 0.26, 0.74),
|
||||
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.22, 0.18, 0.54),
|
||||
Triple(0.24, 0.20, 0.58),
|
||||
)
|
||||
for (palette in GamepadPalette.ALL) {
|
||||
for (c in field) {
|
||||
val t = palette.tint(c)
|
||||
for (v in listOf(t.first, t.second, t.third)) {
|
||||
assertTrue("${palette.id} $c → $t", v in 0.0..1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every settings row lands in exactly one tab — a row missing from the tab map is a setting
|
||||
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
|
||||
*/
|
||||
@Test
|
||||
fun everySettingsRowHasATab() {
|
||||
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
|
||||
assertTrue(rows.isNotEmpty())
|
||||
assertEquals(rows.size, rows.map { it.id }.toSet().size)
|
||||
// Profiles is built separately (from the catalog), so no settings row claims it.
|
||||
assertTrue(rows.none { it.tab == GpTab.PROFILES })
|
||||
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
|
||||
assertTrue("$t is empty", rows.any { it.tab == t })
|
||||
}
|
||||
}
|
||||
|
||||
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
|
||||
@Test
|
||||
fun backgroundRowStepsTheSharedKey() {
|
||||
var s = Settings()
|
||||
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
|
||||
fun palette() = rows().first { it.id == "palette" }
|
||||
|
||||
assertEquals("violet", s.uiPalette)
|
||||
assertEquals("Violet", palette().value)
|
||||
assertTrue("already the first = thud", !palette().adjust(-1))
|
||||
assertTrue(palette().adjust(1))
|
||||
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
|
||||
|
||||
// A from the last entry wraps home.
|
||||
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
|
||||
palette().activate()
|
||||
assertEquals("violet", s.uiPalette)
|
||||
|
||||
// A store written by a newer client shows the palette that is actually drawing.
|
||||
s = s.copy(uiPalette = "chartreuse")
|
||||
assertEquals("Violet", palette().value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM test of the safe-area stream geometry ([SafeArea]) and the sentinel that selects it —
|
||||
* the width-only inset that keeps the picture clear of the cutout and the rounded corners.
|
||||
* Run: `./gradlew :app:testDebugUnitTest`.
|
||||
*/
|
||||
class SafeAreaTest {
|
||||
@Test
|
||||
fun insetsBothSidesAndStaysHostValid() {
|
||||
// A punch-hole phone: 2400 px wide, 96 px of unsafe edge per side → 2208.
|
||||
assertEquals(2400 - 96 * 2, SafeArea.insetWidth(2400, 96))
|
||||
// Odd results even-floor — the host rejects odd dimensions outright, and an inset
|
||||
// subtraction lands odd about half the time.
|
||||
assertEquals(0, SafeArea.insetWidth(2401, 95) % 2)
|
||||
// No cutout and square corners → the native width, unchanged.
|
||||
assertEquals(2400, SafeArea.insetWidth(2400, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absurdInsetsCannotDriveTheModeUnderTheHostFloor() {
|
||||
assertEquals(SafeArea.MIN_WIDTH, SafeArea.insetWidth(1280, 5000))
|
||||
// A negative reading is treated as no inset rather than widening past the panel.
|
||||
assertEquals(1280, SafeArea.insetWidth(1280, -40))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun safeModeIsNarrowerThanNativeWheneverThereIsAnInset() {
|
||||
val native = 2556
|
||||
assertTrue(SafeArea.insetWidth(native, 60) < native)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSentinelIsAPresetAndNeverReadsAsCustom() {
|
||||
// The safe-area mode is a stored preset, not a typed size: `isCustomResolution` must be
|
||||
// false for it, or the touch settings would open the custom width/height fields on it and
|
||||
// the gamepad screen would prepend a bogus "Custom · -2 × -2" row.
|
||||
val s = Settings(width = SAFE_AREA_MODE, height = SAFE_AREA_MODE)
|
||||
assertTrue(!s.isCustomResolution())
|
||||
// And it must be distinct from the UI's own "Custom…" sentinel (-1).
|
||||
assertTrue(SAFE_AREA_MODE != -1)
|
||||
assertTrue(RESOLUTION_OPTIONS.any { it.first == SAFE_AREA_MODE && it.second == SAFE_AREA_MODE })
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,9 @@ class ScreenshotTest {
|
||||
@Test
|
||||
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
|
||||
|
||||
@Test
|
||||
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -31,6 +31,7 @@ import io.unom.punktfunk.BrandDark
|
||||
import io.unom.punktfunk.ConnectModal
|
||||
import io.unom.punktfunk.ConnectPhase
|
||||
import io.unom.punktfunk.ConnectTakeover
|
||||
import io.unom.punktfunk.GamepadSettingsScreen
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.TouchMode
|
||||
import io.unom.punktfunk.SettingsCategory
|
||||
@@ -355,9 +356,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
|
||||
// dispValid, displayP50, e2eDispP50, e2eDispP95].
|
||||
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
|
||||
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
|
||||
// directly-measured capture→displayed pair (1.8/2.6) and the Phase-2 stage terms
|
||||
// (host 0.6 + network 0.3 + decode 0.4 + display 0.5) tile it, rendering the full split
|
||||
// equation; the decoder label shows the ranked low-latency decoder. Light per-window loss
|
||||
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
|
||||
// latch p50) — 1.5/2.3 shown from 1.8/2.6 raw — and the Phase-2 stage terms
|
||||
// (host 0.6 + network 0.3 + decode 0.4 + display 0.2) tile the shaved headline, with the
|
||||
// `os present +0.3 excluded` line naming what came off; the decoder label shows the ranked
|
||||
// low-latency decoder. Light per-window loss
|
||||
// (lost 2 · skipped 1 · FEC 5 of 238) so the reliability line (NORMAL/DETAILED) and the
|
||||
// compact loss flag both render.
|
||||
StatsOverlay(
|
||||
@@ -404,3 +407,13 @@ internal fun WakeTimedOutScene() =
|
||||
@Composable
|
||||
internal fun ConnectConsoleScene() =
|
||||
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
|
||||
|
||||
/**
|
||||
* The real console settings screen — the section tab strip, the glass rows, the focused row's
|
||||
* unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't
|
||||
* stand in for it: this is a different screen with different navigation, and the strip is the part
|
||||
* a layout regression would eat first.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleSettingsScene() =
|
||||
GamepadSettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {})
|
||||
|
||||
@@ -23,8 +23,9 @@ import android.view.InputDevice
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||
* and freed on unplug/[stop], so indices never leak.
|
||||
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
|
||||
* first parsed report as the fallback for a claim that found no free index, and freed on
|
||||
* unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||
@@ -78,6 +79,33 @@ class DsCapture(
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Tier-A pad audio, bound by the app layer (which owns the session handle).
|
||||
*
|
||||
* [start] is called once the router has assigned this pad a wire index, which the host uses to
|
||||
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
|
||||
* unplug alike — and must not return until nothing is still writing to the descriptor.
|
||||
*/
|
||||
interface PadAudioHook {
|
||||
fun start(pad: Int, fd: Int)
|
||||
fun stop(pad: Int)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var padAudio: PadAudioHook? = null
|
||||
|
||||
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
|
||||
@Volatile private var padAudioStarted = false
|
||||
|
||||
/**
|
||||
* The renderer's OWN connection to the pad.
|
||||
*
|
||||
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
|
||||
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
|
||||
* and the audio ring. Closed only after the hook's stop has returned.
|
||||
*/
|
||||
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
@@ -105,12 +133,17 @@ class DsCapture(
|
||||
// (the same init hid-playstation/SDL send on open).
|
||||
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
ensureSlot(m)
|
||||
onActiveChanged?.invoke(true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
// Before anything touches the link: the pad-audio renderer borrows this connection's
|
||||
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
|
||||
// joined, so ordering this first is what makes the borrow sound.
|
||||
stopPadAudio()
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
@@ -136,16 +169,112 @@ class DsCapture(
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
// Normally claimed already, at capture time; this is the retry for a capture that engaged
|
||||
// while every wire index was taken.
|
||||
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
|
||||
* indices are taken.
|
||||
*
|
||||
* Claimed when the capture engages rather than on the first report, because a pad that reports
|
||||
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
|
||||
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
|
||||
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
|
||||
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
|
||||
*/
|
||||
@Synchronized
|
||||
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
|
||||
pad?.let { return it }
|
||||
val p = router.openExternal(m.pref) ?: return null
|
||||
pad = p
|
||||
Log.i(TAG, "captured $m → wire pad ${p.index}")
|
||||
// The wire index exists from here on, and the host addresses pad audio by it.
|
||||
startPadAudio(p.index)
|
||||
return p
|
||||
}
|
||||
|
||||
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
|
||||
private fun startPadAudio(index: Int) {
|
||||
val hook = padAudio ?: return
|
||||
if (padAudioStarted) return
|
||||
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
|
||||
val conn = usb.openAuxConnection()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
if (fd < 0) {
|
||||
conn?.close()
|
||||
Log.w(TAG, "pad audio: could not open a second USB connection")
|
||||
return
|
||||
}
|
||||
padAudioConn = conn
|
||||
padAudioStarted = true
|
||||
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
|
||||
// drives the voice coils for N seconds through the actual client path before the renderer
|
||||
// takes over — the one check that proves the descriptor, the interface claim and the write
|
||||
// path all work on THIS device, without needing a host to be streaming. Same convention as
|
||||
// debug.punktfunk.force_parts.
|
||||
val secs = runCatching {
|
||||
Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java, String::class.java)
|
||||
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
|
||||
}.getOrNull()?.toIntOrNull() ?: 0
|
||||
if (secs > 0) {
|
||||
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
|
||||
// must not also drive it — two engines on one usbfs descriptor reap each other's
|
||||
// completions, which is precisely the fault this test exists to expose.
|
||||
Thread({
|
||||
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
|
||||
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
|
||||
}, "pf-pad-selftest").start()
|
||||
} else {
|
||||
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
|
||||
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
|
||||
// clears it — so without this the stream renders into a muted actuator and looks for
|
||||
// all the world like the host is sending nothing.
|
||||
restoreAudioHaptics()
|
||||
hook.start(index, fd)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
|
||||
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
|
||||
* the interrupt-OUT queue is busy or draining, and it is idempotent.
|
||||
*/
|
||||
private fun restoreAudioHaptics() {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
|
||||
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
|
||||
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the renderer, then close the connection whose descriptor it borrows — in that order.
|
||||
*
|
||||
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
|
||||
* descriptor whose device was gone, leaked the connection, and — because the started flag stayed
|
||||
* set and the native tier-A registry stayed armed for that index — cost the pad both its pad
|
||||
* audio and its wire rumble on the way back in.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun stopPadAudio() {
|
||||
if (!padAudioStarted) return
|
||||
padAudioStarted = false
|
||||
// The hook's stop joins the render thread, so nothing is using the descriptor once it
|
||||
// returns — only then is it safe to close the connection that owns it.
|
||||
pad?.let { padAudio?.stop(it.index) }
|
||||
padAudioConn?.close()
|
||||
padAudioConn = null
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
|
||||
stopPadAudio()
|
||||
disarmBackstop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
@@ -238,6 +367,10 @@ class DsCapture(
|
||||
// write — as this used to — meant a discarded stop left the motors running with
|
||||
// nothing scheduled to try again; a USB pad holds its last level until told zero.
|
||||
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
|
||||
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
|
||||
// haptics stream is live the coils it drives were muted by the very write that
|
||||
// silenced the motors. Give them back.
|
||||
if (sent && padAudioStarted) restoreAudioHaptics()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -276,6 +276,21 @@ object DsDevice {
|
||||
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
||||
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
||||
*/
|
||||
/**
|
||||
* B6: hand the voice coils back to the audio-haptics path.
|
||||
*
|
||||
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
|
||||
* "disable audio haptics" bit — the firmware mutes the coils the 0xD1 haptics stream drives.
|
||||
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
|
||||
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
|
||||
*
|
||||
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
|
||||
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
|
||||
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
|
||||
* client, which is the same packet one transport over.
|
||||
*/
|
||||
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
|
||||
|
||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||
|
||||
@@ -98,6 +98,40 @@ class HidUsbLink(
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
|
||||
*
|
||||
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
|
||||
* returns *any* completed request on that connection, and the same is true of the usbfs reap
|
||||
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
|
||||
* other's completions. This link's reader owns its connection exclusively (see the note on
|
||||
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
|
||||
* renderer — must open its own.
|
||||
*
|
||||
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
|
||||
* interface), so a claim made on this connection does not conflict with one made on that.
|
||||
*
|
||||
* The caller owns the returned connection and must close it.
|
||||
*/
|
||||
fun openAuxConnection(): UsbDeviceConnection? {
|
||||
val dev = device ?: return null
|
||||
return usb.openDevice(dev)
|
||||
}
|
||||
|
||||
/**
|
||||
* The open connection's usbfs file descriptor, or -1 when the link is not running.
|
||||
*
|
||||
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
|
||||
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
|
||||
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
|
||||
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
|
||||
* HID claim untouched.
|
||||
*
|
||||
* **The borrower must stop using it before [stop] runs**: closing the connection while a
|
||||
* transfer is in flight pulls the descriptor out from under the kernel.
|
||||
*/
|
||||
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
|
||||
@@ -69,6 +69,10 @@ object NativeBridge {
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
|
||||
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
|
||||
* so a captured pad's own render capabilities would have nothing to gate. */
|
||||
padAudioOk: Boolean,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
@@ -83,6 +87,18 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSessionEnded(handle: Long): Boolean
|
||||
|
||||
/**
|
||||
* WHY the session ended, as a [SessionEndReason] ordinal — decode with
|
||||
* [SessionEndReason.fromNative]. `0` (NONE) before it ends, or on a `0` handle.
|
||||
*
|
||||
* The companion to [nativeSessionEnded], which only says THAT it ended. Both are needed: the
|
||||
* flag to leave a dead stream, this to decide what to tell the user. A player quitting their
|
||||
* game and a host falling off the network both end the session, and with no way to separate
|
||||
* them the watchdog said "the host may be asleep" for all of them — wrong for every deliberate
|
||||
* ending. Cheap (one atomic load); UI-safe.
|
||||
*/
|
||||
external fun nativeEndReason(handle: Long): Int
|
||||
|
||||
/**
|
||||
* Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified
|
||||
* fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable).
|
||||
@@ -332,6 +348,46 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
|
||||
|
||||
/**
|
||||
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
|
||||
* 4-channel USB audio device.
|
||||
*
|
||||
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
|
||||
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
|
||||
* claim on the same device alone) and never closes the descriptor. The caller must keep the
|
||||
* connection open until [nativeStopPadAudio] returns.
|
||||
*
|
||||
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
|
||||
*
|
||||
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
|
||||
* NOT reported here — the renderer discovers that on its own thread and the session simply
|
||||
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
|
||||
*/
|
||||
external fun nativeStartPadAudio(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
fd: Int,
|
||||
haptics: Boolean,
|
||||
speaker: Boolean,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
|
||||
*
|
||||
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
|
||||
* as this returns, and not before.
|
||||
*/
|
||||
external fun nativeStopPadAudio(handle: Long, pad: Int)
|
||||
|
||||
/**
|
||||
* Drive the pad with a test tone through the real render path — no host, no session.
|
||||
*
|
||||
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
|
||||
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
|
||||
* the main thread. Returns sample frames written, or negative on failure.
|
||||
*/
|
||||
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
|
||||
|
||||
/**
|
||||
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
|
||||
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Why a stream session ended — the Kotlin mirror of `punktfunk_core::client::PunktfunkEndReason`,
|
||||
* read via [NativeBridge.nativeEndReason].
|
||||
*
|
||||
* The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a
|
||||
* player quitting their game and a host falling off the network both arrive as "the session
|
||||
* ended". With no way to tell them apart this client showed one message for all of them — and it
|
||||
* was the alarming one ("Connection lost — the host may be asleep"), in front of players who had
|
||||
* just quit their own game.
|
||||
*
|
||||
* Ordinals are an ABI contract with the Rust side: append only, never renumber.
|
||||
*/
|
||||
enum class SessionEndReason {
|
||||
/** Not ended, or ended before a reason could be observed. Also the fallback for an unknown value. */
|
||||
NONE,
|
||||
|
||||
/** This client closed the session — the user pressed back or stop. Nothing to report. */
|
||||
LOCAL,
|
||||
|
||||
/**
|
||||
* The host's launched game exited. A normal finish, and the one reason worth acting on: go back
|
||||
* to the library the title was launched from, so the next one is a tap away.
|
||||
*/
|
||||
GAME_EXITED,
|
||||
|
||||
/** The host ended the session deliberately (an operator "End", or it simply finished). Normal. */
|
||||
HOST_ENDED,
|
||||
|
||||
/** The host closed reporting a failure of its own. Worth showing; the host's log has the detail. */
|
||||
HOST_ERROR,
|
||||
|
||||
/**
|
||||
* The connection died rather than being closed: idle timeout, reset, the network going away.
|
||||
* This — and only this — is the "the host may be asleep, wake it" case.
|
||||
*/
|
||||
LOST;
|
||||
|
||||
/**
|
||||
* Is this an ordinary outcome rather than something to alarm the user about?
|
||||
*
|
||||
* The question nearly every caller actually asks. [LOCAL], [GAME_EXITED] and [HOST_ENDED] were
|
||||
* all meant to happen. [NONE] counts as normal — no evidence of trouble is not evidence of it.
|
||||
*/
|
||||
val isNormal: Boolean
|
||||
get() = this != HOST_ERROR && this != LOST
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Decode the JNI byte. An unrecognized value becomes [NONE] rather than throwing: this
|
||||
* crosses an ABI where the native side may be newer than this code.
|
||||
*/
|
||||
fun fromNative(v: Int): SessionEndReason = entries.getOrNull(v) ?: NONE
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,27 @@ class HostDiscovery(context: Context) {
|
||||
handler.post(poll)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear the browse down and start a fresh one. This is the manual rescan, and the recovery path
|
||||
* for a browse that started while blocked (permission not yet granted, multicast filtered) or
|
||||
* that never started at all ([start] gives up when `nativeDiscoveryStart` returns 0, and
|
||||
* nothing else would ever retry it).
|
||||
*
|
||||
* It also puts a query back on the wire: `mdns-sd` re-queries on a doubling backoff that caps
|
||||
* at an hour, so a long-lived browse is effectively passive — a host that appeared since, or
|
||||
* whose announcement was lost to multicast, may never be asked for again.
|
||||
*
|
||||
* The currently-shown host set is left alone across the swap (rather than blinking empty via
|
||||
* [stop]'s notification); the first poll of the new browse publishes the fresh set.
|
||||
*/
|
||||
fun restart() {
|
||||
val keep = onChange
|
||||
onChange = null
|
||||
stop()
|
||||
onChange = keep
|
||||
start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!running && nativeHandle == 0L) return
|
||||
running = false
|
||||
|
||||
@@ -37,9 +37,51 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
||||
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
|
||||
}
|
||||
|
||||
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
|
||||
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
|
||||
/**
|
||||
* One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`).
|
||||
*
|
||||
* [role] is `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that
|
||||
* 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).
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
val store: String,
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* 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
|
||||
* entry, which a Lutris or GOG title made a lie.
|
||||
*/
|
||||
val storeLabel: String get() = when (store) {
|
||||
"steam" -> "Steam"
|
||||
"custom" -> "Custom"
|
||||
"heroic" -> "Heroic"
|
||||
"lutris" -> "Lutris"
|
||||
"epic" -> "Epic"
|
||||
"gog" -> "GOG"
|
||||
"xbox" -> "Xbox"
|
||||
else -> "Game"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Design D4: launcher entries lead the shelf, keeping the host's title order within each group.
|
||||
* Applied once where the library is fetched, so no screen has to remember the rule — and a library
|
||||
* without launcher entries comes back untouched.
|
||||
*/
|
||||
fun List<GameEntry>.launchersFirst(): List<GameEntry> {
|
||||
val launchers = filter { it.isLauncher }
|
||||
return if (launchers.isEmpty()) this else launchers + filterNot { it.isLauncher }
|
||||
}
|
||||
|
||||
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
|
||||
@@ -108,10 +150,11 @@ object LibraryClient {
|
||||
header = resolveArt(str(art, "header"), base),
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
role = str(o, "role"),
|
||||
),
|
||||
)
|
||||
}
|
||||
return out
|
||||
return out.launchersFirst()
|
||||
}
|
||||
|
||||
/** A present, non-null, non-blank JSON string field, else null. */
|
||||
@@ -127,8 +170,14 @@ object LibraryClient {
|
||||
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
|
||||
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
|
||||
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
|
||||
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
|
||||
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
|
||||
* defers to normal public trust for any other origin (an external CDN URL).
|
||||
*
|
||||
* The two checks are only sound TOGETHER, and the composition is the point: the trust manager
|
||||
* cannot fail closed on its own (it has no hostname, so it must let a CDN chain through), so the
|
||||
* hostname verifier is what makes the pinned host pin-only. Loosen either and a publicly-trusted
|
||||
* certificate for any name is accepted for the host — which is exactly what 2026-08-05 review M-2
|
||||
* found. The host's own cert is self-signed with no matching SAN, so it can never satisfy the
|
||||
* default verifier; the pin is its only credential, on purpose.
|
||||
*/
|
||||
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
|
||||
val clientCert = CertificateFactory.getInstance("X.509")
|
||||
@@ -162,7 +211,26 @@ fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String)
|
||||
|
||||
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
val verifier = HostnameVerifier { hostname, session ->
|
||||
hostname == host || defaultVerifier.verify(hostname, session)
|
||||
if (hostname == host) {
|
||||
// The PINNED host fails closed: only the pinned leaf is acceptable for this name.
|
||||
//
|
||||
// This used to be a bare `hostname == host`, which composed with the trust manager's
|
||||
// system-CA fall-through into "any publicly-trusted certificate, for any name, is
|
||||
// accepted for the pinned host" — the pin was decorative (2026-08-05 review M-2). A
|
||||
// MITM with any free CA-issued cert intercepted the connection, received the client's
|
||||
// mTLS IDENTITY certificate, and served attacker-chosen library JSON and art URLs.
|
||||
// The Rust (`pf-client-core`) and Apple (`ClientTLS`) paths already fail closed here;
|
||||
// only Android did not.
|
||||
try {
|
||||
sha256Hex((session.peerCertificates.firstOrNull() as? X509Certificate)?.encoded ?: return@HostnameVerifier false) == pinned
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
// Any other origin (an external CDN art URL) is ordinary public trust: the system
|
||||
// trust manager validated the chain, and this checks the name against it.
|
||||
defaultVerifier.verify(hostname, session)
|
||||
}
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
|
||||
@@ -64,6 +64,14 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
|
||||
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
|
||||
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
|
||||
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
|
||||
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
|
||||
# should move when we choose to. Becomes a plain version dependency once the crates are published.
|
||||
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -77,6 +77,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
// handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
|
||||
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
|
||||
// write, and that bit disables the audio-haptics path — so one replayed command would
|
||||
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
|
||||
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
|
||||
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
|
||||
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
|
||||
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
|
||||
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
@@ -174,6 +182,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
out[3..n].copy_from_slice(&data);
|
||||
n
|
||||
}
|
||||
HidOutput::AudioCtl { .. } => {
|
||||
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
|
||||
// plane isn't rendered here either); drop it like TrackpadHaptic.
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
|
||||
@@ -37,6 +37,8 @@ mod discovery;
|
||||
mod feedback;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mic;
|
||||
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
|
||||
mod pad_audio;
|
||||
mod session;
|
||||
mod stats;
|
||||
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
pad_audio_ok: jboolean,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
|
||||
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
|
||||
// should say what the client does).
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
|
||||
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
|
||||
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
|
||||
// so declaring a pad's render caps later would have nothing to gate. Gated on the
|
||||
// settings so a user with pad audio off does not make the host provision endpoints.
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
| if pad_audio_ok != 0 {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
|
||||
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
|
||||
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
|
||||
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
audio: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex::new(None),
|
||||
#[cfg(target_os = "android")]
|
||||
pad_audio: Mutex::new(None),
|
||||
// A fresh session is never muted (mute is per-session UI state, not a setting).
|
||||
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
@@ -392,6 +404,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeEndReason(handle): Int` — WHY the session ended, as a
|
||||
/// `punktfunk_core::client::PunktfunkEndReason` byte (Kotlin mirrors it in `SessionEndReason`).
|
||||
///
|
||||
/// Companion to `nativeSessionEnded`, which only says THAT it ended. Kotlin's watchdog needs both:
|
||||
/// the flag to leave a dead stream, and this to decide what — if anything — to tell the user. A
|
||||
/// player quitting their game and a host dropping off the network both end the session, and until
|
||||
/// 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.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jint {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.client.end_reason() as jint
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN
|
||||
/// 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
|
||||
|
||||
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
|
||||
audio: Mutex<Option<crate::audio::AudioPlayback>>,
|
||||
#[cfg(target_os = "android")]
|
||||
mic: Mutex<Option<crate::mic::MicCapture>>,
|
||||
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
|
||||
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
|
||||
/// `Option` because a session may have no wired DualSense at all, which is the common case.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
|
||||
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
|
||||
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
|
||||
/// for the same reason the stats gate is: the mic stops and restarts across a surface
|
||||
@@ -99,6 +104,14 @@ impl SessionHandle {
|
||||
fn stop_mic(&self) {
|
||||
let _ = self.mic.lock().unwrap().take();
|
||||
}
|
||||
|
||||
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
|
||||
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
|
||||
/// `UsbDeviceConnection`. Idempotent.
|
||||
#[cfg(target_os = "android")]
|
||||
pub(crate) fn stop_pad_audio(&self) {
|
||||
let _ = self.pad_audio.lock().unwrap().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
|
||||
self.stop_audio();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_mic();
|
||||
#[cfg(target_os = "android")]
|
||||
self.stop_pad_audio();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
|
||||
/// DualSense pad audio on a descriptor Kotlin has already obtained.
|
||||
///
|
||||
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
|
||||
/// streaming interface. Kotlin owns that connection and **must keep it open until
|
||||
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
|
||||
/// closing early would pull it out from under an in-flight isochronous transfer.
|
||||
///
|
||||
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
|
||||
/// 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.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
fd: jni::sys::jint,
|
||||
haptics: jboolean,
|
||||
speaker: jboolean,
|
||||
) -> jboolean {
|
||||
jni_guard(0, || {
|
||||
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
// Replace any previous renderer first: dropping it joins the old thread, so two of them
|
||||
// can never hold the same descriptor at once.
|
||||
h.stop_pad_audio();
|
||||
// The capability declaration and the rumble suppression are NOT done here: the renderer
|
||||
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
|
||||
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
|
||||
// rumble and give it nothing in return — no haptics of any kind.
|
||||
match crate::pad_audio::start(
|
||||
std::sync::Arc::clone(&h.client),
|
||||
pad as u8,
|
||||
fd,
|
||||
haptics != 0,
|
||||
speaker != 0,
|
||||
) {
|
||||
Some(p) => {
|
||||
*h.pad_audio.lock().unwrap() = Some(p);
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
|
||||
/// tone through the real client render path, with no host and no session involved.
|
||||
///
|
||||
/// 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`).
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
fd: jni::sys::jint,
|
||||
seconds: jni::sys::jint,
|
||||
hz: jni::sys::jint,
|
||||
) -> jni::sys::jint {
|
||||
jni_guard(-1, || {
|
||||
if fd < 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
|
||||
// other transfers on it (it opens a dedicated connection for exactly this).
|
||||
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
|
||||
///
|
||||
/// 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.
|
||||
#[no_mangle]
|
||||
#[cfg(target_os = "android")]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jni::sys::jint,
|
||||
) {
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_pad_audio();
|
||||
if (0..16).contains(&pad) {
|
||||
// Withdraw the capability and hand the pad back to wire rumble, in that order:
|
||||
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
|
||||
h.client.set_pad_audio_caps(pad as u8, 0);
|
||||
crate::pad_audio::set_tier_a(pad as u8, false);
|
||||
crate::pad_audio::clear_haptics_liveness(pad as u8);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
|
||||
///
|
||||
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
|
||||
|
||||
@@ -206,6 +206,20 @@ struct ContentView: View {
|
||||
model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal)
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
// Coming back to the app re-arms the LAN browse. The home's `onAppear`/`onDisappear` do
|
||||
// NOT fire across background/foreground, and a browse the system suspended while we were
|
||||
// away does not resume on its own — so the host grid came back empty and stayed empty
|
||||
// until the app was relaunched. No-op unless the browse is already running (mid-session
|
||||
// the home has deliberately torn it down).
|
||||
//
|
||||
// Mobile only: macOS never suspends the process, and its `scenePhase` flips on every
|
||||
// window focus change — re-arming there would rebuild the browser each time you alt-tab.
|
||||
// A Mac browse that genuinely breaks is caught by `HostDiscovery`'s own sweep instead.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { discovery.refreshIfRunning() }
|
||||
}
|
||||
#endif
|
||||
#if os(iOS) || os(tvOS)
|
||||
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
|
||||
// ignored so neither branch fires for a Control-Center pull.
|
||||
//
|
||||
@@ -335,6 +349,16 @@ struct ContentView: View {
|
||||
active: fullscreenForSession && model.connection != nil,
|
||||
isFullscreen: $isFullscreen))
|
||||
#endif
|
||||
// A game launched from the library just exited, so the session ended on purpose: put the
|
||||
// player back in that host's library rather than on host selection. Set on the outer Group
|
||||
// (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) { _, host in
|
||||
guard let host else { return }
|
||||
model.returnToLibrary = nil
|
||||
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).
|
||||
|
||||
@@ -122,12 +122,21 @@ struct GamepadHintBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The console backdrop: a living aurora in the brand's violet family, 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 elliptical vignette (pools light in the centre, sinks
|
||||
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
|
||||
/// radial-blob field, unchanged, so nothing regresses.
|
||||
/// 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
|
||||
/// elliptical vignette (pools light in the centre, sinks the corners) and a top/bottom legibility
|
||||
/// scrim. Older OSes fall back to the original drifting radial-blob field, unchanged, so nothing
|
||||
/// regresses.
|
||||
///
|
||||
/// `calm` is what the FORM screens (settings, add-host) wear: the same living field with its pools
|
||||
/// dimmed onto its own corner colour, so those screens keep real colour under their Liquid Glass
|
||||
/// rows without the launcher's contrast. They used to sit on a still gradient; nothing in the
|
||||
/// gamepad UI is backed by a static image now. Motion is identical in both modes on purpose — only
|
||||
/// the contrast differs, so a screen change can't make the field jump.
|
||||
///
|
||||
/// `GamepadPalette` recolours the whole thing (the shared `ui_palette` setting) by transforming the
|
||||
/// COLOURS, not by stacking a filter — see GamepadPalette.swift for why.
|
||||
///
|
||||
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
|
||||
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
|
||||
@@ -136,35 +145,52 @@ struct GamepadHintBar: View {
|
||||
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
|
||||
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
|
||||
struct GamepadScreenBackground: View {
|
||||
/// Quiet the field for a form screen (see the type comment).
|
||||
var calm = false
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
|
||||
var body: some View {
|
||||
let palette = GamepadPalette.named(paletteID)
|
||||
Group {
|
||||
if reduceMotion {
|
||||
composite(at: 0)
|
||||
composite(at: 0, palette: palette)
|
||||
} else {
|
||||
// 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
|
||||
// redraw cost of a battery-fed couch device vs. the display's native rate.
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||
composite(at: context.date.timeIntervalSinceReferenceDate)
|
||||
composite(at: context.date.timeIntervalSinceReferenceDate, palette: palette)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
/// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the
|
||||
/// title/hints legibility scrim.
|
||||
private func composite(at t: TimeInterval) -> some View {
|
||||
/// The colour field under a very slow warm/cool hue sway, the calm flattening, an elliptical
|
||||
/// vignette, and the title/hints legibility scrim — in that order, matching the console
|
||||
/// shader's `composite` so the two platforms' backdrops stay the same picture.
|
||||
private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
colorField(at: t)
|
||||
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 + corner·0.4: over black, `.opacity` IS the multiply…
|
||||
.opacity(calm ? 0.6 : 1)
|
||||
if calm {
|
||||
// …and a plusLighter wash of the palette's own corner colour IS the add. Chosen so
|
||||
// a corner lands exactly where it was and the bright pools come down to meet it.
|
||||
Self.color(palette.tint(Self.cornerRGB))
|
||||
.opacity(0.4)
|
||||
.blendMode(.plusLighter)
|
||||
}
|
||||
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
|
||||
// Soft (extends past the frame) so the corners deepen rather than crush to black.
|
||||
// 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 to black just eats them.
|
||||
EllipticalGradient(
|
||||
colors: [.clear, .black.opacity(0.42)],
|
||||
colors: [.clear, .black.opacity(calm ? 0.21 : 0.42)],
|
||||
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
|
||||
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
|
||||
// darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to
|
||||
@@ -180,33 +206,45 @@ struct GamepadScreenBackground: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
|
||||
@ViewBuilder private func colorField(at t: TimeInterval, palette: GamepadPalette) -> some View {
|
||||
if #available(iOS 18, macOS 15, tvOS 18, *) {
|
||||
MeshGradient(
|
||||
width: 4, height: 4,
|
||||
points: Self.meshPoints(at: t),
|
||||
colors: Self.meshColors,
|
||||
colors: Self.meshColors(palette),
|
||||
smoothsColors: true)
|
||||
} else {
|
||||
LegacyBlobField(t: t)
|
||||
LegacyBlobField(t: t, palette: palette)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
|
||||
|
||||
static func color(_ c: SIMD3<Double>) -> Color {
|
||||
Color(red: c.x, green: c.y, blue: c.z)
|
||||
}
|
||||
|
||||
/// The corner colour — the four pinned corners AND the calm lift's base.
|
||||
static let cornerRGB = SIMD3(0.075, 0.060, 0.160)
|
||||
|
||||
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
|
||||
/// mid-tone violets, and the four interior points hold the bright brand family — a violet and a
|
||||
/// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on
|
||||
/// the right, and the silk shifts temperature as those interior points drift.
|
||||
private static let meshColors: [Color] = {
|
||||
let corner = Color(red: 0.075, green: 0.060, blue: 0.160)
|
||||
return [
|
||||
corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner,
|
||||
Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64),
|
||||
Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70),
|
||||
corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner,
|
||||
]
|
||||
}()
|
||||
/// the right, and the silk shifts temperature as those interior points drift. A palette rotates
|
||||
/// the whole grid; `violet` is the identity, so this array IS what the default draws.
|
||||
private static let baseMeshRGB: [SIMD3<Double>] = [
|
||||
cornerRGB, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), cornerRGB,
|
||||
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64),
|
||||
SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70),
|
||||
cornerRGB, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), cornerRGB,
|
||||
]
|
||||
|
||||
/// `baseMeshRGB` under a palette. Recomputed per frame rather than cached — sixteen `tint`
|
||||
/// calls at 30 Hz costs nothing next to rasterising the mesh, and the obvious cache would be
|
||||
/// mutable global state on a type SwiftUI is free to evaluate off the main actor.
|
||||
private static func meshColors(_ palette: GamepadPalette) -> [Color] {
|
||||
baseMeshRGB.map { color(palette.tint($0)) }
|
||||
}
|
||||
|
||||
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
|
||||
/// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black
|
||||
@@ -233,15 +271,18 @@ struct GamepadScreenBackground: View {
|
||||
}
|
||||
|
||||
/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field — four
|
||||
/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see
|
||||
/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+).
|
||||
/// soft colour blobs on slow Lissajous paths, additively blended. Geometry and motion are verbatim
|
||||
/// so older OSes see exactly the aurora they shipped with (the mesh path is the upgrade for OS
|
||||
/// 18/15+); only the blob COLOURS now pass through the palette, so an older device honours the
|
||||
/// setting too instead of being stuck on violet.
|
||||
private struct LegacyBlobField: View {
|
||||
let t: TimeInterval
|
||||
let palette: GamepadPalette
|
||||
|
||||
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
|
||||
/// (rad/s — periods of 30–90 s), and a radius that slowly breathes.
|
||||
private struct Blob {
|
||||
let color: Color
|
||||
let rgb: SIMD3<Double>
|
||||
let center: CGPoint
|
||||
let drift: CGSize
|
||||
let speed: (x: Double, y: Double)
|
||||
@@ -252,19 +293,19 @@ private struct LegacyBlobField: View {
|
||||
}
|
||||
|
||||
private static let blobs: [Blob] = [
|
||||
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
|
||||
Blob(rgb: SIMD3(0.53, 0.47, 0.96), // brand violet
|
||||
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
|
||||
speed: (0.111, 0.083), phase: (0.0, 1.9),
|
||||
radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52),
|
||||
Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo
|
||||
Blob(rgb: SIMD3(0.24, 0.20, 0.72), // deep indigo
|
||||
center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14),
|
||||
speed: (0.071, 0.096), phase: (2.4, 0.7),
|
||||
radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55),
|
||||
Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum
|
||||
Blob(rgb: SIMD3(0.62, 0.30, 0.80), // plum
|
||||
center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09),
|
||||
speed: (0.089, 0.067), phase: (4.1, 3.2),
|
||||
radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42),
|
||||
Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue
|
||||
Blob(rgb: SIMD3(0.22, 0.38, 0.86), // cool blue
|
||||
center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08),
|
||||
speed: (0.059, 0.104), phase: (1.2, 5.0),
|
||||
radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38),
|
||||
@@ -287,9 +328,10 @@ private struct LegacyBlobField: View {
|
||||
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
|
||||
let r = side * blob.radius
|
||||
* (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x)))
|
||||
let color = GamepadScreenBackground.color(palette.tint(blob.rgb))
|
||||
return Circle()
|
||||
.fill(RadialGradient(
|
||||
colors: [blob.color, blob.color.opacity(0)],
|
||||
colors: [color, color.opacity(0)],
|
||||
center: .center, startRadius: 0, endRadius: r / 2))
|
||||
.frame(width: r, height: r)
|
||||
.position(x: x * size.width, y: y * size.height)
|
||||
@@ -330,27 +372,16 @@ struct GamepadTrayScrim: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The calm backdrop for the gamepad UI's form screens (settings, add-host) — NOT the launcher's
|
||||
/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid
|
||||
/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo
|
||||
/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens,
|
||||
/// so the rows read as glass while the screen stays restful.
|
||||
/// 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
|
||||
/// living field at `calm`, which keeps that luminance under the glass, keeps the palette setting
|
||||
/// honoured on every screen rather than only the launcher, and leaves nothing in the gamepad UI
|
||||
/// backed by a static image. Kept as its own type because that is what the form screens ask for by
|
||||
/// name; the console (`pf-console-ui`) made the same substitution behind its `Bg::Form`.
|
||||
struct GamepadFormBackground: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.075, green: 0.062, blue: 0.150)
|
||||
// Violet lift top-leading, cooler indigo bottom-trailing — resolution-independent
|
||||
// (fraction radii) so the glow scale tracks the window on any screen.
|
||||
EllipticalGradient(
|
||||
colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear],
|
||||
center: UnitPoint(x: 0.26, y: 0.14),
|
||||
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||
EllipticalGradient(
|
||||
colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear],
|
||||
center: UnitPoint(x: 0.82, y: 0.9),
|
||||
startRadiusFraction: 0, endRadiusFraction: 0.78)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
GamepadScreenBackground(calm: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,14 +23,15 @@ import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
||||
/// action. Hashable so it can be the carousel's scroll-position identity.
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or one of the trailing
|
||||
/// actions. Hashable so it can be the carousel's scroll-position identity.
|
||||
private enum GamepadHomeTarget: Hashable {
|
||||
/// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) — which on a
|
||||
/// controller-first surface are THE profile affordance: focus and press, no menus.
|
||||
case saved(UUID, profile: String?)
|
||||
case discovered(String)
|
||||
case addHost
|
||||
case rescan
|
||||
}
|
||||
|
||||
/// A fully-resolved launcher tile — display fields + the activate action, built fresh each render
|
||||
@@ -262,10 +263,14 @@ struct GamepadHomeView: View {
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
let selected = tiles.first { $0.id == selection }
|
||||
let action: String? = switch selected?.id {
|
||||
case .addHost: "Add Host"
|
||||
case .rescan: "Rescan"
|
||||
default: nil
|
||||
}
|
||||
var hints = [GamepadHint(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||
text: selected?.id == .addHost ? "Add Host"
|
||||
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
if libraryEnabled, selected?.hasLibrary == true {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||
}
|
||||
@@ -325,7 +330,15 @@ struct GamepadHomeView: View {
|
||||
subtitle: "Register a host by address",
|
||||
icon: "plus",
|
||||
activate: { showAddHost = true })
|
||||
return saved + discovered + [add]
|
||||
// A controller surface has no toolbar and no pull-to-refresh, so the rescan the field
|
||||
// asked for is a tile like any other — one press from wherever the stick already is.
|
||||
let rescan = HomeTile(
|
||||
id: .rescan,
|
||||
title: "Rescan",
|
||||
subtitle: discovery.isScanning ? "Scanning…" : "Look for hosts on this network",
|
||||
icon: "arrow.clockwise",
|
||||
activate: { discovery.refresh() })
|
||||
return saved + discovered + [add, rescan]
|
||||
}
|
||||
|
||||
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
|
||||
|
||||
@@ -35,6 +35,10 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
let onActivate: (Item) -> Void
|
||||
/// B → back/dismiss; nil disables it.
|
||||
var onBack: (() -> Void)?
|
||||
/// L1 (`-1`) / R1 (`+1`) — a step SIDEWAYS out of the list: the settings screen's section
|
||||
/// tabs. Wired on tvOS too, where the focus engine owns up/down but leaves the shoulders
|
||||
/// to the poll. nil ⇒ the shoulders do nothing.
|
||||
var onShoulder: ((Int) -> Void)?
|
||||
/// Whether this list currently owns controller input — same handoff contract as
|
||||
/// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad).
|
||||
var isActive: Bool = true
|
||||
@@ -159,6 +163,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
case .up, .down: break
|
||||
}
|
||||
}
|
||||
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
|
||||
#else
|
||||
input.onMove = { direction in
|
||||
switch direction {
|
||||
@@ -170,6 +175,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
}
|
||||
input.onConfirm = { activate() }
|
||||
input.onBack = onBack
|
||||
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,18 @@ struct HomeView: View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
|
||||
emptyState
|
||||
#if os(tvOS)
|
||||
emptyState // no pull-to-refresh on a remote; the action row carries Refresh
|
||||
#else
|
||||
// Inside a ScrollView purely so the pull gesture works on the ONE screen
|
||||
// where a rescan matters most: the one that found nothing.
|
||||
ScrollView {
|
||||
emptyState
|
||||
.frame(maxWidth: .infinity)
|
||||
.containerRelativeFrame(.vertical)
|
||||
}
|
||||
.refreshable { await discovery.rescan() }
|
||||
#endif
|
||||
} else {
|
||||
ScrollView {
|
||||
if !store.hosts.isEmpty {
|
||||
@@ -94,6 +105,7 @@ struct HomeView: View {
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
refreshButton
|
||||
}
|
||||
.padding(.top, 24)
|
||||
// One FULL-WIDTH focus target for any downward move out of the grid.
|
||||
@@ -106,6 +118,9 @@ struct HomeView: View {
|
||||
.focusSection()
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.refreshable { await discovery.rescan() }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.navigationTitle("Punktfunk")
|
||||
@@ -151,6 +166,7 @@ struct HomeView: View {
|
||||
if showsArrangeMenu {
|
||||
ToolbarItem(placement: .topBarTrailing) { arrangeMenu }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) { refreshButton }
|
||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||
#else
|
||||
if showsArrangeMenu {
|
||||
@@ -159,6 +175,10 @@ struct HomeView: View {
|
||||
.help("Sort and group the host list")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
refreshButton
|
||||
.help("Scan the network for hosts again")
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
addHostButton
|
||||
.help("Add a host")
|
||||
@@ -324,13 +344,20 @@ struct HomeView: View {
|
||||
ContentUnavailableView {
|
||||
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
|
||||
} description: {
|
||||
Text("Add your punktfunk host with the + button.")
|
||||
Text("Add your punktfunk host with the + button, or scan the network again.")
|
||||
} actions: {
|
||||
Button("Add Host") { showAddHost = true }
|
||||
.glassProminentButtonStyle()
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
// The screen a host SHOULD have appeared on is where a rescan is worth offering
|
||||
// outright rather than hiding behind a pull gesture.
|
||||
Button("Scan Again") { discovery.refresh() }
|
||||
.disabled(discovery.isScanning)
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
Button("Settings") { showSettings = true }
|
||||
#endif
|
||||
@@ -345,6 +372,18 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-run mDNS discovery from scratch. Discovery heals itself now (`HostDiscovery`'s sweep),
|
||||
/// so this is the fallback the field asked for — and the fastest way past the iOS
|
||||
/// local-network permission gate, which only a NEW browser can clear.
|
||||
private var refreshButton: some View {
|
||||
Button {
|
||||
discovery.refresh()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(discovery.isScanning)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// One host has no order and nothing to divide, so the control stays out of the way until
|
||||
/// there is a list to arrange.
|
||||
|
||||
@@ -52,12 +52,15 @@ struct LibraryCoverflowView: View {
|
||||
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
|
||||
// safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and
|
||||
// clamped by width on a narrow screen.
|
||||
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
|
||||
let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0)
|
||||
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
|
||||
let coverWidth = coverHeight * 2 / 3
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 4)
|
||||
if showsGroupHeading {
|
||||
groupHeading.padding(.bottom, 6)
|
||||
}
|
||||
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
|
||||
detailPanel
|
||||
.padding(.top, 12)
|
||||
@@ -89,7 +92,9 @@ struct LibraryCoverflowView: View {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||
@@ -112,6 +117,23 @@ struct LibraryCoverflowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this library have both groups? Only then does the heading earn its row — a
|
||||
/// launcher-less library gets exactly the layout it had before design D4.
|
||||
private var showsGroupHeading: Bool {
|
||||
games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher }
|
||||
}
|
||||
|
||||
/// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus
|
||||
/// rail (a whole new up/down nav model for two or three tiles) the heading names the group and
|
||||
/// changes as the selection crosses the boundary — the launcher entries lead the strip.
|
||||
private var groupHeading: some View {
|
||||
let selected = games.first { $0.id == selection }
|
||||
return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
}
|
||||
|
||||
/// The centered title + store tag — empty (not hidden) so the layout doesn't jump.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let game = games.first { $0.id == selection }
|
||||
@@ -123,10 +145,13 @@ struct LibraryCoverflowView: View {
|
||||
.minimumScaleFactor(0.75)
|
||||
.multilineTextAlignment(.center)
|
||||
if let game {
|
||||
Text(game.isCustom ? "CUSTOM" : "STEAM")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
Text(
|
||||
game.isLauncher
|
||||
? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased()
|
||||
)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -139,7 +164,10 @@ struct LibraryCoverflowView: View {
|
||||
private var hints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = []
|
||||
if onLaunch != nil {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch"))
|
||||
// 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"))
|
||||
}
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
|
||||
return hints
|
||||
|
||||
@@ -80,21 +80,47 @@ struct LibraryView: View {
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(games) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
// Design D4: launcher entries get their own section above the titles, never interleaved.
|
||||
// Both headers appear only when both groups exist, so a library without launcher entries
|
||||
// renders exactly as it did before.
|
||||
let launchers = games.filter(\.isLauncher)
|
||||
let titles = games.filter { !$0.isLauncher }
|
||||
let both = !launchers.isEmpty && !titles.isEmpty
|
||||
return ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !launchers.isEmpty {
|
||||
if both { sectionHeader("Launchers") }
|
||||
tiles(launchers)
|
||||
}
|
||||
if !titles.isEmpty {
|
||||
if both { sectionHeader("Games") }
|
||||
tiles(titles)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
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, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
||||
.tracking(1.1)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var columns: [GridItem] {
|
||||
#if os(tvOS)
|
||||
let minW: CGFloat = 220
|
||||
@@ -152,12 +178,15 @@ struct LibraryView: View {
|
||||
return
|
||||
}
|
||||
do {
|
||||
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
|
||||
// the gamepad coverflow both inherit the D4 ordering.
|
||||
games = try await LibraryClient.fetch(
|
||||
address: current.address,
|
||||
port: current.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM,
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256)
|
||||
hostFingerprint: current.pinnedSHA256
|
||||
).launchersFirst
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = try LibraryImageLoader.session(
|
||||
address: current.address,
|
||||
@@ -185,7 +214,9 @@ private struct GameCard: View {
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
}
|
||||
Text(game.title)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -12,14 +12,21 @@ import AppKit
|
||||
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster —
|
||||
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
|
||||
struct StoreBadge: View {
|
||||
let isCustom: Bool
|
||||
/// Which store surfaced the entry, already resolved to a display name (`GameEntry.storeLabel`).
|
||||
let label: String
|
||||
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
|
||||
/// without reading the title.
|
||||
var isLauncher: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Text(isCustom ? "Custom" : "Steam")
|
||||
Text(label)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.background(
|
||||
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
|
||||
in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,14 @@ final class SessionModel: ObservableObject {
|
||||
@Published private(set) var connection: PunktfunkConnection?
|
||||
/// The host this session is for (a value copy; identity = id).
|
||||
@Published private(set) var activeHost: StoredHost?
|
||||
/// The library entry this session was launched with (`connect(launchID:)`), or nil if the user
|
||||
/// just connected to the host's desktop. Kept because where the client should go when the
|
||||
/// 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?
|
||||
/// 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.
|
||||
@@ -249,6 +257,7 @@ final class SessionModel: ObservableObject {
|
||||
guard phase == .idle else { return }
|
||||
phase = .connecting
|
||||
activeHost = host
|
||||
launchedTitleID = launchID
|
||||
errorMessage = nil
|
||||
settings = effective
|
||||
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
|
||||
@@ -607,6 +616,8 @@ final class SessionModel: ObservableObject {
|
||||
}
|
||||
connection = nil
|
||||
activeHost = nil
|
||||
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
|
||||
launchedTitleID = nil
|
||||
phase = .idle
|
||||
fps = 0
|
||||
mbps = 0
|
||||
@@ -626,10 +637,36 @@ final class SessionModel: ObservableObject {
|
||||
|
||||
/// Called (via the main actor) when the pump hits end-of-session.
|
||||
func sessionEnded() {
|
||||
guard connection != nil else { return }
|
||||
guard let conn = connection else { return }
|
||||
let name = activeHost?.displayName ?? "host"
|
||||
// WHY it ended, asked while the connection is still up — `disconnect` tears it down.
|
||||
let reason = conn.sessionEndReason
|
||||
// Where a game exit sends us: back into the library this title was launched from, so the
|
||||
// next one is a tap away. Only for a launch that CAME from the library — a game exiting in
|
||||
// a plain desktop session has no library to return to.
|
||||
let host = activeHost
|
||||
let cameFromLibrary = launchedTitleID != nil
|
||||
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
||||
errorMessage = "Session ended by \(name)."
|
||||
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, let host {
|
||||
returnToLibrary = host
|
||||
}
|
||||
case .hostEnded, .local:
|
||||
// Someone asked for this: an operator "End" on the host, or our own close racing in.
|
||||
// Say it plainly, without the error framing.
|
||||
errorMessage = "\(name) ended the session."
|
||||
case .hostError:
|
||||
errorMessage = "\(name) ended the session with an error."
|
||||
case .lost:
|
||||
errorMessage = "Lost the connection to \(name)."
|
||||
case .none:
|
||||
// No verdict (an older core, or the close raced the read): keep the wording this path
|
||||
// has always used rather than inventing one.
|
||||
errorMessage = "Session ended by \(name)."
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||||
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||||
//
|
||||
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// The rows are split across SECTION TABS (`GpSettingsTab`) — L1/R1 on a pad, a tap elsewhere. They
|
||||
// used to be one long scroll with inline group headers, which meant thumbing past Video and Audio
|
||||
// to reach the controller settings; a tab is one shoulder press, and each tab remembers where its
|
||||
// focus was. The tab names match the desktop console's and the Android client's, so a setting is
|
||||
// found under the same word wherever you look for it.
|
||||
//
|
||||
// The trailing Profiles tab (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an
|
||||
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
|
||||
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
|
||||
@@ -27,6 +33,17 @@ import GameController
|
||||
import CoreHaptics
|
||||
#endif
|
||||
|
||||
/// The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
|
||||
/// match `pf-console-ui`'s `TABS` and the Android client's `GpTab`.
|
||||
enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
case stream = "Stream"
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
case controller = "Controller"
|
||||
case interface = "Interface"
|
||||
case profiles = "Profiles"
|
||||
}
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
@@ -55,6 +72,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as
|
||||
/// the row steps, which is why the picker lives here and not in a sheet.
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@@ -74,12 +94,23 @@ struct GamepadSettingsView: View {
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
/// `.regular` only on an iPad-class window — see `showsSectionHint`.
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
/// The section showing. The pin picker ignores it — that layer replaces the whole list.
|
||||
@State private var tab: GpSettingsTab = .stream
|
||||
/// Where each tab's focus was when it was last left, so a detour doesn't lose your place.
|
||||
@State private var tabFocus: [GpSettingsTab: String] = [:]
|
||||
@Namespace private var tabHighlight
|
||||
#if os(tvOS)
|
||||
/// Real focus on the strip — the tvOS route to the sections (see `tabStrip`).
|
||||
@FocusState private var focusedTab: GpSettingsTab?
|
||||
#endif
|
||||
/// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per
|
||||
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
|
||||
@State private var pinTarget: StreamProfile?
|
||||
@@ -93,7 +124,8 @@ struct GamepadSettingsView: View {
|
||||
focusID: $focusID,
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { back() }
|
||||
onBack: { back() },
|
||||
onShoulder: { step(tabBy: $0) }
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -101,14 +133,19 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
VStack(spacing: compact ? 4 : 8) {
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||
// The picker is one layer deeper — its rows aren't sections of anything, so the
|
||||
// strip would be a control that does nothing while it's up.
|
||||
if pinTarget == nil { tabStrip }
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -127,8 +164,9 @@ struct GamepadSettingsView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
// No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the
|
||||
// glass rows are the only material on the screen.
|
||||
// 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
|
||||
// applies here too, so this screen previews the row you're stepping.
|
||||
.background { GamepadFormBackground() }
|
||||
.onAppear {
|
||||
gamepads.refresh()
|
||||
@@ -137,6 +175,101 @@ struct GamepadSettingsView: View {
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
|
||||
/// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to
|
||||
/// squeeze six pills — the selected one is always scrolled into view, whether it was reached
|
||||
/// by shoulder button, tap, or (tvOS) the focus engine.
|
||||
private var tabStrip: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(GpSettingsTab.allCases, id: \.self) { t in
|
||||
#if os(tvOS)
|
||||
// Focusable, because L1/R1 is NOT a route here: a Siri Remote has no
|
||||
// extended gamepad profile, so it never reaches GamepadMenuList's poll.
|
||||
// As focusable Buttons the pills are simply above the rows, and moving
|
||||
// focus up onto one switches section — the standard tvOS tab bar.
|
||||
Button { select(tab: t) } label: { pill(t) }
|
||||
.buttonStyle(ConsoleBareButtonStyle())
|
||||
.focused($focusedTab, equals: t)
|
||||
.id(t)
|
||||
#else
|
||||
pill(t)
|
||||
.contentShape(Capsule())
|
||||
.onTapGesture { select(tab: t) }
|
||||
.id(t)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.animation(.smooth(duration: 0.22), value: tab)
|
||||
.onChange(of: tab) { _, t in
|
||||
withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(t) }
|
||||
}
|
||||
#if os(tvOS)
|
||||
.onChange(of: focusedTab) { _, t in
|
||||
// Focus IS selection on a tab bar; nil means focus dropped back into the rows.
|
||||
if let t { select(tab: t) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func pill(_ t: GpSettingsTab) -> some View {
|
||||
let selected = t == tab
|
||||
return Text(t.rawValue)
|
||||
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
|
||||
.foregroundStyle(selected ? .white : .white.opacity(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.
|
||||
if selected {
|
||||
Capsule()
|
||||
.fill(Color.brand.opacity(0.85))
|
||||
.matchedGeometryEffect(id: "tab", in: tabHighlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the legend advertises the shoulder shortcut. Held back on an iPhone, whose legend
|
||||
/// is already at its width and would push "Done" off the edge — the strip is visible and
|
||||
/// tappable there anyway. Never on tvOS: a Siri Remote has no shoulders, and its route to the
|
||||
/// sections is the focus engine (see `tabStrip`).
|
||||
private var showsSectionHint: Bool {
|
||||
#if os(tvOS)
|
||||
false
|
||||
#elseif os(iOS)
|
||||
hSizeClass == .regular
|
||||
#else
|
||||
true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// L1/R1 — one section along, wrapping (the strip is a ring, like A's value cycle).
|
||||
private func step(tabBy delta: Int) {
|
||||
guard pinTarget == nil else { return }
|
||||
let all = GpSettingsTab.allCases
|
||||
guard let i = all.firstIndex(of: tab) else { return }
|
||||
let n = all.count
|
||||
select(tab: all[((i + delta) % n + n) % n])
|
||||
}
|
||||
|
||||
private func select(tab next: GpSettingsTab) {
|
||||
guard next != tab else { return }
|
||||
tabFocus[tab] = focusID
|
||||
// Restore where this tab was, if that row is still in it (a row can come and go with the
|
||||
// hardware it depends on); otherwise the focus list seeds its first row. Resolved against
|
||||
// `allRows` rather than `rows` so it doesn't depend on `tab`'s write being visible yet.
|
||||
let landing = tabFocus[next].flatMap { id in
|
||||
allRows.contains { $0.tab == next && $0.id == id } ? id : nil
|
||||
}
|
||||
tab = next
|
||||
focusID = landing
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
/// rides the cancel action.
|
||||
private var closeButton: some View {
|
||||
@@ -166,12 +299,19 @@ struct GamepadSettingsView: View {
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
guard pinTarget != nil else {
|
||||
// The shoulders change section, so that cell leads — where it fits and where the
|
||||
// shoulders exist at all (see `showsSectionHint`).
|
||||
let sections: [GamepadHint] = showsSectionHint
|
||||
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
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 [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
return sections
|
||||
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
}
|
||||
return [
|
||||
return sections + [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
@@ -201,15 +341,9 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
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) {
|
||||
if let header = row.header {
|
||||
Text(header)
|
||||
.font(.geist(m.headerFont, .semibold, relativeTo: .caption))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.padding(.leading, m.rowHPad)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: row.icon)
|
||||
.font(.system(size: m.iconFont))
|
||||
@@ -276,8 +410,9 @@ struct GamepadSettingsView: View {
|
||||
|
||||
private struct Row: Identifiable {
|
||||
let id: String
|
||||
/// Section header drawn above this row (the first row of each group carries it).
|
||||
var header: String?
|
||||
/// Which section tab this row belongs to. Every row has exactly one, and `rows` shows
|
||||
/// only the current tab's — see `allRows`.
|
||||
var tab: GpSettingsTab = .stream
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
@@ -313,10 +448,17 @@ struct GamepadSettingsView: View {
|
||||
row.activate()
|
||||
}
|
||||
|
||||
/// What the focus list actually shows: the current tab's rows — or the pin picker's, which
|
||||
/// replaces the whole list while it's up (same screen, one layer deeper, so the focus list's
|
||||
/// controller wiring and the tvOS focus engine carry over as is).
|
||||
private var rows: [Row] {
|
||||
// The pin picker replaces the whole list while it's up — same screen, one layer deeper,
|
||||
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
return allRows.filter { $0.tab == tab }
|
||||
}
|
||||
|
||||
/// Every row on the screen, tagged with its section. Built as one list (not per tab) so the
|
||||
/// platform-conditional insertions below can still place a row RELATIVE to another by id.
|
||||
private var allRows: [Row] {
|
||||
let resolution = resolutionOptions
|
||||
let refresh = SettingsOptions.refreshRates(including: hz)
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
@@ -324,7 +466,7 @@ struct GamepadSettingsView: View {
|
||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||
var list: [Row] = [
|
||||
choiceRow(
|
||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||
id: "resolution", tab: .stream, icon: "aspectratio",
|
||||
label: "Resolution",
|
||||
detail: "The host creates a virtual display at exactly this size — no scaling.",
|
||||
options: resolution, current: "\(width)x\(height)"
|
||||
@@ -335,53 +477,48 @@ struct GamepadSettingsView: View {
|
||||
height = parts[1]
|
||||
},
|
||||
choiceRow(
|
||||
id: "refresh", icon: "gauge.with.needle", label: "Refresh rate",
|
||||
id: "refresh", tab: .stream, icon: "gauge.with.needle", label: "Refresh rate",
|
||||
detail: "Rates this display can actually show.",
|
||||
options: refresh, current: hz
|
||||
) { hz = $0 },
|
||||
choiceRow(
|
||||
id: "bitrate", icon: "speedometer", label: "Bitrate",
|
||||
id: "bitrate", tab: .stream, icon: "speedometer", label: "Bitrate",
|
||||
detail: "Automatic uses the host's default (20 Mbps). "
|
||||
+ "Run a speed test from the touch UI for an informed value.",
|
||||
options: bitrate, current: bitrateKbps
|
||||
) { bitrateKbps = $0 },
|
||||
choiceRow(
|
||||
id: "compositor", icon: "macwindow", label: "Compositor",
|
||||
id: "compositor", tab: .stream, icon: "macwindow", label: "Compositor",
|
||||
detail: "Which compositor drives the virtual output — honored only if "
|
||||
+ "available on the host.",
|
||||
options: SettingsOptions.compositors, current: compositor
|
||||
) { compositor = $0 },
|
||||
toggleRow(
|
||||
id: "autoWake", icon: "power", label: "Auto-wake on connect",
|
||||
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
|
||||
+ "streaming. Off connects straight through.",
|
||||
value: $autoWakeEnabled),
|
||||
|
||||
choiceRow(
|
||||
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
||||
id: "codec", tab: .video, icon: "film", label: "Video codec",
|
||||
detail: "A preference — the host falls back if it can't encode this one "
|
||||
+ "(10-bit and 4:4:4 are HEVC-only).",
|
||||
options: SettingsOptions.codecs, current: codec
|
||||
) { codec = $0 },
|
||||
toggleRow(
|
||||
id: "hdr", icon: "sun.max", label: "10-bit HDR",
|
||||
id: "hdr", tab: .video, icon: "sun.max", label: "10-bit HDR",
|
||||
detail: "HDR10 — engages when the host sends HDR content and this display "
|
||||
+ "supports it.",
|
||||
value: $hdrEnabled),
|
||||
toggleRow(
|
||||
id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)",
|
||||
id: "chroma", tab: .video, icon: "textformat", label: "Full chroma (4:4:4)",
|
||||
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
||||
+ "hardware decode.",
|
||||
value: $enable444),
|
||||
choiceRow(
|
||||
id: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
||||
id: "presentPriority", tab: .video, icon: "rectangle.stack", label: "Prioritize",
|
||||
detail: "Lowest latency shows each frame the moment the display can take it; "
|
||||
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
||||
+ "from the next session.",
|
||||
options: SettingsOptions.presentPriorities, current: presentPriority
|
||||
) { presentPriority = $0 },
|
||||
choiceRow(
|
||||
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
|
||||
id: "smoothBuffer", tab: .video, icon: "square.stack.3d.up",
|
||||
label: "Smoothness buffer",
|
||||
detail: "How many frames Smoothness holds — each adds about a refresh of "
|
||||
+ "display latency and absorbs about a refresh of jitter. Only applies "
|
||||
+ "when prioritizing smoothness.",
|
||||
@@ -389,22 +526,22 @@ struct GamepadSettingsView: View {
|
||||
) { smoothBuffer = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||
id: "audio", tab: .audio, icon: "speaker.wave.2", label: "Audio channels",
|
||||
detail: "The speaker layout requested from the host.",
|
||||
options: SettingsOptions.audioChannels, current: audioChannels
|
||||
) { audioChannels = $0 },
|
||||
toggleRow(
|
||||
id: "mic", icon: "mic", label: "Microphone",
|
||||
id: "mic", tab: .audio, icon: "mic", label: "Microphone",
|
||||
detail: "Send this device's microphone to the host's virtual mic.",
|
||||
value: $micEnabled),
|
||||
toggleRow(
|
||||
id: "echoCancel", icon: "waveform", label: "Echo cancellation",
|
||||
id: "echoCancel", tab: .audio, icon: "waveform", label: "Echo cancellation",
|
||||
detail: "Cancel the audio this device plays out of the mic signal — stops "
|
||||
+ "speaker setups feeding the game back to the host.",
|
||||
value: $echoCancel),
|
||||
|
||||
toggleRow(
|
||||
id: "padForward", header: "Controller", icon: "gamecontroller",
|
||||
id: "padForward", tab: .controller, icon: "gamecontroller",
|
||||
label: "Forward controllers",
|
||||
detail: "Send this device's controllers to the host. Turn it off when your "
|
||||
+ "controller already reaches the host another way — USB passthrough such "
|
||||
@@ -415,26 +552,28 @@ struct GamepadSettingsView: View {
|
||||
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
|
||||
// `Row.enabled` existed, so it alone left them live and steppable.
|
||||
choiceRow(
|
||||
id: "pad", icon: "gamecontroller", label: "Use controller",
|
||||
id: "pad", tab: .controller, icon: "gamecontroller", label: "Use controller",
|
||||
detail: "Which pad is forwarded to the host, as player 1.",
|
||||
options: controllers, current: gamepads.preferredID,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepads.preferredID = $0 },
|
||||
choiceRow(
|
||||
id: "padType", icon: "dpad", label: "Controller type",
|
||||
id: "padType", tab: .controller, icon: "dpad", label: "Controller type",
|
||||
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||||
options: SettingsOptions.padTypes, current: gamepadType,
|
||||
enabled: gamepadForwarding
|
||||
) { gamepadType = $0 },
|
||||
choiceRow(
|
||||
id: "systemButtons", icon: "house.circle", label: "Guide button",
|
||||
id: "systemButtons", tab: .controller, icon: "house.circle",
|
||||
label: "Guide button",
|
||||
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
|
||||
+ "Automatic sends them to the host whenever this device delivers them.",
|
||||
options: SettingsOptions.systemButtons, current: systemButtons,
|
||||
enabled: gamepadForwarding
|
||||
) { systemButtons = $0 },
|
||||
choiceRow(
|
||||
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
|
||||
id: "guideGesture", tab: .controller, icon: "hand.point.up.left",
|
||||
label: "Hold Select for guide",
|
||||
detail: "Hold Select alone to press the host's guide button — keep holding "
|
||||
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
|
||||
options: SettingsOptions.guideGestures, current: guideGesture,
|
||||
@@ -442,33 +581,47 @@ struct GamepadSettingsView: View {
|
||||
) { guideGesture = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||
id: "palette", tab: .interface, icon: "paintpalette", label: "Background",
|
||||
detail: "The colour family this backdrop drifts through — it changes as you "
|
||||
+ "step, so pick by looking. Appearance only.",
|
||||
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
|
||||
current: GamepadPalette.named(paletteID).id
|
||||
) { paletteID = $0 },
|
||||
toggleRow(
|
||||
id: "autoWake", tab: .interface, icon: "power", label: "Auto-wake on connect",
|
||||
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
|
||||
+ "streaming. Off connects straight through.",
|
||||
value: $autoWakeEnabled),
|
||||
choiceRow(
|
||||
id: "hud", tab: .interface, icon: "chart.bar", label: "Statistics overlay",
|
||||
detail: "How much to show while streaming — Compact is a one-line pill, "
|
||||
+ "Detailed adds the latency stage breakdown.",
|
||||
options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw
|
||||
) { statsVerbosityRaw = $0 },
|
||||
choiceRow(
|
||||
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
||||
id: "hudPlacement", tab: .interface, icon: "rectangle.inset.topright.filled",
|
||||
label: "Overlay position",
|
||||
detail: "Which corner the statistics overlay sits in.",
|
||||
options: SettingsOptions.hudPlacements, current: hudPlacement
|
||||
) { hudPlacement = $0 },
|
||||
toggleRow(
|
||||
id: "library", icon: "square.grid.2x2", label: "Game library",
|
||||
id: "library", tab: .interface, icon: "square.grid.2x2", label: "Game library",
|
||||
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
|
||||
value: $libraryEnabled),
|
||||
toggleRow(
|
||||
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
||||
id: "gamepadUI", tab: .interface, icon: "hand.tap",
|
||||
label: "Controller-optimized UI",
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
#if os(macOS)
|
||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||||
// the Video group) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
// the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
|
||||
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "windowedSafePresent", icon: "macwindow.badge.plus",
|
||||
id: "windowedSafePresent", tab: .video, icon: "macwindow.badge.plus",
|
||||
label: "Safe windowed presentation",
|
||||
detail: "Windowed streams present in step with the compositor — avoids a "
|
||||
+ "macOS display-driver crash on high-refresh displays, at a small "
|
||||
@@ -478,14 +631,14 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||
// practice: hidden where the device itself can't play haptics (iPad).
|
||||
// The device-rumble mirror slots in after "Controller type", inside the Controller tab.
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
|
||||
let at = list.firstIndex(where: { $0.id == "padType" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
|
||||
id: "deviceRumble", tab: .controller,
|
||||
icon: "iphone.radiowaves.left.and.right",
|
||||
label: "Rumble on this iPhone",
|
||||
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
|
||||
+ "for clip-on pads without rumble motors.",
|
||||
@@ -505,17 +658,17 @@ struct GamepadSettingsView: View {
|
||||
private var profileRows: [Row] {
|
||||
guard !profiles.profiles.isEmpty else {
|
||||
return [Row(
|
||||
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
|
||||
id: "noProfiles", tab: .profiles, icon: "slider.horizontal.3",
|
||||
label: "No profiles yet", value: "",
|
||||
detail: emptyCatalogDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return profiles.profiles.enumerated().map { i, profile in
|
||||
return profiles.profiles.map { profile in
|
||||
let pins = store.hosts
|
||||
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
|
||||
return Row(
|
||||
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
|
||||
id: "profile-\(profile.id)", tab: .profiles,
|
||||
icon: "slider.horizontal.3", label: profile.name,
|
||||
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
|
||||
detail: profileDetail,
|
||||
@@ -537,7 +690,8 @@ struct GamepadSettingsView: View {
|
||||
private func pinRows(for profile: StreamProfile) -> [Row] {
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [Row(
|
||||
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
|
||||
id: "noHosts", tab: .profiles, icon: "desktopcomputer",
|
||||
label: "No saved hosts yet",
|
||||
value: "",
|
||||
detail: "Pair with a host first, then pin this profile to it.",
|
||||
adjustable: false,
|
||||
@@ -547,7 +701,7 @@ struct GamepadSettingsView: View {
|
||||
let hostID = host.id
|
||||
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
|
||||
return Row(
|
||||
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
|
||||
id: "pinHost-\(hostID.uuidString)", tab: .profiles, icon: "desktopcomputer",
|
||||
label: host.displayName,
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
@@ -609,13 +763,13 @@ struct GamepadSettingsView: View {
|
||||
// MARK: - Row builders
|
||||
|
||||
private func choiceRow<T: Equatable>(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
id: String, tab: GpSettingsTab, icon: String, label: String, detail: String,
|
||||
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
|
||||
write: @escaping (T) -> Void
|
||||
) -> Row {
|
||||
let index = options.firstIndex { $0.tag == current }
|
||||
return Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: index.map { options[$0].label } ?? "—",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
@@ -638,11 +792,11 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
|
||||
private func toggleRow(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
id: String, tab: GpSettingsTab, icon: String, label: String, detail: String,
|
||||
value: Binding<Bool>, enabled: Bool = true
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: value.wrappedValue ? "On" : "Off",
|
||||
detail: detail,
|
||||
enabled: enabled,
|
||||
|
||||
@@ -171,14 +171,26 @@ enum SettingsOptions {
|
||||
|
||||
/// This device's native mode first, then the presets, deduped by dimensions (native wins a
|
||||
/// tie).
|
||||
///
|
||||
/// On iOS the native row is followed by its **safe-area** variant, which is the same mode
|
||||
/// narrowed so the picture clears the sensor housing and the rounded corners — see
|
||||
/// [`SafeDisplay`] for why a narrower mode is the whole fix. It is emitted unconditionally and
|
||||
/// left to the dedup below: on a device with no housing the two modes are identical, the
|
||||
/// duplicate is dropped, and no pointless row appears.
|
||||
@MainActor
|
||||
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
|
||||
var native: [(name: String, w: Int, h: Int)] = []
|
||||
#if os(iOS) || os(tvOS)
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
|
||||
native = [("This device",
|
||||
Int(max(bounds.width, bounds.height)),
|
||||
Int(min(bounds.width, bounds.height)))]
|
||||
let nativeW = Int(max(bounds.width, bounds.height))
|
||||
let nativeH = Int(min(bounds.width, bounds.height))
|
||||
native = [("This device", nativeW, nativeH)]
|
||||
#if os(iOS)
|
||||
let safe = SafeDisplay.mode(
|
||||
nativeWidth: nativeW, nativeHeight: nativeH,
|
||||
sideInsetPoints: mainWindowSideInset(), scale: UIScreen.main.nativeScale)
|
||||
native.append(("This device (safe area)", safe.width, safe.height))
|
||||
#endif
|
||||
#else
|
||||
if let screen = NSScreen.main {
|
||||
let scale = screen.backingScaleFactor
|
||||
@@ -191,6 +203,26 @@ enum SettingsOptions {
|
||||
return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted }
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The key window's per-side safe-area inset in points, resolved for the LANDSCAPE stream even
|
||||
/// when this settings screen is currently portrait (see `SafeDisplay.sideInsetPoints`).
|
||||
///
|
||||
/// Zero when no window is up yet — the safe mode then equals the native one and `resolutionModes`
|
||||
/// dedups the row away, which is the right answer for a device we can't measure.
|
||||
@MainActor
|
||||
private static func mainWindowSideInset() -> Double {
|
||||
let insets = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first { $0.isKeyWindow }?
|
||||
.safeAreaInsets
|
||||
guard let insets else { return 0 }
|
||||
return SafeDisplay.sideInsetPoints(
|
||||
left: Double(insets.left), right: Double(insets.right), top: Double(insets.top),
|
||||
isPhone: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Refresh rates the device can actually display (no point asking the host to render frames
|
||||
/// the screen can't show), plus any stored custom value so it stays selectable.
|
||||
@MainActor
|
||||
|
||||
@@ -9,6 +9,25 @@
|
||||
//
|
||||
// iOS/tvOS gate Bonjour browsing on Info.plist `NSBonjourServices` listing `_punktfunk._udp`
|
||||
// (Config/Info.plist) — without it the system blocks the browse and nothing is returned.
|
||||
//
|
||||
// SELF-HEALING is what the bookkeeping below is for. Neither Network.framework primitive
|
||||
// recovers on its own, and all three failure modes read as "the host isn't there":
|
||||
//
|
||||
// - `browseResultsChangedHandler` fires only when the result SET changes. A service that is
|
||||
// found but whose resolve fails is never re-offered — from the browser's point of view
|
||||
// nothing changed — so one unlucky resolve hid that host for the life of the process.
|
||||
// - `NWConnection` has no timeout. A resolve that cannot complete (v6-only advert against our
|
||||
// IPv4 pin, Wi-Fi still associating, host mid-reboot) parks in `.preparing`/`.waiting`
|
||||
// forever instead of failing, so the retry path above was never even reached.
|
||||
// - `NWBrowser` parks in `.waiting` when the browse is blocked. On iOS that is where the LOCAL
|
||||
// NETWORK PRIVACY gate lands the first launch after install: the browse starts, the system
|
||||
// puts up its "find and connect to devices on your local network" prompt, and the browser
|
||||
// waits. Granting permission does NOT revive that browser — only a new one sees the grant.
|
||||
//
|
||||
// Every one of those presented as "restarting the app fixes it", which is what field reports
|
||||
// described. A 1 Hz `sweep` therefore times out stuck resolves, retries failed ones on a backoff
|
||||
// and re-arms a browser that stopped working; `refresh()` forces the same recovery immediately,
|
||||
// behind the UI's pull-to-refresh and Refresh button.
|
||||
|
||||
#if canImport(Network)
|
||||
import Foundation
|
||||
@@ -48,12 +67,50 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
||||
public final class HostDiscovery: ObservableObject {
|
||||
/// Currently-visible hosts, deduped by `id`, sorted by name. Main-actor.
|
||||
@Published public private(set) var hosts: [DiscoveredHost] = []
|
||||
/// True for a moment after a rescan is kicked off, so a Refresh control can show that it did
|
||||
/// something on the surfaces with no pull-to-refresh spinner of their own (macOS, tvOS).
|
||||
@Published public private(set) var isScanning = false
|
||||
|
||||
private var browser: NWBrowser?
|
||||
/// Keyed by the service endpoint's description (a stable, Sendable handle we can capture
|
||||
/// into the resolve callbacks without smuggling non-Sendable Network types across hops).
|
||||
private var resolved: [String: DiscoveredHost] = [:]
|
||||
/// Every service the browser currently reports, keyed by the endpoint's description (a stable,
|
||||
/// Sendable handle we can capture into the resolve callbacks without smuggling non-Sendable
|
||||
/// Network types across hops). Held — not just diffed — so a retry can re-resolve a service
|
||||
/// the browser will never report again (see the file header).
|
||||
private var services: [String: NWBrowser.Result] = [:]
|
||||
/// The transport address a completed resolve produced, per service key. The rest of a
|
||||
/// `DiscoveredHost` comes from the advert's TXT, which is re-read on every browse report.
|
||||
private var addresses: [String: (host: String, port: UInt16)] = [:]
|
||||
private var connections: [String: NWConnection] = [:]
|
||||
/// Deadline for each in-flight resolve — `NWConnection` has none of its own.
|
||||
private var deadlines: [String: Date] = [:]
|
||||
/// Consecutive failed resolves per service, and when the next attempt is allowed.
|
||||
private var failures: [String: Int] = [:]
|
||||
private var retryAt: [String: Date] = [:]
|
||||
/// Services whose address should be re-resolved even though we already have one — set by
|
||||
/// `refresh()`. The old address keeps showing until the new one lands, so a rescan never
|
||||
/// blinks the list empty; without this a manual Refresh silently skipped every host it had
|
||||
/// already resolved, which is exactly the host whose address may have moved.
|
||||
private var staleAddresses: Set<String> = []
|
||||
/// Consecutive non-ready browser states, and when to tear it down and re-arm. nil = healthy.
|
||||
private var browserFailures = 0
|
||||
private var browserRearmAt: Date?
|
||||
/// Bumped on every re-arm so callbacks from a superseded browser — and from the resolves it
|
||||
/// started — are ignored instead of clobbering the current generation's bookkeeping.
|
||||
private var generation = 0
|
||||
/// The 1 Hz maintenance tick. Nothing else re-drives a stuck resolve or a sick browser.
|
||||
private var sweep: Task<Void, Never>?
|
||||
private var scanningUntil: Date?
|
||||
|
||||
/// A LAN resolve answers in milliseconds; this only has to outlast a slow Wi-Fi wake.
|
||||
private static let resolveTimeout: TimeInterval = 6
|
||||
/// How long `isScanning` holds — and `rescan()` waits — after a manual refresh.
|
||||
private static let scanSettle: TimeInterval = 1.5
|
||||
/// 1s, 2s, 4s, 8s … capped at 30s, for the resolve retry and the browser re-arm alike. Long
|
||||
/// enough that a genuinely-down network doesn't spin the main queue, short enough that a host
|
||||
/// coming back is picked up while the user is still looking at the screen.
|
||||
private static func backoff(_ failures: Int) -> TimeInterval {
|
||||
min(pow(2, Double(max(0, failures - 1))), 30)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -63,34 +120,73 @@ public final class HostDiscovery: ObservableObject {
|
||||
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
|
||||
#endif
|
||||
guard browser == nil else { return }
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
using: NWParameters())
|
||||
browser.browseResultsChangedHandler = { results, _ in
|
||||
MainActor.assumeIsolated { [weak self] in self?.reconcile(results) }
|
||||
}
|
||||
browser.stateUpdateHandler = { state in
|
||||
// A failed browser never recovers on its own; tear down and re-arm so transient
|
||||
// network changes (Wi-Fi flip, VPN) don't leave discovery silently dead.
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
if case .failed = state { self?.restart() }
|
||||
}
|
||||
}
|
||||
self.browser = browser
|
||||
browser.start(queue: .main)
|
||||
armBrowser()
|
||||
startSweep()
|
||||
}
|
||||
|
||||
/// Stop browsing and drop all discovered state.
|
||||
public func stop() {
|
||||
sweep?.cancel()
|
||||
sweep = nil
|
||||
generation &+= 1
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
for conn in connections.values { conn.cancel() }
|
||||
connections.removeAll()
|
||||
resolved.removeAll()
|
||||
deadlines.removeAll()
|
||||
services.removeAll()
|
||||
addresses.removeAll()
|
||||
failures.removeAll()
|
||||
retryAt.removeAll()
|
||||
staleAddresses.removeAll()
|
||||
browserFailures = 0
|
||||
browserRearmAt = nil
|
||||
scanningUntil = nil
|
||||
if isScanning { isScanning = false }
|
||||
if !hosts.isEmpty { hosts = [] }
|
||||
}
|
||||
|
||||
/// Force a rescan now: re-arm the browser and retry every service whose resolve had failed,
|
||||
/// clearing the backoffs so nothing is left waiting. This is the manual escape hatch for the
|
||||
/// failure modes in the file header — and the only thing that clears the iOS local-network
|
||||
/// permission gate without an app restart, since only a NEW browser sees a permission the
|
||||
/// user granted after the old one started.
|
||||
///
|
||||
/// Also starts discovery if it wasn't running, so a Refresh button does the obvious thing.
|
||||
public func refresh() {
|
||||
#if DEBUG
|
||||
guard !debugPinned else { return } // as in `start()` — the harness's set is the truth
|
||||
#endif
|
||||
isScanning = true
|
||||
scanningUntil = Date().addingTimeInterval(Self.scanSettle)
|
||||
failures.removeAll()
|
||||
retryAt.removeAll()
|
||||
staleAddresses = Set(services.keys)
|
||||
browserFailures = 0
|
||||
armBrowser()
|
||||
startSweep()
|
||||
pump()
|
||||
}
|
||||
|
||||
/// `refresh()` for a `.refreshable` gesture: holds briefly so the control's spinner reflects a
|
||||
/// browse that had time to answer instead of blinking out instantly.
|
||||
public func rescan() async {
|
||||
refresh()
|
||||
try? await Task.sleep(nanoseconds: UInt64(Self.scanSettle * 1_000_000_000))
|
||||
}
|
||||
|
||||
/// `refresh()`, but only when discovery is already running — the app-foreground hook. iOS
|
||||
/// suspends a backgrounded process's browse and `onAppear`/`onDisappear` don't fire across
|
||||
/// background/foreground, so a browse that died while suspended stayed dead on return; this
|
||||
/// re-arms it without starting a browse on a screen that deliberately isn't browsing
|
||||
/// (mid-session, where the home tore discovery down).
|
||||
public func refreshIfRunning() {
|
||||
guard browser != nil else { return }
|
||||
refresh()
|
||||
}
|
||||
|
||||
deinit {
|
||||
sweep?.cancel()
|
||||
browser?.cancel()
|
||||
for conn in connections.values { conn.cancel() }
|
||||
}
|
||||
@@ -124,48 +220,103 @@ public final class HostDiscovery: ObservableObject {
|
||||
}
|
||||
#endif
|
||||
|
||||
private func restart() {
|
||||
stop()
|
||||
start()
|
||||
// MARK: - Browser
|
||||
|
||||
/// Build and start a fresh browser, retiring the previous one and every resolve it started.
|
||||
/// Those resolves' callbacks are gated on `generation`, so they must not be left holding map
|
||||
/// entries — `pump()` restarts them against the new generation.
|
||||
private func armBrowser() {
|
||||
generation &+= 1
|
||||
browser?.cancel()
|
||||
for conn in connections.values { conn.cancel() }
|
||||
connections.removeAll()
|
||||
deadlines.removeAll()
|
||||
browserRearmAt = nil
|
||||
|
||||
let generation = self.generation
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
using: NWParameters())
|
||||
browser.browseResultsChangedHandler = { results, _ in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self, generation == self.generation else { return }
|
||||
self.reconcile(results)
|
||||
}
|
||||
}
|
||||
browser.stateUpdateHandler = { state in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self, generation == self.generation else { return }
|
||||
self.browserStateChanged(state)
|
||||
}
|
||||
}
|
||||
self.browser = browser
|
||||
browser.start(queue: .main)
|
||||
}
|
||||
|
||||
/// Diff the browser's current result set against what we're tracking: drop departed
|
||||
/// services, resolve newly-seen ones.
|
||||
private func reconcile(_ results: Set<NWBrowser.Result>) {
|
||||
let live = Set(results.map { Self.key($0) })
|
||||
for key in resolved.keys where !live.contains(key) { resolved[key] = nil }
|
||||
for key in connections.keys where !live.contains(key) {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
/// A browser that stops working never recovers on its own, and it has two ways to stop:
|
||||
/// `.failed` (dead) and `.waiting` (blocked — a network change, or the iOS local-network
|
||||
/// permission gate described in the file header). Schedule a re-arm for both, on a backoff:
|
||||
/// re-arming synchronously on `.failed` alone both missed the permission case entirely and
|
||||
/// could spin the main queue on a browser that fails instantly every time.
|
||||
private func browserStateChanged(_ state: NWBrowser.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
browserFailures = 0
|
||||
browserRearmAt = nil
|
||||
case .failed, .waiting:
|
||||
guard browserRearmAt == nil else { return } // one re-arm already scheduled
|
||||
browserFailures += 1
|
||||
browserRearmAt = Date().addingTimeInterval(Self.backoff(browserFailures))
|
||||
default:
|
||||
break // .setup / .cancelled — nothing to heal
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff the browser's current result set against what we're tracking: drop departed services,
|
||||
/// record the rest — re-reading the advert every time, so a host that re-keys, moves or flips
|
||||
/// its pairing policy republishes under the same name and the card follows it — then resolve
|
||||
/// whatever still needs an address.
|
||||
private func reconcile(_ results: Set<NWBrowser.Result>) {
|
||||
var live: Set<String> = []
|
||||
for result in results {
|
||||
let key = Self.key(result)
|
||||
if resolved[key] == nil, connections[key] == nil { resolve(result) }
|
||||
live.insert(key)
|
||||
services[key] = result
|
||||
}
|
||||
for key in Array(services.keys) where !live.contains(key) { forget(key) }
|
||||
publish()
|
||||
pump()
|
||||
}
|
||||
|
||||
private func forget(_ key: String) {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
deadlines[key] = nil
|
||||
services[key] = nil
|
||||
addresses[key] = nil
|
||||
failures[key] = nil
|
||||
retryAt[key] = nil
|
||||
staleAddresses.remove(key)
|
||||
}
|
||||
|
||||
// MARK: - Resolve
|
||||
|
||||
/// Start the resolves that are due: every live service with no address yet, nothing in flight,
|
||||
/// and past its retry time.
|
||||
private func pump() {
|
||||
let now = Date()
|
||||
for (key, result) in services {
|
||||
guard addresses[key] == nil || staleAddresses.contains(key) else { continue }
|
||||
guard connections[key] == nil else { continue }
|
||||
if let at = retryAt[key], at > now { continue }
|
||||
resolve(key, result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one service to IP:port via a short UDP connection (it reaches `.ready` once the
|
||||
/// path is established — no data is sent), reading the TXT up front so the callback only
|
||||
/// captures Sendable values + the endpoint key.
|
||||
private func resolve(_ result: NWBrowser.Result) {
|
||||
let key = Self.key(result)
|
||||
let name = Self.instanceName(result.endpoint)
|
||||
var fp: String?
|
||||
var pair: String?
|
||||
var id: String?
|
||||
var macs: [String] = []
|
||||
var osChain = ""
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = Self.entry(txt, "fp")
|
||||
pair = Self.entry(txt, "pair")
|
||||
id = Self.entry(txt, "id")
|
||||
macs = (Self.entry(txt, "mac") ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
osChain = sanitizeOsChain(Self.entry(txt, "os") ?? "")
|
||||
}
|
||||
/// path is established — no data is sent). The TXT is NOT read here: it comes from the browse
|
||||
/// result at publish time, so a re-advertised host doesn't need a fresh resolve to be re-read.
|
||||
private func resolve(_ key: String, _ result: NWBrowser.Result) {
|
||||
// Resolve over IPv4 only: Network.framework prefers IPv6 (RFC 6724), and the host's OS
|
||||
// mDNS responder often answers AAAA for its hostname even though the punktfunk host stack
|
||||
// (control QUIC + data UDP) binds IPv4 sockets exclusively — a v6-resolved address would
|
||||
@@ -177,44 +328,125 @@ public final class HostDiscovery: ObservableObject {
|
||||
}
|
||||
let conn = NWConnection(to: result.endpoint, using: params)
|
||||
connections[key] = conn
|
||||
deadlines[key] = Date().addingTimeInterval(Self.resolveTimeout)
|
||||
let generation = self.generation
|
||||
conn.stateUpdateHandler = { state in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self, let conn = self.connections[key] else { return }
|
||||
// Look the connection back up rather than capturing it — capturing it here would
|
||||
// retain the connection through its own handler.
|
||||
guard let self, generation == self.generation,
|
||||
let conn = self.connections[key] else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
if case let .hostPort(host, port)? = conn.currentPath?.remoteEndpoint,
|
||||
let address = Self.hostString(host) {
|
||||
self.resolved[key] = DiscoveredHost(
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port.rawValue,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional", macAddresses: macs,
|
||||
osChain: osChain)
|
||||
self.publish()
|
||||
}
|
||||
conn.cancel()
|
||||
let endpoint = conn.currentPath?.remoteEndpoint
|
||||
self.connections[key] = nil
|
||||
self.deadlines[key] = nil
|
||||
conn.cancel()
|
||||
if case let .hostPort(host, port)? = endpoint,
|
||||
let address = Self.hostString(host) {
|
||||
self.addresses[key] = (address, port.rawValue)
|
||||
self.failures[key] = nil
|
||||
self.retryAt[key] = nil
|
||||
self.staleAddresses.remove(key)
|
||||
self.publish()
|
||||
} else {
|
||||
// Ready but no usable remote — a failed attempt, not a finished one.
|
||||
self.resolveFailed(key)
|
||||
}
|
||||
case .failed, .cancelled:
|
||||
self.connections[key] = nil
|
||||
self.deadlines[key] = nil
|
||||
self.resolveFailed(key)
|
||||
default:
|
||||
break
|
||||
break // .preparing / .waiting — the sweep's deadline is what ends these
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.start(queue: .main)
|
||||
}
|
||||
|
||||
/// Publish the resolved set, deduped by `id` (a host on several interfaces / re-advertising
|
||||
/// collapses to one row), sorted by name.
|
||||
private func resolveFailed(_ key: String) {
|
||||
let count = (failures[key] ?? 0) + 1
|
||||
failures[key] = count
|
||||
retryAt[key] = Date().addingTimeInterval(Self.backoff(count))
|
||||
}
|
||||
|
||||
// MARK: - Sweep
|
||||
|
||||
private func startSweep() {
|
||||
sweep?.cancel()
|
||||
sweep = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let now = Date()
|
||||
// Time out the resolves that parked. Without this they never end, and `pump()` skips a
|
||||
// service that has a connection in flight — so that host stayed invisible indefinitely.
|
||||
for key in deadlines.filter({ $0.value <= now }).keys {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
deadlines[key] = nil
|
||||
resolveFailed(key)
|
||||
}
|
||||
if let at = browserRearmAt, at <= now { armBrowser() }
|
||||
pump()
|
||||
if let until = scanningUntil, until <= now {
|
||||
scanningUntil = nil
|
||||
isScanning = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Publish
|
||||
|
||||
/// Publish the live adverts that have an address, deduped by `id` (a host on several
|
||||
/// interfaces / re-advertising collapses to one row), sorted by name.
|
||||
private func publish() {
|
||||
var byID: [String: DiscoveredHost] = [:]
|
||||
for host in resolved.values { byID[host.id] = host }
|
||||
for key in services.keys.sorted() {
|
||||
guard let result = services[key], let address = addresses[key] else { continue }
|
||||
let host = Self.host(from: result, address: address.host, port: address.port)
|
||||
byID[host.id] = host
|
||||
}
|
||||
let next = byID.values.sorted {
|
||||
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
if next != hosts { hosts = next }
|
||||
}
|
||||
|
||||
/// Join a browse result's advert (instance name + TXT) to a resolved address.
|
||||
private static func host(
|
||||
from result: NWBrowser.Result, address: String, port: UInt16
|
||||
) -> DiscoveredHost {
|
||||
let name = instanceName(result.endpoint)
|
||||
var fp: String?
|
||||
var pair: String?
|
||||
var id: String?
|
||||
var macs: [String] = []
|
||||
var osChain = ""
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
fp = entry(txt, "fp")
|
||||
pair = entry(txt, "pair")
|
||||
id = entry(txt, "id")
|
||||
macs = (entry(txt, "mac") ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
osChain = sanitizeOsChain(entry(txt, "os") ?? "")
|
||||
}
|
||||
return DiscoveredHost(
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional", macAddresses: macs,
|
||||
osChain: osChain)
|
||||
}
|
||||
|
||||
private static func key(_ result: NWBrowser.Result) -> String {
|
||||
"\(result.endpoint)"
|
||||
}
|
||||
|
||||
@@ -38,12 +38,46 @@ public struct LaunchSpec: Codable, Hashable, Sendable {
|
||||
/// One title in the unified library. `id` is store-qualified: `steam:<appid>` / `custom:<id>`.
|
||||
public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: String
|
||||
public var store: String // "steam" | "custom"
|
||||
public var store: String // "steam" | "custom" | "lutris" | "heroic" | "epic" | "gog" | "xbox"
|
||||
public var title: String
|
||||
public var art: Artwork
|
||||
public var launch: LaunchSpec?
|
||||
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
|
||||
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. Deliberately a plain
|
||||
/// optional String: the host owns the vocabulary, and an unknown future value must never fail
|
||||
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
|
||||
public var role: String?
|
||||
|
||||
public var isCustom: Bool { store == "custom" }
|
||||
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
public var isLauncher: Bool { role == "launcher" }
|
||||
|
||||
/// Display name for the store badge — the same table the Rust clients use
|
||||
/// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for
|
||||
/// every non-custom entry, which a Lutris or GOG title made a lie.
|
||||
public var storeLabel: String {
|
||||
switch store {
|
||||
case "steam": return "Steam"
|
||||
case "custom": return "Custom"
|
||||
case "heroic": return "Heroic"
|
||||
case "lutris": return "Lutris"
|
||||
case "epic": return "Epic"
|
||||
case "gog": return "GOG"
|
||||
case "xbox": return "Xbox"
|
||||
default: return "Game"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Array where Element == GameEntry {
|
||||
/// Design D4: launcher entries lead the shelf, and the host's title order survives within each
|
||||
/// group. Applied once where the library is fetched, so no individual view has to remember
|
||||
/// the rule — and a library without launcher entries comes back untouched.
|
||||
var launchersFirst: [GameEntry] {
|
||||
let launchers = filter(\.isLauncher)
|
||||
return launchers.isEmpty ? self : launchers + filter { !$0.isLauncher }
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
@@ -1430,6 +1430,49 @@ public final class PunktfunkConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a stream session ended — the Swift mirror of `PunktfunkEndReason` (ABI v17).
|
||||
///
|
||||
/// The distinction that matters to a UI is normal vs alarming, and it is not a spectrum: a
|
||||
/// player quitting their game and a host falling off the network both arrive as "the session
|
||||
/// ended". Without this every client wrote one message for all of them, and every client chose
|
||||
/// an error.
|
||||
public enum SessionEndReason: UInt8, Sendable {
|
||||
/// Not ended, or ended before a reason could be observed. Also the fallback for an
|
||||
/// unrecognized value — the core may be newer than this code.
|
||||
case none = 0
|
||||
/// This client closed the session. Nothing to report: the UI initiated it.
|
||||
case local = 1
|
||||
/// The host's launched game exited. A normal finish, and the one reason worth acting on:
|
||||
/// go back to the library the title was launched from.
|
||||
case gameExited = 2
|
||||
/// The host ended the session deliberately (an operator "End", or it simply finished).
|
||||
case hostEnded = 3
|
||||
/// The host closed reporting a failure of its own.
|
||||
case hostError = 4
|
||||
/// The connection died rather than being closed: idle timeout, reset, network gone. This —
|
||||
/// and only this — is the "the host may be asleep" case.
|
||||
case lost = 5
|
||||
|
||||
/// Is this an ordinary outcome rather than something to alarm the user about? `.none`
|
||||
/// counts as normal: no evidence of trouble is not evidence of it.
|
||||
public var isNormal: Bool { self != .hostError && self != .lost }
|
||||
}
|
||||
|
||||
/// Why this session ended. Only meaningful once it HAS ended (a plane threw `.closed`, or
|
||||
/// `onSessionEnd` fired) — before that it is `.none`.
|
||||
///
|
||||
/// Read it before tearing the connection down: once `close()` has been requested this reports
|
||||
/// `.none`, which is the safe direction (the caller falls back to its normal handling).
|
||||
public var sessionEndReason: SessionEndReason {
|
||||
guard let h = liveHandle() else { return .none }
|
||||
var out: UInt8 = 0
|
||||
guard punktfunk_connection_end_reason(h, &out) == statusOK else { return .none }
|
||||
return SessionEndReason(rawValue: out) ?? .none
|
||||
}
|
||||
|
||||
/// Shorthand for the single most actionable reason: the host's launched game exited.
|
||||
public var endedBecauseGameExited: Bool { sessionEndReason == .gameExited }
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Snapshot the handle unless close is pending (callers hold their plane lock).
|
||||
|
||||
@@ -538,14 +538,25 @@ public final class StreamLayerView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the host who renders the pointer (the §8 mid-stream render flip): we draw it only
|
||||
/// while the DESKTOP model is engaged (the local OS cursor wears the host shape); under
|
||||
/// the capture model — and while released — the host composites it into the video (full
|
||||
/// fidelity, the pre-channel look). One edge-detected reconciler, called from every
|
||||
/// Tell the host who renders the pointer (the §8 mid-stream render flip). The host may
|
||||
/// composite one into the video ONLY while we are holding a grabbed, hidden pointer — the
|
||||
/// capture model, engaged. That is the one state with no local cursor on screen.
|
||||
///
|
||||
/// Every other state leaves a normal OS cursor visible over the video: the desktop model
|
||||
/// draws it wearing the host's shape, and a RELEASED view shows the plain arrow. A
|
||||
/// host-composited pointer then appears *underneath* it as a second cursor — and, because a
|
||||
/// released view forwards no motion, one that never moves. On glass that reads as a frozen
|
||||
/// duplicate stuck wherever the host pointer was last left (verified: `client_draws=false
|
||||
/// blended=true live=(-1, 622)` — parked on the streamed output's left edge while the user
|
||||
/// moved their own cursor around freely).
|
||||
///
|
||||
/// So "released" counts as WE draw it: the host stops compositing, the client keeps
|
||||
/// receiving shape/state over the channel (the forwarder only ticks on this side of the
|
||||
/// flip), and re-engaging is seamless. One edge-detected reconciler, called from every
|
||||
/// transition (chord, engage/release, session start).
|
||||
private func reconcileCursorRender() {
|
||||
guard cursorChannelActive, let connection else { return }
|
||||
let clientDraws = captured && desktopMouse
|
||||
let clientDraws = !captured || desktopMouse
|
||||
guard sentClientDraws != clientDraws else { return }
|
||||
sentClientDraws = clientDraws
|
||||
connection.setCursorRender(clientDraws: clientDraws)
|
||||
|
||||
@@ -225,6 +225,15 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
|
||||
/// so the system observes a real transition instead of coalescing the flip away.
|
||||
private static let pointerLockForcedOffHold: TimeInterval = 0.05
|
||||
/// Attempts spent in the QUIET tail (see `scheduleQuietRelock()`), reset with the burst.
|
||||
private var pointerRelockQuietAttempt = 0
|
||||
/// When the quiet tail re-asks, measured from the drop. The visible burst above spends its whole
|
||||
/// budget inside ~0.6 s — and the pointer-lock cooldown the platform applies right after its own
|
||||
/// Escape gesture is about a second, so every one of those attempts asks while the answer can
|
||||
/// only be no. These land AFTER it. They are "quiet" because unlike the burst they do not hide
|
||||
/// the cursor or mute motion: the pointer behaves exactly as it does today while they run, so
|
||||
/// stretching the recovery costs the user nothing if it also fails.
|
||||
private static let pointerRelockQuietDelays: [TimeInterval] = [1.2, 2.4]
|
||||
#endif
|
||||
|
||||
/// Reads whether the scene's pointer is actually locked right now; nil = state
|
||||
@@ -340,12 +349,80 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// SwiftUI places us in the hierarchy AFTER start()'s setCaptured(true), and may reparent us
|
||||
// later — re-anchor the chain here so a lock requested before we had a parent still lands.
|
||||
updatePointerLockChain()
|
||||
anchorKeyResponder()
|
||||
}
|
||||
|
||||
public override func didMove(toParent parent: UIViewController?) {
|
||||
super.didMove(toParent: parent)
|
||||
updatePointerLockChain() // chain shape changed — re-anchor (or no-op if not yet in a window)
|
||||
}
|
||||
|
||||
/// Put THIS controller on the responder chain for hardware key presses.
|
||||
///
|
||||
/// Nothing of ours is otherwise a first responder during a normal stream: keys arrive on the
|
||||
/// GameController (`GCKeyboard`) path, which is a parallel HID feed that does not consume the
|
||||
/// UIKit event, and `StreamLayerUIView` only becomes first responder to summon the SOFT
|
||||
/// keyboard (it is `UIKeyInput`, so making it one for any other reason would raise the on-screen
|
||||
/// keyboard mid-game). With no responder of ours in the chain, every hardware key press reaches
|
||||
/// UIKit unclaimed — and an unclaimed press is what lets the system apply its own default for
|
||||
/// that key. `pressesBegan` below is where we claim Escape; this is what gets it delivered.
|
||||
///
|
||||
/// A controller is not `UIKeyInput`, so being first responder raises no keyboard. Deferred to
|
||||
/// the soft keyboard whenever the view has taken over, so the three-finger-swipe keyboard is
|
||||
/// unaffected.
|
||||
///
|
||||
/// Only while captured — the whole claim is scoped to "the stream owns the keyboard", and
|
||||
/// holding the chain outside that would sit in front of SwiftUI's focus for no reason. Safe to
|
||||
/// call from anywhere: `start()` engages capture BEFORE SwiftUI puts us in a window (where
|
||||
/// `becomeFirstResponder` cannot succeed), so `viewDidAppear` calls it again to catch up.
|
||||
private func anchorKeyResponder() {
|
||||
guard captured, !streamView.isFirstResponder, !isFirstResponder else { return }
|
||||
becomeFirstResponder()
|
||||
}
|
||||
|
||||
public override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
/// Claim Escape while the stream owns the keyboard, so the SYSTEM never gets to act on it.
|
||||
///
|
||||
/// This is the fix for "Escape hands the mouse back to iPadOS": the platform releases the
|
||||
/// scene's pointer lock on an Escape that nothing claimed — the same "let me out" the web
|
||||
/// Pointer Lock API mandates. Every recovery attempt before this one fought that release AFTER
|
||||
/// the fact (a re-lock burst, then a click), and the platform's post-Escape cooldown means the
|
||||
/// burst is refused by construction. Claiming the press means there is nothing to recover from.
|
||||
///
|
||||
/// Escape is forwarded to the host on the GCKeyboard path, which is untouched by this — that
|
||||
/// path never sees the UIKit responder chain, so the host still receives the keystroke and
|
||||
/// in-game menus still open. Only the system's own interpretation is suppressed.
|
||||
///
|
||||
/// Strictly scoped: only while `captured` (the stream owns input), and only Escape. Anything
|
||||
/// else — including every key while the pointer is released — goes to `super` untouched, so
|
||||
/// Escape still dismisses sheets, exits full screen and does everything else it should whenever
|
||||
/// we are not holding the keyboard. The deliberate ways out are unaffected: ⌘⎋ and ⌃⌥⇧Q are
|
||||
/// recognized on the GCKeyboard path and clear `captured` themselves.
|
||||
public override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||
let unclaimed = presses.filter { !claimsPress($0) }
|
||||
if !unclaimed.isEmpty || presses.isEmpty {
|
||||
super.pressesBegan(unclaimed, with: event)
|
||||
}
|
||||
}
|
||||
|
||||
public override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||
let unclaimed = presses.filter { !claimsPress($0) }
|
||||
if !unclaimed.isEmpty || presses.isEmpty {
|
||||
super.pressesEnded(unclaimed, with: event)
|
||||
}
|
||||
}
|
||||
|
||||
public override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||
// Never swallowed: a cancelled press is the system taking the key away from us, and
|
||||
// dropping it here would strand UIKit's own bookkeeping for a press we did claim.
|
||||
super.pressesCancelled(presses, with: event)
|
||||
}
|
||||
|
||||
/// Is this press one the stream owns outright (Escape while captured)?
|
||||
private func claimsPress(_ press: UIPress) -> Bool {
|
||||
captured && press.key?.keyCode == .keyboardEscape
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@@ -478,6 +555,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
if !down, self.wantsPointerLock, self.pointerLockWasEngaged,
|
||||
!self.pointerRelockPending, self.pointerLockEngaged() != true {
|
||||
self.pointerRelockAttempt = 0
|
||||
self.pointerRelockQuietAttempt = 0 // a real gesture buys a fresh tail too
|
||||
self.updatePointerLockChain() // a reparent since the drop would break the walk to us
|
||||
self.requestPointerRelock()
|
||||
}
|
||||
@@ -749,10 +827,16 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
guard captureEnabled, !captured, connection != nil else { return }
|
||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||
captured = true
|
||||
// Claim the responder chain for as long as we own the keyboard — `pressesBegan` has to
|
||||
// be delivered to us before it can keep Escape away from the system.
|
||||
anchorKeyResponder()
|
||||
} else {
|
||||
guard captured else { return }
|
||||
inputCapture?.setForwarding(false)
|
||||
captured = false
|
||||
// Hand the chain back: released means Escape is the system's again, and staying first
|
||||
// responder for a stream that no longer owns input would sit in front of SwiftUI focus.
|
||||
if isFirstResponder { resignFirstResponder() }
|
||||
}
|
||||
setNeedsUpdateOfPrefersPointerLocked()
|
||||
updatePointerLockChain() // (re)anchor the SwiftUI ancestors so the lock actually resolves
|
||||
@@ -782,6 +866,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
pointerLockWasEngaged = true
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
pointerRelockQuietAttempt = 0 // granted — any scheduled tail finds nothing to do
|
||||
} else if wantsPointerLock, pointerLockWasEngaged {
|
||||
requestPointerRelock()
|
||||
} else {
|
||||
@@ -790,6 +875,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
if !wantsPointerLock { pointerLockWasEngaged = false }
|
||||
pointerRelockPending = false
|
||||
pointerRelockAttempt = 0
|
||||
pointerRelockQuietAttempt = 0
|
||||
}
|
||||
let useGCMouse = captured && locked
|
||||
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
|
||||
@@ -830,10 +916,12 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
pointerRelockAttempt = 0
|
||||
}
|
||||
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
|
||||
// Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back
|
||||
// and a click into the video re-captures. The caller invalidates the interaction, so
|
||||
// the cursor can never stay hidden on a lock the system won't grant.
|
||||
// Out of VISIBLE budget: give the cursor straight back (the caller invalidates the
|
||||
// interaction, so it can never stay hidden on a lock the system won't grant) and hand
|
||||
// off to the quiet tail, which keeps asking after the platform's post-Escape cooldown
|
||||
// without costing the user anything while it does.
|
||||
pointerRelockPending = false
|
||||
scheduleQuietRelock()
|
||||
return
|
||||
}
|
||||
pointerRelockAttempt += 1
|
||||
@@ -881,6 +969,43 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep asking for the lock after the visible burst has given up — past the cooldown the
|
||||
/// platform applies to its own Escape gesture, which is the window the burst spends entirely.
|
||||
///
|
||||
/// Deliberately NOT a longer burst. `pointerRelockPending` hides the cursor and mutes absolute
|
||||
/// motion, which is only tolerable for the couple of frames a fast re-grab takes; holding that
|
||||
/// for seconds would trade a released pointer for a frozen one. These attempts leave the
|
||||
/// pointer fully usable — if they all fail the user sees exactly today's behaviour, and a click
|
||||
/// is still the immediate way back.
|
||||
///
|
||||
/// Each attempt presents a real false→true transition (the same escalation the burst uses on
|
||||
/// its later tries) because re-asserting a value the system already holds is what didn't take.
|
||||
/// A grant arrives as a `didChange` → `syncPointerLock`, which resets the counters, so a
|
||||
/// successful attempt silently ends the tail.
|
||||
private func scheduleQuietRelock() {
|
||||
guard pointerRelockQuietAttempt < Self.pointerRelockQuietDelays.count else { return }
|
||||
let delay = Self.pointerRelockQuietDelays[pointerRelockQuietAttempt]
|
||||
pointerRelockQuietAttempt += 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
// Still wanted, still ours to want, and still not held — otherwise the tail is moot.
|
||||
guard self.wantsPointerLock, self.pointerLockWasEngaged,
|
||||
self.pointerLockEngaged() != true,
|
||||
self.view.window?.windowScene?.activationState == .foregroundActive
|
||||
else { return }
|
||||
self.pointerLockForcedOff = true
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
self.updatePointerLockChain()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
|
||||
[weak self] in
|
||||
guard let self else { return }
|
||||
self.pointerLockForcedOff = false
|
||||
self.setNeedsUpdateOfPrefersPointerLocked()
|
||||
self.scheduleQuietRelock() // no-op once the delays are spent, or once granted
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
|
||||
@@ -179,6 +179,14 @@ public enum DefaultsKey {
|
||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — a
|
||||
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
|
||||
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
|
||||
/// Android client carry the same table under the same names. Presentation only, so it is
|
||||
/// a device preference and never part of a stream profile. An unknown value reads as the
|
||||
/// default rather than failing — a newer client may have shipped a palette this build
|
||||
/// doesn't know.
|
||||
public static let uiPalette = "punktfunk.uiPalette"
|
||||
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||
/// the phone body is the only actuator in the player's hands. Off by default (opt-in); read
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// The gamepad UI's background colour families.
|
||||
//
|
||||
// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale
|
||||
// applied to the ONE field GamepadScreenBackground already draws, so every palette inherits its
|
||||
// structure (dark corners, bright interior pools, warm-left/cool-right) and the brand default is
|
||||
// exactly the shipped look — `violet` is the identity transform.
|
||||
//
|
||||
// The table and the `tint` math are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the
|
||||
// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so the shared `ui_palette`
|
||||
// setting names the same colour family on every client. Keep the three copies in step: a palette
|
||||
// added here without the others is a value the other clients will silently render as Violet.
|
||||
//
|
||||
// It lives in PunktfunkShared rather than next to the views because that is the target the tests
|
||||
// can reach — the arithmetic below is the part that has to agree across three languages.
|
||||
|
||||
import Foundation
|
||||
import simd
|
||||
|
||||
public struct GamepadPalette: Identifiable, Equatable, Sendable {
|
||||
/// The stored `ui_palette` value (`DefaultsKey.uiPalette`).
|
||||
public let id: String
|
||||
/// What the settings row shows.
|
||||
public let name: String
|
||||
/// Hue rotation about the grey axis, degrees — positive runs red → green → blue.
|
||||
public let hueDegrees: Double
|
||||
/// Saturation scale about luminance; 1 keeps the source saturation.
|
||||
public let saturation: Double
|
||||
|
||||
/// The six shipped palettes, in cycling order: the brand violet, then cool → warm, then the
|
||||
/// neutral.
|
||||
public static let all: [GamepadPalette] = [
|
||||
GamepadPalette(id: "violet", name: "Violet", hueDegrees: 0, saturation: 1.0),
|
||||
GamepadPalette(id: "tide", name: "Tide", hueDegrees: -70, saturation: 1.0),
|
||||
GamepadPalette(id: "forest", name: "Forest", hueDegrees: -130, saturation: 0.9),
|
||||
GamepadPalette(id: "ember", name: "Ember", hueDegrees: 105, saturation: 1.0),
|
||||
GamepadPalette(id: "rose", name: "Rose", hueDegrees: 60, saturation: 0.95),
|
||||
GamepadPalette(id: "graphite", name: "Graphite", hueDegrees: 0, saturation: 0.12),
|
||||
]
|
||||
|
||||
/// The palette stored under `id`, falling back to the brand default — an unknown name is a
|
||||
/// palette a newer client shipped, not a reason to draw nothing.
|
||||
public static func named(_ id: String) -> GamepadPalette {
|
||||
all.first { $0.id == id } ?? all[0]
|
||||
}
|
||||
|
||||
/// `true` for the identity transform, so the default path can skip the per-colour work.
|
||||
public var isIdentity: Bool { hueDegrees == 0 && saturation == 1 }
|
||||
|
||||
/// Apply this palette to one RGB triple.
|
||||
public func tint(_ c: SIMD3<Double>) -> SIMD3<Double> {
|
||||
guard !isIdentity else { return c }
|
||||
return GamepadPalette.tint(c, hueDegrees: hueDegrees, saturation: saturation)
|
||||
}
|
||||
|
||||
/// Rotate `c` about the grey axis by `hueDegrees` (Rodrigues — the same rotation the field's
|
||||
/// own ±8° warm/cool sway uses, in the same orientation) and scale its saturation about
|
||||
/// luminance. Clamped, because a large rotation can push a channel out of gamut.
|
||||
///
|
||||
/// Deliberately computed here rather than left to SwiftUI's `.hueRotation`: that modifier's
|
||||
/// exact behaviour is the framework's, and the Rust and Kotlin clients have no equivalent —
|
||||
/// doing the arithmetic on the COLOURS keeps the three implementations identical.
|
||||
public static func tint(
|
||||
_ c: SIMD3<Double>, hueDegrees: Double, saturation: Double
|
||||
) -> SIMD3<Double> {
|
||||
let a = hueDegrees * .pi / 180
|
||||
let cs = cos(a)
|
||||
let sn = sin(a)
|
||||
let invSqrt3 = 1 / 3.0.squareRoot()
|
||||
let grey = (c.x + c.y + c.z) / 3 * (1 - cs)
|
||||
// The `sn` term is cross(k, c) with k = (1,1,1)/√3.
|
||||
let rot = SIMD3(
|
||||
c.x * cs + (c.z - c.y) * invSqrt3 * sn + grey,
|
||||
c.y * cs + (c.x - c.z) * invSqrt3 * sn + grey,
|
||||
c.z * cs + (c.y - c.x) * invSqrt3 * sn + grey)
|
||||
let luma = 0.2126 * rot.x + 0.7152 * rot.y + 0.0722 * rot.z
|
||||
func mix(_ v: Double) -> Double { min(max(luma + (v - luma) * saturation, 0), 1) }
|
||||
return SIMD3(mix(rot.x), mix(rot.y), mix(rot.z))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Safe-area stream sizing — the pure geometry behind the "safe area" resolution row.
|
||||
//
|
||||
// An iPhone clips the picture in HARDWARE: the sensor housing (notch / Dynamic Island) and the four
|
||||
// rounded corners eat whatever the stream draws underneath them. The session view is deliberately
|
||||
// edge-to-edge (ContentView's `.ignoresSafeArea()` on iOS) and the presenter aspect-FITS the host
|
||||
// mode into it, so which pixels survive is decided entirely by the mode's aspect ratio:
|
||||
//
|
||||
// * A 16:9 mode on a 19.5:9 phone pillarboxes, and those black bars land exactly on the unsafe
|
||||
// regions. That is why 1080p has always "just worked" and never needed a setting.
|
||||
// * The device's NATIVE mode has the screen's own aspect ratio, so it fills every pixel —
|
||||
// including the ones behind the housing and under the corner radii. That is the mode that
|
||||
// loses its corners, and the reason this file exists.
|
||||
//
|
||||
// So the fix needs no layout change and no input change: ask the host for a mode that is narrower
|
||||
// by the safe-area insets, and the existing aspect-fit centres it inside the safe region. Pointer
|
||||
// input keeps mapping correctly for free, because `hostPoint(from:)` derives the video rect from
|
||||
// the live host mode (`AVMakeRect(aspectRatio:insideRect:)`) instead of assuming full-bleed.
|
||||
//
|
||||
// The formula is Moonlight's (its settings' resolution table carries the same row): full native
|
||||
// height, width reduced by the left+right safe-area insets. Width-only is not a simplification —
|
||||
// under aspect-fit only one axis can bind, and on a landscape phone that axis is always the
|
||||
// horizontal one. Insetting the height too would shrink the picture without uncovering anything.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SafeDisplay {
|
||||
/// The host rejects odd dimensions and anything under 320×200 (`validate_dimensions` in
|
||||
/// `pf-encode`), so the computed mode is even-floored and clamped exactly like `RenderScale`.
|
||||
public static let minWidth = 320
|
||||
public static let minHeight = 200
|
||||
|
||||
/// A portrait top inset at or above this many points means a sensor housing rather than a
|
||||
/// status bar. Notched and Dynamic Island iPhones report 44–59 pt; a plain status bar (older
|
||||
/// iPhones, every iPad) reports 20–24 pt. Used only by [`sideInsetPoints`] and only when the
|
||||
/// horizontal insets are unavailable — see there for why that case exists at all.
|
||||
public static let housingTopInsetThreshold: Double = 40
|
||||
|
||||
/// The per-side inset, in points, that the **landscape** stream will be subject to — which is
|
||||
/// not necessarily the inset the caller can read right now.
|
||||
///
|
||||
/// The stream is always landscape, but the settings screen the resolution row is rendered in may
|
||||
/// be portrait, and `safeAreaInsets` only ever describes the CURRENT orientation. In portrait a
|
||||
/// notched iPhone reports its housing on `top` and reports `left`/`right` as zero, so reading
|
||||
/// the horizontal insets there would compute "no inset needed" for exactly the devices that
|
||||
/// need one.
|
||||
///
|
||||
/// - In landscape, `max(left, right)` is the answer directly. (iOS symmetrizes the two so
|
||||
/// content stays centred, so they normally agree; `max` is simply the safe reduction.)
|
||||
/// - In portrait, the housing's portrait TOP inset equals its landscape SIDE inset on every
|
||||
/// notched/Dynamic Island iPhone — the same physical intrusion, measured on the axis that
|
||||
/// happens to be vertical at the time — so `top` is the correct stand-in. It is accepted only
|
||||
/// on phones and only past [`housingTopInsetThreshold`], so an iPad's status bar (or an older
|
||||
/// iPhone's) never fabricates an inset for a device with nothing to avoid.
|
||||
///
|
||||
/// Returns 0 when there is no housing to route around, which makes the safe mode identical to
|
||||
/// the native one — and the caller's dedup then drops the duplicate row on its own.
|
||||
public static func sideInsetPoints(
|
||||
left: Double, right: Double, top: Double, isPhone: Bool
|
||||
) -> Double {
|
||||
let horizontal = max(left, right)
|
||||
if horizontal > 0 { return horizontal }
|
||||
if isPhone, top >= housingTopInsetThreshold { return top }
|
||||
return 0
|
||||
}
|
||||
|
||||
/// The landscape safe-area mode in PIXELS: full native height, width reduced by
|
||||
/// `sideInsetPoints` on each side.
|
||||
///
|
||||
/// `nativeWidth`/`nativeHeight` are the device's native landscape pixels (the long edge first —
|
||||
/// `UIScreen.main.nativeBounds` is portrait-oriented, so the caller swaps). `scale` converts the
|
||||
/// point-valued insets into those same pixels and must therefore be `nativeScale`, not `scale`:
|
||||
/// with Display Zoom on, the two differ and only the former matches `nativeBounds`.
|
||||
///
|
||||
/// Even-floored and clamped so the result is directly host-valid — an odd width is rejected
|
||||
/// outright by the encoder, and an inset subtraction lands odd about half the time.
|
||||
public static func mode(
|
||||
nativeWidth: Int, nativeHeight: Int, sideInsetPoints: Double, scale: Double
|
||||
) -> (width: Int, height: Int) {
|
||||
let insetPixels = max(0, sideInsetPoints) * max(scale, 1) * 2 // both sides
|
||||
let width = Double(nativeWidth) - insetPixels
|
||||
let evenFloor: (Double, Int) -> Int = { value, minimum in
|
||||
max(Int(value.rounded(.down)), minimum) / 2 * 2
|
||||
}
|
||||
return (evenFloor(width, minWidth), evenFloor(Double(nativeHeight), minHeight))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// The gamepad UI's background palettes. These assertions are the CONTRACT the Rust
|
||||
// (`pf-console-ui::library::tint`) and Kotlin (`GamepadPalette.tint`) ports have to reproduce —
|
||||
// the same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette`
|
||||
// value names the same colour family on every client.
|
||||
|
||||
import XCTest
|
||||
import simd
|
||||
@testable import PunktfunkShared
|
||||
|
||||
final class GamepadPaletteTests: XCTestCase {
|
||||
/// The brightest interior pool of the mesh field — the colour a palette is judged by.
|
||||
private let violetPool = SIMD3(0.49, 0.39, 0.95)
|
||||
|
||||
/// The brand default must be the IDENTITY transform. Every existing install already sees the
|
||||
/// shipped violet backdrop, and a palette table that quietly restyled it would be a
|
||||
/// regression dressed as a feature.
|
||||
func testVioletIsTheUntouchedShippedField() {
|
||||
let violet = GamepadPalette.named("violet")
|
||||
XCTAssertEqual(GamepadPalette.all.first?.id, "violet")
|
||||
XCTAssertTrue(violet.isIdentity)
|
||||
XCTAssertEqual(violet.tint(violetPool), violetPool)
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
XCTAssertEqual(GamepadPalette.named("chartreuse").id, "violet")
|
||||
XCTAssertEqual(GamepadPalette.named("").id, "violet")
|
||||
}
|
||||
|
||||
/// The ids and their order are the cross-client contract (the strip order, and the order
|
||||
/// L1/R1 and A cycle through).
|
||||
func testTableMatchesTheOtherClients() {
|
||||
XCTAssertEqual(
|
||||
GamepadPalette.all.map(\.id),
|
||||
["violet", "tide", "forest", "ember", "rose", "graphite"])
|
||||
XCTAssertEqual(
|
||||
GamepadPalette.all.map(\.name),
|
||||
["Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"])
|
||||
}
|
||||
|
||||
/// A rotation moves the hue while roughly holding luminance, and the saturation scale
|
||||
/// collapses toward grey — the same four checks the Rust test makes.
|
||||
func testTintRotatesHueAndScalesSaturation() {
|
||||
XCTAssertTrue(violetPool.z > violetPool.x && violetPool.z > violetPool.y, "blue-dominant")
|
||||
|
||||
// +105° (Ember) turns the blue-dominant pool red-dominant…
|
||||
let ember = GamepadPalette.named("ember").tint(violetPool)
|
||||
XCTAssertGreaterThan(ember.x, ember.z, "\(ember) should be warm")
|
||||
// …−130° (Forest) turns it green-dominant…
|
||||
let forest = GamepadPalette.named("forest").tint(violetPool)
|
||||
XCTAssertTrue(forest.y > forest.x && forest.y > forest.z, "\(forest)")
|
||||
// …and −70° (Tide) lands on a cyan whose green and blue both beat red.
|
||||
let tide = GamepadPalette.named("tide").tint(violetPool)
|
||||
XCTAssertTrue(tide.y > tide.x && tide.z > tide.x, "\(tide)")
|
||||
|
||||
// Graphite's saturation scale leaves the channels nearly equal…
|
||||
let grey = GamepadPalette.named("graphite").tint(violetPool)
|
||||
let spread = max(grey.x, grey.y, grey.z) - min(grey.x, grey.y, grey.z)
|
||||
XCTAssertLessThan(spread, 0.08, "\(grey)")
|
||||
// …at about the source's luminance (it desaturates, it doesn't dim).
|
||||
let luma = 0.2126 * violetPool.x + 0.7152 * violetPool.y + 0.0722 * violetPool.z
|
||||
XCTAssertEqual(grey.y, luma, accuracy: 0.05)
|
||||
}
|
||||
|
||||
/// Every palette stays in gamut on every colour the field is built from — an out-of-range
|
||||
/// channel would clamp differently on each platform's rasteriser.
|
||||
func testEveryPaletteStaysInGamut() {
|
||||
let field: [SIMD3<Double>] = [
|
||||
SIMD3(0.075, 0.060, 0.160), SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74),
|
||||
SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84),
|
||||
SIMD3(0.16, 0.26, 0.64), SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75),
|
||||
SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), SIMD3(0.22, 0.18, 0.54),
|
||||
SIMD3(0.24, 0.20, 0.58),
|
||||
]
|
||||
for palette in GamepadPalette.all {
|
||||
for c in field {
|
||||
let t = palette.tint(c)
|
||||
for v in [t.x, t.y, t.z] {
|
||||
XCTAssertTrue((0...1).contains(v), "\(palette.id) \(c) → \(t)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,5 +50,21 @@ final class HostDiscoveryTests: XCTestCase {
|
||||
XCTAssertEqual(host.fingerprintHex, String(repeating: "ab", count: 32))
|
||||
XCTAssertFalse(host.host.isEmpty, "a resolved address is required to connect")
|
||||
XCTAssertGreaterThan(host.port, 0, "a resolved port is required to connect")
|
||||
|
||||
// A rescan tears the browser down and re-arms it (the only way past the iOS local-network
|
||||
// permission gate without relaunching). The host must come BACK — `refresh()` cancels every
|
||||
// in-flight resolve and invalidates the previous generation's callbacks, so a re-arm that
|
||||
// failed to re-drive them would leave the list permanently empty.
|
||||
await discovery.rescan()
|
||||
var reappeared = false
|
||||
let rescanDeadline = Date().addingTimeInterval(10)
|
||||
while Date() < rescanDeadline {
|
||||
if await discovery.hosts.contains(where: { $0.id == uniqueid }) {
|
||||
reappeared = true
|
||||
break
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
XCTAssertTrue(reappeared, "a rescan must re-find a host that is still advertising")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// The safe-area stream mode (SafeDisplay), as pure geometry: Moonlight's formula — full native
|
||||
// height, width reduced by the left+right safe insets — plus the host's dimension rules (even, and
|
||||
// never under 320×200) and the landscape-inset resolution that makes the row correct even when the
|
||||
// settings screen it is rendered on is currently portrait.
|
||||
|
||||
import XCTest
|
||||
|
||||
import PunktfunkShared
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class SafeDisplayTests: XCTestCase {
|
||||
func testLandscapeUsesTheHorizontalInsets() {
|
||||
// Landscape: the housing is on a side and iOS symmetrizes the two, so either one is the
|
||||
// per-side inset.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 59, right: 59, top: 0, isPhone: true), 59)
|
||||
// Asymmetric (or mid-rotation) readings reduce to the larger — never under-inset.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 44, top: 0, isPhone: true), 44)
|
||||
}
|
||||
|
||||
func testPortraitFallsBackToTheHousingTopInset() {
|
||||
// Portrait on a notched phone: left/right are zero and the housing sits on `top`. Reading
|
||||
// the horizontal insets here would compute "no inset" for exactly the devices that need one,
|
||||
// so the portrait top inset stands in — it is the same physical intrusion.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: true), 59)
|
||||
// A plain status bar is not a housing: an iPad (or a pre-notch iPhone) must not fabricate an
|
||||
// inset for a device with nothing to route around.
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 24, isPhone: true), 0)
|
||||
XCTAssertEqual(
|
||||
SafeDisplay.sideInsetPoints(left: 0, right: 0, top: 59, isPhone: false), 0)
|
||||
}
|
||||
|
||||
func testModeInsetsWidthOnlyAndKeepsFullHeight() {
|
||||
// A Dynamic Island phone: 2556×1179 native, 59 pt per side at nativeScale 3 → 177 px per
|
||||
// side, 354 px total. Height is untouched — under aspect-fit only the horizontal axis binds.
|
||||
let m = SafeDisplay.mode(
|
||||
nativeWidth: 2556, nativeHeight: 1179, sideInsetPoints: 59, scale: 3)
|
||||
XCTAssertEqual(m.width, 2202, "2556 − 2×177")
|
||||
XCTAssertEqual(m.height, 1178, "odd native heights even-floor")
|
||||
// The safe mode must be NARROWER than native, or it would still fill the housing.
|
||||
XCTAssertLessThan(m.width, 2556)
|
||||
}
|
||||
|
||||
func testNoHousingYieldsTheNativeModeSoTheRowDedups() {
|
||||
// Zero inset ⇒ identical to native (bar the even-floor). `resolutionModes` dedups by
|
||||
// dimensions, so this is what makes the extra row vanish on a device that has no housing
|
||||
// rather than showing a pointless duplicate.
|
||||
let m = SafeDisplay.mode(
|
||||
nativeWidth: 2360, nativeHeight: 1640, sideInsetPoints: 0, scale: 2)
|
||||
XCTAssertEqual(m.width, 2360)
|
||||
XCTAssertEqual(m.height, 1640)
|
||||
}
|
||||
|
||||
func testResultIsAlwaysHostValid() {
|
||||
// Odd widths even-floor: `validate_dimensions` rejects odd outright, and an inset
|
||||
// subtraction lands odd about half the time.
|
||||
let odd = SafeDisplay.mode(
|
||||
nativeWidth: 2001, nativeHeight: 1001, sideInsetPoints: 0, scale: 1)
|
||||
XCTAssertEqual(odd.width % 2, 0)
|
||||
XCTAssertEqual(odd.height % 2, 0)
|
||||
// An absurd inset can't drive the mode under the host's floor.
|
||||
let tiny = SafeDisplay.mode(
|
||||
nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: 5000, scale: 3)
|
||||
XCTAssertEqual(tiny.width, SafeDisplay.minWidth)
|
||||
XCTAssertEqual(tiny.height, 720)
|
||||
// A negative inset is treated as none rather than widening past the panel.
|
||||
let neg = SafeDisplay.mode(
|
||||
nativeWidth: 1280, nativeHeight: 720, sideInsetPoints: -40, scale: 3)
|
||||
XCTAssertEqual(neg.width, 1280)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "punktfunk",
|
||||
"name": "Punktfunk",
|
||||
"author": "enrico",
|
||||
"flags": ["debug"],
|
||||
"api_version": 1,
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DECK="${DECK:?set DECK=deck@<ip>}"
|
||||
NAME="$(python3 -c 'import json;print(json.load(open("'"$HERE"'/plugin.json"))["name"])')"
|
||||
# The on-disk plugin DIR (what scripts/package.sh staged into out/), not plugin.json "name" —
|
||||
# that field is the brand-cased label Decky shows in its plugin list. See package.sh's header.
|
||||
NAME=punktfunk
|
||||
STAGE_LOCAL="$HERE/out/$NAME"
|
||||
[ -d "$STAGE_LOCAL" ] || { echo "$STAGE_LOCAL missing — run scripts/package.sh first" >&2; exit 1; }
|
||||
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
# package.json,decky.pyi,LICENSE,README.md}
|
||||
# out/punktfunk/ (the same tree, unzipped — rsync this with scripts/deploy.sh)
|
||||
#
|
||||
# Decky extracts the zip with --strip-components=1, so the single top-level dir MUST equal
|
||||
# plugin.json "name". Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs
|
||||
# only bash, python3 and zip.
|
||||
# The single top-level dir is the plugin's ON-DISK folder name (Decky extracts the zip as-is,
|
||||
# so the dir in the zip becomes ~/homebrew/plugins/<dir>). It is deliberately NOT read from
|
||||
# plugin.json "name": that field is the user-visible label ("Punktfunk", brand-cased, shown in
|
||||
# Decky's plugin list) and Decky locates an installed plugin by MATCHING it, never by the folder
|
||||
# name. Keeping the folder lowercase means a rename of the label can't strand the old directory
|
||||
# next to a new one (which would show up as two plugins).
|
||||
# Run after `pnpm build` (or use `pnpm run package`). Host-agnostic: needs only bash, python3 and zip.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$HERE"
|
||||
@@ -15,7 +19,7 @@ cd "$HERE"
|
||||
[ -f dist/index.js ] || { echo "dist/index.js missing — run 'pnpm build' first" >&2; exit 1; }
|
||||
[ -f LICENSE ] || { echo "LICENSE missing (required by the Decky store)" >&2; exit 1; }
|
||||
|
||||
NAME="$(python3 -c 'import json;print(json.load(open("plugin.json"))["name"])')"
|
||||
NAME=punktfunk # the on-disk plugin dir (see the header) — NOT plugin.json "name"
|
||||
VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"])')"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
|
||||
@@ -122,6 +122,25 @@ function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The label a saved row shows.
|
||||
*
|
||||
* A saved record whose name IS its own address is a PLACEHOLDER, not a choice: `hosts add`
|
||||
* falls back to the address when the pairing path had nothing better, so the row ends up
|
||||
* captioned with the same string it already prints underneath. When the box is on the air it
|
||||
* is advertising its actual hostname — prefer that, and the row reads "home-worker-5" instead
|
||||
* of "192.168.1.21".
|
||||
*
|
||||
* A real saved name always wins over the advert, even a stale one: it may be a name the user
|
||||
* chose, and a live advert must never quietly overwrite that. Compared against the SAVED
|
||||
* address, so a host that moved DHCP lease still recognises its old address as a placeholder.
|
||||
*/
|
||||
function hostLabel(s: SavedHost, advert?: DiscoveredHost): string {
|
||||
const placeholder = !s.name || s.name === s.addr || s.name === `${s.addr}:${s.port}`;
|
||||
if (!placeholder) return s.name;
|
||||
return advert?.name || s.name || s.addr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the saved store and the live browse into the rows the panel draws.
|
||||
*
|
||||
@@ -134,7 +153,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho
|
||||
// Prefer a live advert's address: the host may have moved since it was last saved.
|
||||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||||
return {
|
||||
name: s.name || s.addr,
|
||||
name: hostLabel(s, advert),
|
||||
addr: advert?.addr ?? s.addr,
|
||||
port: advert?.port ?? s.port,
|
||||
fp: s.fp_hex,
|
||||
@@ -387,7 +406,10 @@ export async function applyUpdate(
|
||||
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
|
||||
void backend.callable("utilities/install_plugin")(
|
||||
info.artifact,
|
||||
"punktfunk",
|
||||
// The name Decky uninstalls before extracting the new zip — it locates the folder by
|
||||
// matching plugin.json "name", so this must equal THIS build's plugin.json name (the
|
||||
// brand-cased one), not the lowercase on-disk dir.
|
||||
"Punktfunk",
|
||||
info.latest,
|
||||
info.hash,
|
||||
INSTALL_TYPE_UPDATE,
|
||||
|
||||
@@ -337,9 +337,11 @@ export default definePlugin(() => {
|
||||
// controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load.
|
||||
void ensureGamepadUiShortcut();
|
||||
return {
|
||||
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
|
||||
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
|
||||
name: "punktfunk",
|
||||
// `name` must stay in sync with plugin.json (the loader keys plugins by it) — and it is
|
||||
// USER-VISIBLE: Decky labels the entry in its plugin list with it, so it carries the brand
|
||||
// case. Decky finds an installed plugin by matching plugin.json "name" (never the folder
|
||||
// name), so this is independent of the on-disk dir, which stays lowercase `punktfunk`.
|
||||
name: "Punktfunk",
|
||||
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw
|
||||
// at plugin-load time (an error boundary only catches render-time, not load-time, errors).
|
||||
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
|
||||
|
||||
@@ -70,9 +70,18 @@ declare const appStore:
|
||||
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||
function shortcutStillExists(appId: number): boolean {
|
||||
try {
|
||||
const get = appStore?.GetAppOverviewByAppID;
|
||||
if (!get) return true; // no way to verify — preserve the reuse path
|
||||
return get(appId) != null;
|
||||
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
|
||||
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
|
||||
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
|
||||
// "true". That is not a stale-data bug but a total one: the guard then answers "still
|
||||
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
|
||||
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
|
||||
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
|
||||
// one is a ReferenceError that optional chaining does NOT prevent.
|
||||
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
|
||||
return true; // no way to verify — preserve the reuse path
|
||||
}
|
||||
return appStore.GetAppOverviewByAppID(appId) != null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ const CSS: &str = "
|
||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
||||
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
||||
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
|
||||
/* Launcher entries (design D4) open the launcher itself. They rarely have poster art, so an
|
||||
art-less one must not read as a game whose cover failed to load: accent face, the launcher
|
||||
named instead of a title monogram, and an accent badge. */
|
||||
.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); }
|
||||
.pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); }
|
||||
.pf-store-badge.pf-launcher { color: white; background: @accent_color; }
|
||||
.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); }
|
||||
";
|
||||
|
||||
/// Everything the shell shares below the component tree.
|
||||
|
||||
@@ -204,10 +204,18 @@ pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||
});
|
||||
match crate::library::fetch_games(&addr, port, &identity, pin) {
|
||||
Ok(games) => {
|
||||
// A fourth column, appended: `game` or `launcher` (design D4). Appended rather than
|
||||
// folded into an existing field so anything reading the first three columns is
|
||||
// untouched.
|
||||
for g in &games {
|
||||
println!("{}\t{}\t{}", g.id, g.store, g.title);
|
||||
let role = if g.is_launcher() { "launcher" } else { "game" };
|
||||
println!("{}\t{}\t{}\t{}", g.id, g.store, g.title, role);
|
||||
}
|
||||
let launchers = games.iter().filter(|g| g.is_launcher()).count();
|
||||
match launchers {
|
||||
0 => println!("{} game(s)", games.len()),
|
||||
n => println!("{} game(s), {} launcher(s)", games.len() - n, n),
|
||||
}
|
||||
println!("{} game(s)", games.len());
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -773,6 +781,7 @@ fn mock_library() -> (
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
role: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -674,6 +674,9 @@ pub struct HostsPage {
|
||||
saved: FactoryVecDeque<HostCard>,
|
||||
discovered: FactoryVecDeque<HostCard>,
|
||||
widgets: PageWidgets,
|
||||
/// Forces the mDNS browse to re-query (the header's Refresh button). `None` only if the
|
||||
/// browse never started — the button then just re-renders, which is what it did before.
|
||||
rescan: Option<discovery::Rescan>,
|
||||
}
|
||||
|
||||
struct PageWidgets {
|
||||
@@ -693,6 +696,10 @@ pub enum HostsMsg {
|
||||
},
|
||||
/// Reload the disk store and re-render (fresh pairings, renames, the library gate).
|
||||
Refresh,
|
||||
/// Re-query mDNS *and* re-render — the header's Refresh button. Distinct from [`Self::Refresh`],
|
||||
/// which only re-reads local state: after a while `mdns-sd` re-queries about once an hour, so a
|
||||
/// host that appeared since (or whose announcement was lost) needs an actual query to show up.
|
||||
Rescan,
|
||||
/// A completed reachability sweep: saved-host key → reachable. Merged into the online pips.
|
||||
Probed(HashMap<String, bool>),
|
||||
/// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores.
|
||||
@@ -841,6 +848,13 @@ impl SimpleComponent for HostsPage {
|
||||
add_host_btn.set_tooltip_text(Some("Add host"));
|
||||
add_host_btn.set_action_name(Some("win.add-host"));
|
||||
header.pack_start(&add_host_btn);
|
||||
let rescan_btn = gtk::Button::from_icon_name("view-refresh-symbolic");
|
||||
rescan_btn.set_tooltip_text(Some("Scan the network for hosts again"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan));
|
||||
}
|
||||
header.pack_start(&rescan_btn);
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||
@@ -867,8 +881,8 @@ impl SimpleComponent for HostsPage {
|
||||
}
|
||||
|
||||
// Stream mDNS adverts into the model; every add/remove re-evaluates both grids.
|
||||
let (rx, rescan) = discovery::browse();
|
||||
{
|
||||
let rx = discovery::browse();
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
@@ -937,6 +951,7 @@ impl SimpleComponent for HostsPage {
|
||||
disc_heading,
|
||||
searching,
|
||||
},
|
||||
rescan: Some(rescan),
|
||||
};
|
||||
model.rebuild();
|
||||
|
||||
@@ -954,6 +969,14 @@ impl SimpleComponent for HostsPage {
|
||||
self.rebuild();
|
||||
}
|
||||
HostsMsg::Refresh => self.rebuild(),
|
||||
HostsMsg::Rescan => {
|
||||
if let Some(rescan) = &self.rescan {
|
||||
rescan.request();
|
||||
}
|
||||
// Adverts stream in as they answer; re-render now so the local half is current
|
||||
// either way.
|
||||
self.rebuild();
|
||||
}
|
||||
HostsMsg::Probed(map) => {
|
||||
self.probed = map;
|
||||
self.rebuild();
|
||||
|
||||
@@ -28,6 +28,12 @@ struct State {
|
||||
req: ConnectRequest,
|
||||
stack: gtk::Stack,
|
||||
flow: gtk::FlowBox,
|
||||
/// Launcher entries (design D4) get their own shelf above the games, so a handful of ways to
|
||||
/// open a launcher aren't buried in a 400-title grid. Hidden outright when there are none.
|
||||
launcher_flow: gtk::FlowBox,
|
||||
launchers_group: gtk::Box,
|
||||
/// The "Games" heading — only earns its space once a Launchers shelf is above it.
|
||||
games_heading: gtk::Label,
|
||||
error_page: adw::StatusPage,
|
||||
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
|
||||
art: RefCell<HashMap<String, gdk::Texture>>,
|
||||
@@ -94,11 +100,44 @@ fn build(
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
// The launcher shelf: same tile geometry as the games grid, its own FlowBox so the two
|
||||
// groups never interleave and each wraps on its own.
|
||||
let launcher_flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(6)
|
||||
.column_spacing(12)
|
||||
.row_spacing(18)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
launcher_flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
let launchers_heading = gtk::Label::new(Some("Launchers"));
|
||||
launchers_heading.add_css_class("pf-group-heading");
|
||||
launchers_heading.set_halign(gtk::Align::Start);
|
||||
launchers_heading.set_margin_bottom(8);
|
||||
let launchers_group = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
launchers_group.append(&launchers_heading);
|
||||
launchers_group.append(&launcher_flow);
|
||||
launchers_group.set_margin_bottom(24);
|
||||
launchers_group.set_visible(false);
|
||||
|
||||
let games_heading = gtk::Label::new(Some("Games"));
|
||||
games_heading.add_css_class("pf-group-heading");
|
||||
games_heading.set_halign(gtk::Align::Start);
|
||||
games_heading.set_margin_bottom(8);
|
||||
games_heading.set_visible(false);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
content.set_margin_top(24);
|
||||
content.set_margin_bottom(24);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&launchers_group);
|
||||
content.append(&games_heading);
|
||||
content.append(&flow);
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(1100)
|
||||
@@ -166,6 +205,9 @@ fn build(
|
||||
req,
|
||||
stack,
|
||||
flow,
|
||||
launcher_flow,
|
||||
launchers_group,
|
||||
games_heading,
|
||||
error_page,
|
||||
art: RefCell::new(HashMap::new()),
|
||||
pics: RefCell::new(HashMap::new()),
|
||||
@@ -224,18 +266,41 @@ fn load(state: &Rc<State>) {
|
||||
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
|
||||
fn render(state: &Rc<State>, games: &[GameEntry]) {
|
||||
state.flow.remove_all();
|
||||
state.launcher_flow.remove_all();
|
||||
state.pics.borrow_mut().clear();
|
||||
for game in games {
|
||||
// Design D4: launchers never interleave with titles. The host already sorts by title, and
|
||||
// `partition` is stable, so each group keeps that order.
|
||||
let (launchers, titles): (Vec<&GameEntry>, Vec<&GameEntry>) =
|
||||
games.iter().partition(|g| g.is_launcher());
|
||||
for game in &launchers {
|
||||
state.launcher_flow.append(&game_card(state, game));
|
||||
}
|
||||
for game in &titles {
|
||||
state.flow.append(&game_card(state, game));
|
||||
}
|
||||
// A library with no launcher entries looks exactly as it did before this existed.
|
||||
state.launchers_group.set_visible(!launchers.is_empty());
|
||||
state
|
||||
.games_heading
|
||||
.set_visible(!launchers.is_empty() && !titles.is_empty());
|
||||
}
|
||||
|
||||
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
|
||||
/// monogram placeholder underneath the async art. Activation starts a session launching
|
||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let monogram = gtk::Label::new(Some(&initials(&game.title)));
|
||||
monogram.add_css_class("pf-poster-monogram");
|
||||
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens
|
||||
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load".
|
||||
let launcher = game.is_launcher();
|
||||
let monogram = if launcher {
|
||||
let l = gtk::Label::new(Some(store_label(&game.store)));
|
||||
l.add_css_class("pf-poster-launcher-name");
|
||||
l
|
||||
} else {
|
||||
let l = gtk::Label::new(Some(&initials(&game.title)));
|
||||
l.add_css_class("pf-poster-monogram");
|
||||
l
|
||||
};
|
||||
monogram.set_halign(gtk::Align::Center);
|
||||
monogram.set_valign(gtk::Align::Center);
|
||||
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
@@ -252,6 +317,9 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let badge = gtk::Label::new(Some(store_label(&game.store)));
|
||||
badge.add_css_class("pf-pill");
|
||||
badge.add_css_class("pf-store-badge");
|
||||
if launcher {
|
||||
badge.add_css_class("pf-launcher");
|
||||
}
|
||||
badge.set_halign(gtk::Align::Start);
|
||||
badge.set_valign(gtk::Align::Start);
|
||||
badge.set_margin_start(6);
|
||||
@@ -262,6 +330,9 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
poster.add_overlay(&pic);
|
||||
poster.add_overlay(&badge);
|
||||
poster.add_css_class("pf-poster");
|
||||
if launcher {
|
||||
poster.add_css_class("pf-launcher");
|
||||
}
|
||||
poster.set_overflow(gtk::Overflow::Hidden);
|
||||
poster.set_size_request(150, 225);
|
||||
poster.set_halign(gtk::Align::Center);
|
||||
|
||||
@@ -46,8 +46,13 @@ pub fn wake_and_connect(
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
use std::time::Duration;
|
||||
let events = crate::discovery::browse();
|
||||
let (events, rescan) = crate::discovery::browse();
|
||||
let mut wait = WakeWait::new();
|
||||
// A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own
|
||||
// re-query interval has doubled well past a minute by the time a boot finishes — so ask
|
||||
// again periodically instead of waiting to be told. Every 5th tick: often enough that a
|
||||
// host that came up is noticed promptly, rare enough not to hammer multicast.
|
||||
let mut ticks: u32 = 0;
|
||||
loop {
|
||||
if cancel.get() {
|
||||
waiting.close();
|
||||
@@ -100,6 +105,10 @@ pub fn wake_and_connect(
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
ticks += 1;
|
||||
if ticks % 5 == 0 {
|
||||
rescan.request();
|
||||
}
|
||||
glib::timeout_future(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -343,6 +343,7 @@ impl Service {
|
||||
probe_inflight: Arc::new(AtomicBool::new(false)),
|
||||
last_probe: Instant::now() - Duration::from_secs(60),
|
||||
wake_cancel: None,
|
||||
rescan: None,
|
||||
}
|
||||
.run(stop_w)
|
||||
})
|
||||
@@ -373,11 +374,14 @@ struct ServiceState {
|
||||
last_probe: Instant,
|
||||
/// Cancels the active wake thread (it owns the model's wake status).
|
||||
wake_cancel: Option<Arc<AtomicBool>>,
|
||||
/// Forces the mDNS browse to re-query. Installed by `run`; `None` before it starts.
|
||||
rescan: Option<discovery::Rescan>,
|
||||
}
|
||||
|
||||
impl ServiceState {
|
||||
fn run(mut self, stop: Arc<AtomicBool>) {
|
||||
let discovery_rx = discovery::browse();
|
||||
let (discovery_rx, rescan) = discovery::browse();
|
||||
self.rescan = Some(rescan);
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
// mDNS churn.
|
||||
while let Ok(ev) = discovery_rx.try_recv() {
|
||||
@@ -512,6 +516,14 @@ impl ServiceState {
|
||||
}
|
||||
ConsoleCmd::Probe => {
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60);
|
||||
// "Refresh presence" means the mDNS half too, not just the QUIC sweep: the browse
|
||||
// runs for the process's lifetime and `mdns-sd` backs its re-query interval off to
|
||||
// as much as an hour, so a host that appeared since startup may never be asked
|
||||
// for again. (No console screen emits Probe yet — every face button on the home
|
||||
// screen is spoken for — but the plumbing is correct for when one does.)
|
||||
if let Some(r) = &self.rescan {
|
||||
r.request();
|
||||
}
|
||||
}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
@@ -791,6 +803,7 @@ fn spawn_fetch(
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -831,6 +844,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -286,6 +286,12 @@ mod session_main {
|
||||
// Spawned at first params-build so it exists for --connect AND console launches.
|
||||
#[cfg(unix)]
|
||||
crate::ctl_socket::spawn(gamepad.clone());
|
||||
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
|
||||
// slots declare their render caps at open time, which happens on attach — after this.
|
||||
gamepad.set_pad_audio_prefs(
|
||||
settings.pad_haptics,
|
||||
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
@@ -389,6 +395,11 @@ mod session_main {
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
echo_cancel: settings.echo_cancel,
|
||||
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
|
||||
// service learns the same prefs below so tier-A slots declare their render caps
|
||||
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
|
||||
pad_haptics: settings.pad_haptics,
|
||||
pad_speaker: settings.pad_speaker.clone(),
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
|
||||
@@ -490,9 +490,13 @@ fn wake_and_connect(
|
||||
|
||||
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
std::thread::spawn(move || {
|
||||
let rx = crate::discovery::browse();
|
||||
let (rx, rescan) = crate::discovery::browse();
|
||||
let mut seen: Vec<DiscoveredHost> = Vec::new();
|
||||
let mut wait = WakeWait::new();
|
||||
// A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own
|
||||
// re-query interval has doubled well past a minute by the time a boot finishes — so ask
|
||||
// again periodically instead of waiting to be told (matches the GTK client's wake wait).
|
||||
let mut ticks: u32 = 0;
|
||||
loop {
|
||||
// Cancel already returned the UI to the host list — stop re-sending and tear down.
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
@@ -555,6 +559,10 @@ fn wake_and_connect(
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
ticks += 1;
|
||||
if ticks.is_multiple_of(5) {
|
||||
rescan.request();
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -595,6 +595,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
move || sa.call(true)
|
||||
})
|
||||
.into()];
|
||||
// Re-query mDNS. The browse runs for the app's lifetime, and `mdns-sd` backs its
|
||||
// re-query interval off to as much as an hour — so a host that appeared since
|
||||
// startup, or whose announcement was lost to multicast, may need an actual ask.
|
||||
actions.push(
|
||||
icon_btn("Scan the network for hosts again", Symbol::Refresh)
|
||||
.on_click({
|
||||
let (c, st) = (ctx.clone(), set_status.clone());
|
||||
move || {
|
||||
if let Some(r) = c.shared.rescan.lock().unwrap().as_ref() {
|
||||
r.request();
|
||||
}
|
||||
st.call("Scanning the network\u{2026}".to_string());
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
// The couch UI's front door, beside the other page actions. Absent on ARM64,
|
||||
// where the session binary ships without its Skia console.
|
||||
if CONSOLE_UI_AVAILABLE {
|
||||
|
||||
@@ -39,6 +39,10 @@ pub(crate) struct Game {
|
||||
pub(crate) id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) store: String,
|
||||
/// This entry opens the launcher itself (Steam Big Picture, Heroic) rather than a title —
|
||||
/// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that
|
||||
/// isn't `launcher` is a game" is decided in one place for every client.
|
||||
pub(crate) launcher: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
@@ -135,6 +139,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -215,6 +220,17 @@ fn initials(title: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A small group label above a tile grid ("Launchers" / "Games"). Only drawn when the page shows
|
||||
/// both groups — a single unlabelled grid is what every launcher-less library looked like before.
|
||||
fn group_heading(text: &str) -> Element {
|
||||
text_block(text)
|
||||
.font_size(12.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.margin(edges(2.0, 8.0, 2.0, 2.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// One poster tile: the artwork (or a monogram placeholder while it loads) with the store
|
||||
/// badge overlaid top-left, the title below, tap-to-launch across the whole tile.
|
||||
fn poster_tile(
|
||||
@@ -228,13 +244,20 @@ fn poster_tile(
|
||||
.stretch(Stretch::UniformToFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
// A launcher rarely has poster art, and an art-less launcher drawn like an art-less game
|
||||
// reads as "a game whose cover failed to load". So it names its launcher instead of
|
||||
// showing a title monogram, and the frame below picks up the accent stroke.
|
||||
None => border(
|
||||
text_block(initials(&game.title))
|
||||
.font_size(28.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
text_block(if game.launcher {
|
||||
store_label(&game.store).to_string()
|
||||
} else {
|
||||
initials(&game.title)
|
||||
})
|
||||
.font_size(if game.launcher { 18.0 } else { 28.0 })
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
@@ -242,14 +265,27 @@ fn poster_tile(
|
||||
};
|
||||
let framed = border(grid(vec![
|
||||
poster,
|
||||
pill(store_label(&game.store), Pill::Neutral)
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(6.0))
|
||||
.into(),
|
||||
// `Pill::Info` rather than a solid accent fill — `style.rs` is explicit that
|
||||
// white-on-bright is unreadable here.
|
||||
pill(
|
||||
store_label(&game.store),
|
||||
if game.launcher {
|
||||
Pill::Info
|
||||
} else {
|
||||
Pill::Neutral
|
||||
},
|
||||
)
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(6.0))
|
||||
.into(),
|
||||
]))
|
||||
.corner_radius(8.0)
|
||||
.border_brush(ThemeRef::CardStroke)
|
||||
.border_brush(if game.launcher {
|
||||
ThemeRef::Accent
|
||||
} else {
|
||||
ThemeRef::CardStroke
|
||||
})
|
||||
.border_thickness(uniform(1.0));
|
||||
|
||||
border(
|
||||
@@ -332,22 +368,43 @@ pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element {
|
||||
.into(),
|
||||
),
|
||||
LibraryPhase::Ready(games) => {
|
||||
let tiles: Vec<Element> = games
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
|
||||
let (target, id) = (target.clone(), g.id.clone());
|
||||
poster_tile(
|
||||
g,
|
||||
props.state.art.get(&g.id).map(String::as_str),
|
||||
poster_h,
|
||||
Box::new(move || {
|
||||
initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
body.push(tile_grid(tiles, cols, POSTER_GAP));
|
||||
let tile = |g: &Game| -> Element {
|
||||
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
|
||||
let (target, id) = (target.clone(), g.id.clone());
|
||||
poster_tile(
|
||||
g,
|
||||
props.state.art.get(&g.id).map(String::as_str),
|
||||
poster_h,
|
||||
Box::new(move || initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)),
|
||||
)
|
||||
};
|
||||
// Design D4: launcher entries get their own shelf above the titles, never
|
||||
// interleaved. `partition` is stable, so the host's title order survives in each
|
||||
// group. Headings appear only when both groups exist, so a library without launcher
|
||||
// entries renders exactly as it did before.
|
||||
let (launchers, titles): (Vec<&Game>, Vec<&Game>) =
|
||||
games.iter().partition(|g| g.launcher);
|
||||
let both = !launchers.is_empty() && !titles.is_empty();
|
||||
if !launchers.is_empty() {
|
||||
if both {
|
||||
body.push(group_heading("Launchers"));
|
||||
}
|
||||
body.push(tile_grid(
|
||||
launchers.iter().map(|g| tile(g)).collect(),
|
||||
cols,
|
||||
POSTER_GAP,
|
||||
));
|
||||
}
|
||||
if !titles.is_empty() {
|
||||
if both {
|
||||
body.push(group_heading("Games"));
|
||||
}
|
||||
body.push(tile_grid(
|
||||
titles.iter().map(|g| tile(g)).collect(),
|
||||
cols,
|
||||
POSTER_GAP,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,10 @@ impl PartialEq for Svc {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Shared {
|
||||
pub(crate) target: Mutex<Target>,
|
||||
/// Forces the app's single LAN browse to re-query — the hosts page's Refresh. Installed by
|
||||
/// the discovery effect below; `None` until then (and if the browse never started, in which
|
||||
/// case Refresh is simply inert rather than a second, competing browse).
|
||||
pub(crate) rescan: Mutex<Option<discovery::Rescan>>,
|
||||
/// The live session child (spawn mode) — the status page's Disconnect and the
|
||||
/// request-access Cancel kill it. A FRESH handle is installed per spawn.
|
||||
pub(crate) session: Mutex<crate::spawn::SessionChild>,
|
||||
@@ -459,8 +463,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
|
||||
cx.use_effect((), {
|
||||
let set_hosts = set_hosts.clone();
|
||||
let ctx = ctx.clone();
|
||||
move || {
|
||||
let rx = discovery::browse();
|
||||
let (rx, rescan) = discovery::browse();
|
||||
*ctx.shared.rescan.lock().unwrap() = Some(rescan);
|
||||
std::thread::spawn(move || {
|
||||
let mut acc: Vec<DiscoveredHost> = Vec::new();
|
||||
while let Ok(h) = rx.recv_blocking() {
|
||||
|
||||
@@ -1911,10 +1911,35 @@ pub(crate) fn settings_page(
|
||||
} else {
|
||||
border(vstack(Vec::<Element>::new())).into()
|
||||
};
|
||||
// Every save on this page is fire-and-forget by design — a failed settings write must
|
||||
// never take a stream down — so a client whose config store rejects writes looks entirely
|
||||
// normal: toggles move, profiles appear, and NOTHING survives a restart. That is exactly
|
||||
// how it reached us from the field ("it's in read-only mode"), with no log file to send
|
||||
// either. When the store is refusing writes, say so, name the path, and stop pretending.
|
||||
//
|
||||
// Same always-mounted-slot discipline as `sheet_slot`: one child in both states, and the
|
||||
// SAME KIND in both (a Border wrapping the bar, versus an empty background-less Border —
|
||||
// which per style.rs is not hit-testable, so it swallows no clicks). Neither a grid child
|
||||
// nor a vstack child is ever added or removed, which is where this reconciler's phantom
|
||||
// bookkeeping breaks.
|
||||
let store_slot: Element = match pf_client_core::trust::store_health::last_error() {
|
||||
Some(err) => border(
|
||||
InfoBar::new("Your changes aren\u{2019}t being saved")
|
||||
.message(format!(
|
||||
"Punktfunk can\u{2019}t write to its settings folder, so nothing on this \
|
||||
page will survive a restart. {err}"
|
||||
))
|
||||
.error()
|
||||
.is_closable(false),
|
||||
)
|
||||
.margin(edges(24.0, 12.0, 28.0, 0.0))
|
||||
.into(),
|
||||
None => border(vstack(Vec::<Element>::new())).into(),
|
||||
};
|
||||
// The bar rides an Auto row above the nav's Star row, so the nav (and the sheet's scrim
|
||||
// over it) still fills the rest of the window.
|
||||
grid(vec![
|
||||
scope_bar.grid_row(0),
|
||||
Element::from(vstack(vec![store_slot, scope_bar])).grid_row(0),
|
||||
Element::from(grid(vec![nav.into(), sheet_slot, confirm])).grid_row(1),
|
||||
])
|
||||
.rows([GridLength::Auto, GridLength::STAR])
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
//! results to the UI. Ported verbatim from the GTK client (`mdns-sd` is cross-platform).
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DiscoveredHost {
|
||||
@@ -24,10 +30,25 @@ pub struct DiscoveredHost {
|
||||
pub os: String,
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||
/// dropped (the send fails) or the daemon dies.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors
|
||||
/// `pf_client_core::discovery::Rescan`; see there for why a client needs one (`mdns-sd` re-queries
|
||||
/// on a backoff that doubles out to an hour, so a long-lived browse is effectively passive).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rescan(Arc<AtomicBool>);
|
||||
|
||||
impl Rescan {
|
||||
/// Ask the browse thread to put a fresh query on the wire. Coalesces; returns immediately.
|
||||
pub fn request(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime, with a handle that forces an immediate re-query.
|
||||
/// The thread exits when the receiver is dropped (the send fails) or the daemon dies.
|
||||
pub fn browse() -> (async_channel::Receiver<DiscoveredHost>, Rescan) {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let requested = flag.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-mdns".into())
|
||||
.spawn(move || {
|
||||
@@ -38,18 +59,45 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let receiver = match daemon.browse("_punktfunk._udp.local.") {
|
||||
let mut receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
|
||||
return;
|
||||
}
|
||||
};
|
||||
while let Ok(event) = receiver.recv() {
|
||||
loop {
|
||||
// The worker has to notice that its consumer went away even when NOTHING is
|
||||
// arriving — the normal state of a LAN with no hosts on it. The old blocking
|
||||
// `recv()` only ever learned that from a failed send, so a bounded consumer (the
|
||||
// wake-and-wait below spawns one browse per wake) left this thread and its daemon
|
||||
// — another thread, and a socket bound to :5353 — running for the app's lifetime.
|
||||
// Checked at the TOP so the `continue` arms below can't skip it either.
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
// Re-browsing the same type replaces the daemon's listener: it replays the cache
|
||||
// into the new channel, queries immediately, and resets the backoff.
|
||||
if requested.swap(false, Ordering::Relaxed) {
|
||||
match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => receiver = r,
|
||||
Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"),
|
||||
}
|
||||
}
|
||||
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => event,
|
||||
Err(_) if receiver.is_disconnected() && receiver.is_empty() => break,
|
||||
Err(_) => continue, // timed out — go round and look for a rescan request
|
||||
};
|
||||
if let ServiceEvent::ServiceResolved(info) = event {
|
||||
let props = info.get_properties();
|
||||
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
|
||||
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
|
||||
// IPv4 only, like every other client (`pf_client_core::discovery`): the core
|
||||
// dials `format!("{host}:{port}").parse::<SocketAddr>()`, which cannot parse a
|
||||
// bare IPv6 literal, and the host stack binds IPv4 sockets exclusively. Taking
|
||||
// an arbitrary first address here rendered cards that failed on every click,
|
||||
// because a host's OS responder commonly answers AAAA for its hostname.
|
||||
let Some(addr) = info.get_addresses_v4().iter().next().map(|a| a.to_string())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -85,5 +133,5 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
let _ = daemon.shutdown();
|
||||
})
|
||||
.expect("spawn mdns thread");
|
||||
rx
|
||||
(rx, Rescan(flag))
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
fn discover_and_print() {
|
||||
use std::time::{Duration, Instant};
|
||||
println!("Browsing the LAN for punktfunk hosts (~5 s)…");
|
||||
let rx = discovery::browse();
|
||||
let (rx, _rescan) = discovery::browse();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while Instant::now() < deadline {
|
||||
|
||||
@@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wasapi = "0.23"
|
||||
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
|
||||
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
|
||||
# property stores entirely (the same version the host pins).
|
||||
winreg = "0.56"
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
|
||||
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
|
||||
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
|
||||
|
||||
@@ -98,13 +98,43 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
/// Settings device pickers via session main), or the OS default. A picked device that's
|
||||
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
|
||||
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
|
||||
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
|
||||
///
|
||||
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
|
||||
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
|
||||
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
|
||||
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
|
||||
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
|
||||
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
|
||||
pub(crate) fn device_by_id(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
id: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
let devices = enumerator
|
||||
.get_device_collection(direction)
|
||||
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
|
||||
let count = devices
|
||||
.get_nbr_devices()
|
||||
.map_err(|e| anyhow!("endpoint count: {e}"))?;
|
||||
for i in 0..count {
|
||||
let dev = devices
|
||||
.get_device_at_index(i)
|
||||
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
|
||||
if dev.get_id().is_ok_and(|got| got == id) {
|
||||
return Ok(dev);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no active {direction:?} endpoint with id {id}")
|
||||
}
|
||||
|
||||
fn pick_device(
|
||||
enumerator: &DeviceEnumerator,
|
||||
direction: &Direction,
|
||||
var: &str,
|
||||
) -> Result<wasapi::Device> {
|
||||
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
|
||||
match enumerator.get_device(&id) {
|
||||
match device_by_id(enumerator, direction, &id) {
|
||||
Ok(d) => {
|
||||
tracing::info!(
|
||||
var,
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredHost {
|
||||
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
|
||||
@@ -54,10 +59,32 @@ pub enum DiscoveryEvent {
|
||||
Removed { fullname: String },
|
||||
}
|
||||
|
||||
/// Browse continuously. The worker exits when the returned receiver is dropped, or when the
|
||||
/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
/// Forces the running browse to re-query now. Cheap to clone and hand to a UI thread; a request
|
||||
/// made after the browse has ended is simply never read.
|
||||
///
|
||||
/// Why a client needs one at all: `mdns-sd` re-queries on a DOUBLING backoff (1s, 2s, 4s … capped
|
||||
/// at one hour), so a browse that has been up a while is effectively passive — it is listening for
|
||||
/// announcements rather than asking. A host that starts advertising later, or whose announcement
|
||||
/// was dropped (ordinary for multicast over Wi-Fi), can stay invisible for a very long time.
|
||||
/// Re-querying resets that clock, which is what a Refresh button should do.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rescan(Arc<AtomicBool>);
|
||||
|
||||
impl Rescan {
|
||||
/// Ask the browse thread to put a fresh query on the wire. Returns immediately; the query
|
||||
/// follows within a tick. Coalesces — several requests in a row cost one query.
|
||||
pub fn request(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse continuously, with a handle that forces an immediate re-query ([`Rescan`]). The worker
|
||||
/// exits when the returned receiver is dropped, or when the daemon dies — checked on a tick, so
|
||||
/// it stops even on a LAN where no advert ever arrives.
|
||||
pub fn browse() -> (async_channel::Receiver<DiscoveryEvent>, Rescan) {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let requested = flag.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-mdns".into())
|
||||
.spawn(move || {
|
||||
@@ -68,7 +95,7 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let receiver = match daemon.browse("_punktfunk._udp.local.") {
|
||||
let mut receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
|
||||
@@ -88,6 +115,17 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
// Also at the TOP, and for the same reason: every `continue` below would skip it.
|
||||
if requested.swap(false, Ordering::Relaxed) {
|
||||
// Browsing the same type again REPLACES the daemon's listener for it: it
|
||||
// replays the cache into the new channel (so nothing already known is lost),
|
||||
// puts a fresh PTR query on the wire immediately, and — the point — resets the
|
||||
// re-query backoff described on `Rescan`.
|
||||
match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => receiver = r,
|
||||
Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"),
|
||||
}
|
||||
}
|
||||
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => event,
|
||||
Err(_) if receiver.is_disconnected() => break,
|
||||
@@ -147,7 +185,7 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
let _ = daemon.shutdown();
|
||||
})
|
||||
.expect("spawn mdns thread");
|
||||
rx
|
||||
(rx, Rescan(flag))
|
||||
}
|
||||
|
||||
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
|
||||
@@ -174,7 +212,7 @@ fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
|
||||
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
|
||||
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
|
||||
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
|
||||
let rx = browse();
|
||||
let (rx, _rescan) = browse();
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut adverts = Adverts::new();
|
||||
while Instant::now() < deadline {
|
||||
|
||||
@@ -369,8 +369,14 @@ enum Ctl {
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
Forwarding(bool),
|
||||
SystemButtons { forward_raw: bool, gesture: bool },
|
||||
SystemButtons {
|
||||
forward_raw: bool,
|
||||
gesture: bool,
|
||||
},
|
||||
TapButton(u32),
|
||||
/// Which pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||
/// speaker) — the settings half of the per-pad tier-A capability declared at slot open.
|
||||
PadAudioPrefs(u8),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -573,6 +579,18 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
|
||||
}
|
||||
|
||||
/// Declare which pad-audio streams this session's settings want rendered (`haptics` =
|
||||
/// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` =
|
||||
/// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad
|
||||
/// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge
|
||||
/// declares exactly these; every other pad declares 0. Call before [`Self::attach`],
|
||||
/// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing"
|
||||
/// for an embedder that never calls it, keeping the wire bytes exactly as before.
|
||||
pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) {
|
||||
let bits = (haptics as u8) | ((speaker as u8) << 1);
|
||||
let _ = self.ctl.send(Ctl::PadAudioPrefs(bits));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -746,6 +764,8 @@ impl Ds5Feedback {
|
||||
/// The USB report offsets these are derived from — see the type doc. Kept beside the derived
|
||||
/// values so the subtraction is visible at the point of definition.
|
||||
const REPORT_ID_LEN: usize = 1;
|
||||
/// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`): report byte 5.
|
||||
const AUDIO: usize = 5 - Self::REPORT_ID_LEN;
|
||||
const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN;
|
||||
const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN;
|
||||
const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN;
|
||||
@@ -782,6 +802,29 @@ impl Ds5Feedback {
|
||||
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
||||
p
|
||||
}
|
||||
|
||||
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
|
||||
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
|
||||
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
|
||||
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
|
||||
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
|
||||
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
|
||||
fn audio_haptics_packet() -> [u8; 47] {
|
||||
[0u8; 47]
|
||||
}
|
||||
|
||||
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
|
||||
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
|
||||
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
|
||||
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
|
||||
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
|
||||
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
|
||||
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
|
||||
let mut p = [0u8; 47];
|
||||
p[0] = (flags & 0x1E) << 3;
|
||||
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
||||
@@ -818,6 +861,14 @@ struct Slot {
|
||||
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
|
||||
/// `guide_gesture` policy is on.
|
||||
gesture: SelectGesture,
|
||||
/// Pad-audio render capabilities declared for this slot (bit0 = haptics, bit1 = speaker
|
||||
/// — the [`NativeClient::set_pad_audio_caps`] bits). Nonzero only for a tier-A pad (a
|
||||
/// WIRED DualSense/Edge, see [`crate::pad_audio::is_tier_a_ds5`]) under matching
|
||||
/// settings; bit0 set additionally suppresses wire rumble for this slot (the SDL
|
||||
/// disable-bit trap — see [`Worker::render_feedback`]).
|
||||
audio_caps: u8,
|
||||
/// The wire-rumble-suppressed notice fired for this slot (log once, not per command).
|
||||
rumble_suppressed_logged: bool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
@@ -834,6 +885,8 @@ impl Slot {
|
||||
held_clicks: [false; 2],
|
||||
last_accel: [0; 3],
|
||||
gesture: SelectGesture::default(),
|
||||
audio_caps: 0,
|
||||
rumble_suppressed_logged: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,6 +1024,10 @@ struct Worker {
|
||||
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
|
||||
/// down went out on receipt, the up goes out from the poll once `due` passes.
|
||||
synthetic_ups: Vec<(u8, u32, Instant)>,
|
||||
/// Pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
|
||||
/// speaker — [`GamepadService::set_pad_audio_prefs`]). `0` (the default) until an embedder
|
||||
/// declares some: tier-A detection then never runs and every arrival stays caps-less.
|
||||
pad_audio_prefs: u8,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
@@ -1176,11 +1233,18 @@ impl Worker {
|
||||
Ok(pad) => {
|
||||
let mut slot = Slot::new(id, index, pref, pad);
|
||||
Self::set_slot_sensors(&mut slot, true);
|
||||
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
|
||||
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
|
||||
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and
|
||||
// uses the session-default kind.
|
||||
if let Some(c) = &self.attached {
|
||||
// Pad-audio render caps go in FIRST — the core ORs them into this (and
|
||||
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
|
||||
// set (0 for non-tier-A): wire indices are reused within a connection, so
|
||||
// a tier-A slot that closes must not leave its bits behind for the next
|
||||
// pad on the same index (the set_rumble_quirks rule).
|
||||
c.set_pad_audio_caps(index, slot.audio_caps);
|
||||
send(
|
||||
c,
|
||||
InputKind::GamepadArrival,
|
||||
@@ -1203,6 +1267,27 @@ impl Worker {
|
||||
};
|
||||
c.set_rumble_quirks(index as u16, quirks);
|
||||
}
|
||||
if slot.audio_caps != 0 {
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
|
||||
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
|
||||
// "disable audio haptics") whenever its rumble path runs — which
|
||||
// would MUTE the voice coils the 0xD1 stream drives. One effects
|
||||
// packet with those bits CLEARED puts the pad back on audio haptics
|
||||
// ("Leaving emulated rumble bits off will restore audio haptics" —
|
||||
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
|
||||
// render_feedback so SDL never re-arms them.
|
||||
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
|
||||
}
|
||||
// Hand the pad to the session's renderer worker. Windows correlation
|
||||
// needs the HID interface path; Linux matches the sink by signature.
|
||||
crate::pad_audio::register_tier_a(index, slot.pad.path());
|
||||
tracing::info!(
|
||||
index,
|
||||
caps = slot.audio_caps,
|
||||
"tier-A DualSense: pad-audio render caps declared"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
id,
|
||||
index,
|
||||
@@ -1216,6 +1301,35 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
|
||||
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
|
||||
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
|
||||
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
|
||||
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
|
||||
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
|
||||
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
|
||||
if self.pad_audio_prefs == 0 {
|
||||
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
|
||||
}
|
||||
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
|
||||
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
|
||||
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
|
||||
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
|
||||
return 0; // not a DualSense/Edge — no wired check needed
|
||||
}
|
||||
use sdl3::joystick::ConnectionState;
|
||||
let wired = match pad.connection_state() {
|
||||
Ok(ConnectionState::Wired) => true,
|
||||
Ok(ConnectionState::Wireless) => false,
|
||||
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
|
||||
};
|
||||
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
|
||||
self.pad_audio_prefs
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
|
||||
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
||||
/// already gone (unplug).
|
||||
@@ -1233,6 +1347,11 @@ impl Worker {
|
||||
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
|
||||
}
|
||||
let slot = self.slots.remove(i);
|
||||
if slot.audio_caps != 0 {
|
||||
// Take the pad back from the pad-audio renderer (its device-gone path then
|
||||
// re-correlates — and finds nothing until a tier-A pad registers again).
|
||||
crate::pad_audio::unregister_tier_a(slot.index);
|
||||
}
|
||||
tracing::info!(
|
||||
id = slot.id,
|
||||
index = slot.index,
|
||||
@@ -1654,6 +1773,7 @@ impl Worker {
|
||||
set_valve_hidapi(false);
|
||||
}
|
||||
}
|
||||
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1966,6 +2086,20 @@ impl Worker {
|
||||
// first; the physical silence backstop is in `close_slot_at`).
|
||||
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
||||
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
|
||||
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
|
||||
// so a slot with tier-A haptics active never issues wire rumble (the stream
|
||||
// carries the feedback; the game's rumble is in its haptics mix).
|
||||
if slot.audio_caps & 0x01 != 0 {
|
||||
if !slot.rumble_suppressed_logged {
|
||||
slot.rumble_suppressed_logged = true;
|
||||
tracing::info!(
|
||||
pad = slot.index,
|
||||
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
||||
}
|
||||
}
|
||||
@@ -2003,13 +2137,27 @@ impl Worker {
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||||
}
|
||||
// The audio-control region of a DS5 output report a game wrote host-side
|
||||
// (volumes + routing; the SAMPLES ride 0xD1) — folded back into the physical
|
||||
// pad's effects packet, but only where a tier-A renderer is actually live
|
||||
// (`audio_caps`): replaying speaker volumes at a pad whose audio device
|
||||
// nothing streams to would just mute/blast a future session's start state.
|
||||
// Non-tier-A pads keep dropping it (the pre-pad-audio behaviour).
|
||||
HidOutput::AudioCtl { flags, raw, .. } if is_ds && slot.audio_caps != 0 => {
|
||||
let _ = slot
|
||||
.pad
|
||||
.send_effect(&Ds5Feedback::audio_ctl_packet(flags, &raw));
|
||||
}
|
||||
// Deliberately unhandled, listed rather than left to a bare `_` so a new
|
||||
// variant cannot join them silently: adaptive triggers exist only on a
|
||||
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
|
||||
// and carried by `send_effect` above when the pad is one.
|
||||
// and carried by `send_effect` above when the pad is one. `AudioCtl` lands here
|
||||
// only when the guarded arm above declined it — a non-DualSense pad, or one with
|
||||
// no live tier-A renderer — which is the pre-pad-audio behaviour: drop it.
|
||||
HidOutput::Trigger { .. }
|
||||
| HidOutput::TrackpadHaptic { .. }
|
||||
| HidOutput::HidRaw { .. } => {}
|
||||
| HidOutput::HidRaw { .. }
|
||||
| HidOutput::AudioCtl { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2048,6 +2196,9 @@ fn hidout_pad(h: &HidOutput) -> u8 {
|
||||
| HidOutput::Trigger { pad, .. }
|
||||
| HidOutput::TrackpadHaptic { pad, .. }
|
||||
| HidOutput::HidRaw { pad, .. } => *pad,
|
||||
// AudioCtl's pad is the plane's only u16. `HidOutput::decode` rejects anything at or
|
||||
// above MAX_PADS (B27), so by the time one reaches here the narrowing is lossless.
|
||||
HidOutput::AudioCtl { pad, .. } => *pad as u8,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,6 +2226,7 @@ impl Worker {
|
||||
system_forward: true,
|
||||
guide_gesture: false,
|
||||
synthetic_ups: Vec::new(),
|
||||
pad_audio_prefs: 0,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -2520,6 +2672,44 @@ mod slot_tests {
|
||||
}),
|
||||
6
|
||||
);
|
||||
// AudioCtl's wire pad is u16; the index space is 0..MAX_PADS end to end.
|
||||
assert_eq!(
|
||||
hidout_pad(&HidOutput::AudioCtl {
|
||||
pad: 7,
|
||||
flags: 0,
|
||||
raw: [0; 6]
|
||||
}),
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
|
||||
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
|
||||
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
|
||||
/// stay CLEAR — setting either would mute the voice coils the 0xD1 stream drives.
|
||||
#[test]
|
||||
fn audio_ctl_folds_report_bytes_into_effect_offsets() {
|
||||
let raw = [0x50, 0x60, 0x70, 0x05, 0x11, 0x22];
|
||||
// flags 0b1_0111: haptics-select (bit0) + audio-valid bits 1/2/4 of the condensed form.
|
||||
let p = Ds5Feedback::audio_ctl_packet(0b1_0111, &raw);
|
||||
assert_eq!(&p[4..10], &raw, "report bytes 5..=10 → struct 4..=9");
|
||||
// bits1..4 (0b1011) → flag0 bits 4..7.
|
||||
assert_eq!(p[0], 0b1011_0000);
|
||||
assert_eq!(
|
||||
p[0] & 0x03,
|
||||
0,
|
||||
"haptics-select must NOT replay into p[0] bits 0/1"
|
||||
);
|
||||
// Nothing else is touched: no trigger/LED enable bits, no stray bytes.
|
||||
assert!(p[1..4].iter().all(|&b| b == 0));
|
||||
assert!(p[10..].iter().all(|&b| b == 0));
|
||||
// No audio-valid flags condenses to no enable bits (raw still carried verbatim).
|
||||
let p = Ds5Feedback::audio_ctl_packet(0b0_0001, &raw);
|
||||
assert_eq!(p[0], 0);
|
||||
assert_eq!(&p[4..10], &raw);
|
||||
// The tier-A activation packet is the all-clear: every enable bit off — per
|
||||
// SDL_hidapi_ps5.c, leaving the emulated-rumble bits off restores audio haptics.
|
||||
assert_eq!(Ds5Feedback::audio_haptics_packet(), [0u8; 47]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,11 @@ pub mod os;
|
||||
// Client settings profiles: the override catalog + the one connect-time resolver
|
||||
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
|
||||
// the bindings live on.
|
||||
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
|
||||
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
|
||||
// the tier-A pad registry the gamepad worker feeds it through.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod pad_audio;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod profiles;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -66,6 +66,20 @@ pub struct GameEntry {
|
||||
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
|
||||
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. A UI may group these
|
||||
/// separately; one that doesn't renders them as ordinary tiles, which is the intended
|
||||
/// degradation (design D4). Kept a plain string: the host owns the vocabulary, and an unknown
|
||||
/// future value must never fail the whole library decode.
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
impl GameEntry {
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
pub fn is_launcher(&self) -> bool {
|
||||
self.role.as_deref() == Some("launcher")
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,14 @@ pub struct SessionParams {
|
||||
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
|
||||
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
|
||||
pub echo_cancel: bool,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
|
||||
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
|
||||
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
|
||||
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
|
||||
/// off — see [`crate::pad_audio::speaker_active`]).
|
||||
pub pad_speaker: String,
|
||||
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
|
||||
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
|
||||
pub clipboard: bool,
|
||||
@@ -356,6 +364,11 @@ fn pump(
|
||||
);
|
||||
}
|
||||
}
|
||||
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
|
||||
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
|
||||
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
|
||||
let pad_speaker_on = crate::pad_audio::speaker_active(¶ms.pad_speaker);
|
||||
let pad_audio_on = params.pad_haptics || pad_speaker_on;
|
||||
let connector = match NativeClient::connect(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
@@ -379,6 +392,11 @@ fn pump(
|
||||
0
|
||||
}) | (if params.phase_lock {
|
||||
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
|
||||
} else {
|
||||
0
|
||||
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
|
||||
}) | (if pad_audio_on {
|
||||
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
@@ -501,6 +519,20 @@ fn pump(
|
||||
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
|
||||
// thread (one puller per plane), blocking on the audio queue like the Apple client.
|
||||
let audio_thread = spawn_audio(connector.clone(), stop.clone());
|
||||
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
|
||||
// the settings could render. The output device is opened LAZILY once frames actually
|
||||
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
|
||||
// a session without a wired DualSense costs one idle 10 ms poll loop.
|
||||
let pad_audio_thread = pad_audio_on
|
||||
.then(|| {
|
||||
crate::pad_audio::spawn(
|
||||
connector.clone(),
|
||||
stop.clone(),
|
||||
params.pad_haptics,
|
||||
pad_speaker_on,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
|
||||
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
|
||||
// away when the host has no clipboard capability, so spawning is unconditional.
|
||||
@@ -876,7 +908,27 @@ fn pump(
|
||||
}
|
||||
}
|
||||
Err(PunktfunkError::NoFrame) => {}
|
||||
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
|
||||
// The session ended. `None` here means "normal finish" to every embedder — the browse
|
||||
// console returns to the library with no status strip, the one-shot binary exits 0
|
||||
// quietly — so only an ending that actually went wrong should carry a message.
|
||||
// Previously EVERY close reported "Host ended the session", which put an error-shaped
|
||||
// line in front of the player for quitting their own game.
|
||||
Err(PunktfunkError::Closed) => {
|
||||
use punktfunk_core::client::PunktfunkEndReason as End;
|
||||
break match connector.end_reason() {
|
||||
// The player quit the game the host launched. Nothing to report; a launcher
|
||||
// embedder returns to its library, which is where they were headed anyway.
|
||||
End::GameExited => None,
|
||||
// We closed it, or the host closed cleanly (an operator "End", or the session
|
||||
// simply finishing). Both were asked for.
|
||||
End::Local | End::HostEnded => None,
|
||||
End::HostError => Some("The host ended the session with an error".to_string()),
|
||||
End::Lost => Some("Connection lost".to_string()),
|
||||
// No verdict (an older core, or the close raced the read): keep the wording
|
||||
// this arm has always used rather than inventing a new one.
|
||||
End::None => Some("Host ended the session".to_string()),
|
||||
};
|
||||
}
|
||||
Err(e) => break Some(format!("session: {e:?}")),
|
||||
}
|
||||
|
||||
@@ -1066,6 +1118,9 @@ fn pump(
|
||||
if let Some(t) = audio_thread {
|
||||
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = pad_audio_thread {
|
||||
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
|
||||
}
|
||||
if let Some(t) = clipboard_thread {
|
||||
let _ = t.join(); // exits within its next_clip wait once `stop` is set
|
||||
}
|
||||
|
||||
@@ -91,22 +91,131 @@ fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
|
||||
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
/// A sibling temp path unique to this process. The stores below have five whole-file writers
|
||||
/// (WinUI shell, session, console UI, CLI, Decky) and a single shared `.json.tmp` lets two of
|
||||
/// them interleave: on Windows the second `fs::write` hits a sharing violation, and worse, one
|
||||
/// process can rename the OTHER's half-written bytes over the target. The pid keeps each
|
||||
/// writer on its own scratch file; the rename below removes it, so a leftover only survives a
|
||||
/// hard kill.
|
||||
fn temp_sibling(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(format!(".tmp-{}", std::process::id()));
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Write a config file the safe way: a sibling temp file, then a rename over the target. A
|
||||
/// plain `fs::write` truncates first, so a crash, a full disk or a power cut between truncate
|
||||
/// and the last byte leaves an empty/half file — and these stores are what a client needs to
|
||||
/// find its hosts at all. Rename is atomic within a directory on both Unix and Windows
|
||||
/// (`MoveFileEx` with replace), so a reader ever sees the old file or the new one, never a
|
||||
/// torn one. Same discipline as the host's `session_settings.rs`.
|
||||
///
|
||||
/// **But the rename is not always available, and losing the write is far worse than a torn
|
||||
/// one.** The Windows client ships as an MSIX package, so every path here is rewritten by the
|
||||
/// container's AppData virtualization before it reaches the filesystem — and when the package
|
||||
/// is installed to a secondary drive (Settings ▸ Storage ▸ "New apps will save to: D:"),
|
||||
/// Windows stores that redirected AppData on the *package's* volume, under
|
||||
/// `D:\WpSystem\<SID>\AppData\`. The literal path we name still says `C:\Users\…`, so a rename
|
||||
/// can end up straddling two volumes, and `std::fs::rename` is `MoveFileExW` with
|
||||
/// `MOVEFILE_REPLACE_EXISTING` and *not* `MOVEFILE_COPY_ALLOWED` — a cross-volume move fails
|
||||
/// outright with `ERROR_NOT_SAME_DEVICE`. Creating and writing files works fine, which is why
|
||||
/// such an install starts, streams and pairs happily while every setting and profile silently
|
||||
/// evaporates (field report 2026-08-05: "it's in read-only mode").
|
||||
///
|
||||
/// So a failed rename falls back to writing the target in place. That is exactly what the
|
||||
/// identity files already do a few lines up — and those demonstrably work on the affected
|
||||
/// installs — so the fallback is a path we know resolves. It gives up crash-atomicity for that
|
||||
/// one write and nothing else: the temp+rename stays the normal route everywhere it works.
|
||||
///
|
||||
/// Writes and reads of one literal path cannot disagree under that redirection — Microsoft
|
||||
/// documents a single private-location-first resolution order for both, so whichever layer a
|
||||
/// write lands in is the layer the next read finds. The fallback still verifies by reading
|
||||
/// back: a silent write is the exact bug being fixed here, and this path only runs on an
|
||||
/// install that has already proven it does something unusual.
|
||||
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
match std::fs::rename(&tmp, path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
// Don't leave the temp behind to confuse the next writer (or a backup tool).
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Err(e)
|
||||
let tmp = temp_sibling(path);
|
||||
let atomic = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
|
||||
let Err(e) = atomic else {
|
||||
store_health::clear();
|
||||
return Ok(());
|
||||
};
|
||||
// Don't leave the temp behind to confuse the next writer (or a backup tool).
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
match std::fs::write(path, bytes) {
|
||||
Ok(()) => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"atomic replace unavailable in this install; wrote the config in place instead",
|
||||
);
|
||||
// Read it straight back. This whole bug was a write that reported success and
|
||||
// vanished, so the fallback does not get to claim success on the strength of an
|
||||
// `Ok(())` alone — on the one layered filesystem we know we run on, that is the
|
||||
// failure mode to be paranoid about. Only on the degraded path, so the normal
|
||||
// route pays nothing.
|
||||
match std::fs::read(path) {
|
||||
Ok(back) if back == bytes => {
|
||||
store_health::clear();
|
||||
Ok(())
|
||||
}
|
||||
Ok(_) => {
|
||||
let e = std::io::Error::other(
|
||||
"the file read back different from what was just written",
|
||||
);
|
||||
store_health::record(path, &e);
|
||||
Err(e)
|
||||
}
|
||||
Err(reread) => {
|
||||
store_health::record(path, &reread);
|
||||
Err(reread)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both routes are gone: the store really is unwritable. Report the direct write's
|
||||
// error — it describes the actual permission/space problem, where the rename's may
|
||||
// only say the two paths landed on different volumes.
|
||||
Err(direct) => {
|
||||
store_health::record(path, &direct);
|
||||
Err(direct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the config store is accepting writes, so a front-end can *say so* when it is not.
|
||||
///
|
||||
/// Every persistence call site in this crate is deliberately fire-and-forget — a failed
|
||||
/// settings write must never take a stream down — which historically meant a client whose
|
||||
/// store was unwritable looked completely normal: toggles moved, profiles appeared, and
|
||||
/// nothing survived a restart. The field report that produced this module had no log file to
|
||||
/// send either, so there was no signal anywhere. Recording the last failure centrally lets the
|
||||
/// UI surface it without unpicking ~15 `let _ = …save()` call sites.
|
||||
pub mod store_health {
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
pub(crate) fn record(path: &Path, err: &std::io::Error) {
|
||||
let msg = format!("{}: {err}", path.display());
|
||||
tracing::error!(store = %path.display(), error = %err, "cannot persist client config");
|
||||
if let Ok(mut slot) = LAST_ERROR.lock() {
|
||||
*slot = Some(msg);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear() {
|
||||
if let Ok(mut slot) = LAST_ERROR.lock() {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The most recent failure to persist a config file, if the last attempt failed.
|
||||
///
|
||||
/// Tracks the last *attempt*, not a per-file verdict: a store that cannot be written fails
|
||||
/// every file, so this latches for as long as the problem lasts and goes quiet the moment
|
||||
/// any write gets through.
|
||||
pub fn last_error() -> Option<String> {
|
||||
LAST_ERROR.lock().ok().and_then(|s| s.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,6 +1111,15 @@ pub struct Settings {
|
||||
/// Experimental: the game-library browser ("Browse library…" on saved cards) —
|
||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||
pub library_enabled: bool,
|
||||
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
|
||||
/// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/
|
||||
/// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android
|
||||
/// clients' twins). Presentation only: nothing about a stream depends on it, which is
|
||||
/// why it is a device preference and never part of a settings profile. An unknown
|
||||
/// name reads as the default rather than erroring — a newer client may have shipped a
|
||||
/// palette this binary doesn't know.
|
||||
#[serde(default = "default_ui_palette")]
|
||||
pub ui_palette: String,
|
||||
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
||||
/// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional
|
||||
/// behavior before this became a setting. Off is for hosts reached over a VPN, where
|
||||
@@ -1024,6 +1142,21 @@ pub struct Settings {
|
||||
/// `PUNKTFUNK_AUDIO_SOURCE`).
|
||||
#[serde(default)]
|
||||
pub mic_device: String,
|
||||
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
|
||||
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
|
||||
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
|
||||
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
|
||||
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
|
||||
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
|
||||
/// a wired DS5. `default` so pre-existing stores load with it on.
|
||||
#[serde(default = "default_true")]
|
||||
pub pad_haptics: bool,
|
||||
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
|
||||
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
|
||||
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
|
||||
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
|
||||
#[serde(default = "default_pad_speaker")]
|
||||
pub pad_speaker: String,
|
||||
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
|
||||
/// stream mode follows the session window — the connect asks for the window's pixel
|
||||
/// size and a mid-session resize renegotiates the host's virtual display + encoder
|
||||
@@ -1071,6 +1204,14 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_ui_palette() -> String {
|
||||
"violet".into()
|
||||
}
|
||||
|
||||
fn default_pad_speaker() -> String {
|
||||
"pad".into()
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
||||
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
||||
@@ -1175,10 +1316,13 @@ impl Default for Settings {
|
||||
stats_verbosity: None,
|
||||
fullscreen_on_stream: true,
|
||||
library_enabled: false,
|
||||
ui_palette: default_ui_palette(),
|
||||
auto_wake: true,
|
||||
invert_scroll: false,
|
||||
speaker_device: String::new(),
|
||||
mic_device: String::new(),
|
||||
pad_haptics: true,
|
||||
pad_speaker: "pad".into(),
|
||||
match_window: false,
|
||||
last_window_w: 0,
|
||||
last_window_h: 0,
|
||||
@@ -1919,6 +2063,7 @@ mod tests {
|
||||
/// discipline all three client stores now share.
|
||||
#[test]
|
||||
fn write_atomic_replaces_and_cleans_up() {
|
||||
let _guard = store_health_lock();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
@@ -1932,7 +2077,112 @@ mod tests {
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}");
|
||||
write_atomic(&p, b"{\"a\":2}").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}");
|
||||
assert!(!p.with_extension("json.tmp").exists());
|
||||
assert!(!temp_sibling(&p).exists());
|
||||
// Nothing else in the directory either — the scratch file is gone, not renamed aside.
|
||||
let left: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok().map(|e| e.file_name()))
|
||||
.collect();
|
||||
assert_eq!(left, vec![std::ffi::OsString::from("store.json")]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `store_health` is process-global, so the two tests that read it must not run at the same
|
||||
/// time — one's successful write clears the other's recorded failure. Nothing else in the
|
||||
/// crate's tests reaches `write_atomic`, so this lock is the whole serialization needed.
|
||||
fn store_health_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Two processes saving at once must not share one scratch file — the pid keeps them apart.
|
||||
/// (Same-process, so this only proves the name varies with the pid, not the interleaving.)
|
||||
#[test]
|
||||
fn temp_sibling_is_per_process_and_a_sibling() {
|
||||
let p = Path::new("/tmp/pf/client-windows-settings.json");
|
||||
let t = temp_sibling(p);
|
||||
assert_eq!(t.parent(), p.parent());
|
||||
assert_eq!(
|
||||
t.file_name().unwrap().to_str().unwrap(),
|
||||
format!("client-windows-settings.json.tmp-{}", std::process::id())
|
||||
);
|
||||
// Must not collide with the store itself, nor look like one to `load()`.
|
||||
assert_ne!(t, p.to_path_buf());
|
||||
}
|
||||
|
||||
/// **The fix itself.** When the temp+rename route is unavailable, the bytes must still
|
||||
/// reach the target — that is the difference between the field's "read-only mode" and a
|
||||
/// working client. Simulated by parking a DIRECTORY on the (deterministic) temp sibling
|
||||
/// path so the temp leg cannot be written; the field's install fails one step later, at
|
||||
/// the rename, but both funnel into the same fallback, which is what this pins.
|
||||
#[test]
|
||||
fn the_atomic_route_failing_falls_back_to_an_in_place_write() {
|
||||
let _guard = store_health_lock();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-inplace-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("store.json");
|
||||
std::fs::write(&p, b"{\"old\":true}").unwrap();
|
||||
|
||||
// Block the scratch path, so the atomic route cannot complete.
|
||||
std::fs::create_dir_all(temp_sibling(&p)).unwrap();
|
||||
assert!(temp_sibling(&p).is_dir());
|
||||
|
||||
// The write must still report success AND actually be readable back — a silent
|
||||
// `Ok(())` that lost the bytes is the bug, not the fix.
|
||||
write_atomic(&p, b"{\"new\":true}").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"new\":true}");
|
||||
// Degraded, but not broken: nothing to warn the user about.
|
||||
assert_eq!(store_health::last_error(), None);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The other end: when the in-place fallback ALSO fails, the error must surface rather
|
||||
/// than be swallowed, because at that point nothing the user does on the page will stick.
|
||||
#[test]
|
||||
fn a_failed_rename_still_persists_the_write() {
|
||||
let _guard = store_health_lock();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-fallback-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Sanity: the healthy path reports a healthy store.
|
||||
let ok = dir.join("store.json");
|
||||
write_atomic(&ok, b"{}").unwrap();
|
||||
assert_eq!(store_health::last_error(), None);
|
||||
|
||||
// Now the unwritable case: a directory in the target's place defeats BOTH the rename
|
||||
// and the in-place write, so the error must surface instead of being swallowed.
|
||||
let blocked = dir.join("blocked.json");
|
||||
std::fs::create_dir_all(&blocked).unwrap();
|
||||
std::fs::write(blocked.join("occupant"), b"x").unwrap();
|
||||
assert!(write_atomic(&blocked, b"{\"a\":1}").is_err());
|
||||
let reported = store_health::last_error().expect("an unwritable store must be reported");
|
||||
assert!(
|
||||
reported.contains("blocked.json"),
|
||||
"the report names the store: {reported}"
|
||||
);
|
||||
// No scratch file left behind by the failed attempt.
|
||||
assert!(!temp_sibling(&blocked).exists());
|
||||
|
||||
// And a later success clears it, so the UI stops warning once the store recovers.
|
||||
write_atomic(&ok, b"{\"a\":2}").unwrap();
|
||||
assert_eq!(store_health::last_error(), None);
|
||||
assert_eq!(std::fs::read_to_string(&ok).unwrap(), "{\"a\":2}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,11 @@ fn load_floor(path: &Path, channel: &str) -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it.
|
||||
/// Raise (never lower) the floor, through the crate's one config writer — this used to
|
||||
/// hand-roll its own tmp+rename, which meant it neither cleaned up its temp on a failed
|
||||
/// rename nor picked up [`crate::trust::write_atomic`]'s in-place fallback, so on an install
|
||||
/// where the rename cannot work the floor silently never rose and a declined update came
|
||||
/// back forever.
|
||||
fn store_floor(path: &Path, channel: &str, serial: u64) {
|
||||
let mut file: FloorFile = std::fs::read(path)
|
||||
.ok()
|
||||
@@ -287,10 +291,7 @@ fn store_floor(path: &Path, channel: &str, serial: u64) {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, &bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
let _ = crate::trust::write_atomic(path, &bytes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- check
|
||||
|
||||
@@ -309,6 +309,24 @@ pub fn offer_wire_mimes(raw: &[String]) -> Vec<&'static str> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a non-canonical, client-supplied MIME is safe to hand to Wayland as a string argument.
|
||||
///
|
||||
/// Deliberately strict: printable ASCII only (so no NUL and no other control byte can reach the
|
||||
/// `CString` in the generated encoder), bounded length, and it must actually look like a MIME type.
|
||||
/// A real `type/subtype[;params]` passes; nothing that could crash or confuse the compositor does.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn valid_passthrough_mime(m: &str) -> bool {
|
||||
let Some((ty, rest)) = m.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
!ty.is_empty()
|
||||
&& !rest.is_empty()
|
||||
&& m.len() <= 255
|
||||
// 0x21..=0x7E: printable ASCII without space. Excludes NUL, every other control byte, and
|
||||
// any non-ASCII byte.
|
||||
&& m.bytes().all(|b| (0x21..=0x7E).contains(&b))
|
||||
}
|
||||
|
||||
/// The Wayland MIMEs to advertise when installing a source for a client's offer. Each wire MIME
|
||||
/// expands to its canonical Wayland name(s); a rich-text-only offer also advertises `text/plain`
|
||||
/// so plain-text targets always paste (§3.5 synthesis — destination-side, one direction only).
|
||||
@@ -342,7 +360,17 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
|
||||
WIRE_PNG => push("image/png"),
|
||||
WIRE_JPEG => push("image/jpeg"),
|
||||
WIRE_GIF => push("image/gif"),
|
||||
other => push(other),
|
||||
// A MIME we don't canonicalize is passed through verbatim — so it is the one value on
|
||||
// this path the CLIENT fully controls, and it ends up as a Wayland string argument.
|
||||
// The wayland-scanner-generated request encoder builds a `CString` and `unwrap()`s it,
|
||||
// so a single interior NUL turns one control message into a host clipboard panic
|
||||
// (2026-08-05 review L-8). `String::from_utf8_lossy` on the wire preserves `\0`, so
|
||||
// nothing upstream removes it. Validate here, at the boundary where the value stops
|
||||
// being ours and becomes libwayland's.
|
||||
other if valid_passthrough_mime(other) => push(other),
|
||||
other => {
|
||||
tracing::debug!(mime = %other.escape_debug(), "clipboard: dropping a malformed client MIME");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Synthesis: rich text without plain text → also advertise plain (the source derives it lazily).
|
||||
@@ -389,6 +417,38 @@ mod tests {
|
||||
assert_eq!(offer_wire_mimes(&raw), vec![WIRE_TEXT, WIRE_HTML]);
|
||||
}
|
||||
|
||||
/// One control message must not be able to panic the host clipboard coordinator
|
||||
/// (2026-08-05 review L-8). The passthrough branch is the only place a client string becomes a
|
||||
/// Wayland argument, and the generated encoder `unwrap()`s a `CString` built from it.
|
||||
#[test]
|
||||
fn passthrough_mimes_cannot_carry_a_nul_or_control_byte() {
|
||||
// The crash payload: an interior NUL survives `String::from_utf8_lossy` on the wire.
|
||||
assert!(!valid_passthrough_mime("image/webp\0"));
|
||||
assert!(!valid_passthrough_mime("\0"));
|
||||
assert!(!valid_passthrough_mime("image/\0webp"));
|
||||
// Other control bytes and whitespace are refused for the same reason.
|
||||
assert!(!valid_passthrough_mime("image/web\np"));
|
||||
assert!(!valid_passthrough_mime("image/web p"));
|
||||
assert!(!valid_passthrough_mime("image/web\tp"));
|
||||
// Shapes that are not a MIME type at all.
|
||||
assert!(!valid_passthrough_mime(""));
|
||||
assert!(!valid_passthrough_mime("noslash"));
|
||||
assert!(!valid_passthrough_mime("/nosubtype"));
|
||||
assert!(!valid_passthrough_mime("notype/"));
|
||||
assert!(!valid_passthrough_mime(&format!(
|
||||
"image/{}",
|
||||
"x".repeat(300)
|
||||
)));
|
||||
// Legitimate uncanonicalized MIMEs still pass through.
|
||||
assert!(valid_passthrough_mime("image/webp"));
|
||||
assert!(valid_passthrough_mime("application/x-custom+json"));
|
||||
assert!(valid_passthrough_mime("text/plain;charset=utf-8"));
|
||||
|
||||
// End to end: the offer list is built without the malformed entry, and does not panic.
|
||||
let offers = wayland_offers_for(&["image/webp\0".to_string(), WIRE_PNG.to_string()]);
|
||||
assert_eq!(offers, vec!["image/png".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_wayland_mime_prefers_canonical() {
|
||||
let avail = vec!["text/plain".to_string(), "UTF8_STRING".to_string()];
|
||||
|
||||
@@ -169,7 +169,27 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] {
|
||||
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
|
||||
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
|
||||
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
// Bound the DECODE, not just the result.
|
||||
//
|
||||
// These bytes are client-supplied, and `load_from_memory` used the `image` crate's DEFAULT
|
||||
// limits — 512 MiB of decode allowance — while the 32767 dimension check below only ran on the
|
||||
// already-decoded image. So a small, valid PNG declaring enormous dimensions was allocated in
|
||||
// full before anything rejected it: ~1000× amplification from a few KB of wire (2026-08-05
|
||||
// review L-9). Limits applied here make the allocation refuse instead.
|
||||
//
|
||||
// The caps are the clipboard's own contract expressed up front: the same 32767 per side that
|
||||
// is checked below (a CF_DIB cannot express more), and 256 MiB, which is more than the largest
|
||||
// representable 32bpp image anyone pastes and far less than a memory-exhaustion primitive.
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(32767);
|
||||
limits.max_image_height = Some(32767);
|
||||
limits.max_alloc = Some(256 * 1024 * 1024);
|
||||
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?;
|
||||
let mut reader = reader;
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
||||
if w == 0 || h == 0 || w > 32767 || h > 32767 {
|
||||
|
||||
@@ -203,17 +203,118 @@ pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [
|
||||
(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
|
||||
];
|
||||
|
||||
/// The mesh gradient as SkSL, palette + motion baked into the source (only time and
|
||||
/// resolution are uniforms). A smooth bicubic blend of the 16 colours — a separable
|
||||
// --- Background palettes -------------------------------------------------------------------
|
||||
|
||||
/// One background colour family for the console's living backdrop. A palette is NOT a second
|
||||
/// hand-tuned 16-colour grid: it is a hue rotation + saturation scale applied to
|
||||
/// [`MESH_COLORS`], so every palette inherits the field's structure (dark corners, bright
|
||||
/// interior pools, warm-left/cool-right) and the brand default is exactly the shipped look —
|
||||
/// `violet` is the identity transform. The Apple and Android clients carry the same table and
|
||||
/// the same [`tint`] math, so a palette reads as the same colour family on every client.
|
||||
pub struct Palette {
|
||||
/// The stored `ui_palette` value (see `trust::Settings::ui_palette`).
|
||||
pub id: &'static str,
|
||||
/// What the settings row shows.
|
||||
pub name: &'static str,
|
||||
/// Hue rotation about the grey axis, degrees — positive runs red → green → blue.
|
||||
pub hue_deg: f64,
|
||||
/// Saturation scale about luminance; `1.0` keeps the source saturation.
|
||||
pub sat: f64,
|
||||
}
|
||||
|
||||
/// The six shipped palettes, in cycling order (the brand violet first, then cool → warm,
|
||||
/// then the neutral). Adding one here adds it to every console settings screen; the Apple
|
||||
/// and Android tables must gain the same entry to keep the `ui_palette` key portable.
|
||||
pub const PALETTES: [Palette; 6] = [
|
||||
Palette {
|
||||
id: "violet",
|
||||
name: "Violet",
|
||||
hue_deg: 0.0,
|
||||
sat: 1.0,
|
||||
},
|
||||
Palette {
|
||||
id: "tide",
|
||||
name: "Tide",
|
||||
hue_deg: -70.0,
|
||||
sat: 1.0,
|
||||
},
|
||||
Palette {
|
||||
id: "forest",
|
||||
name: "Forest",
|
||||
hue_deg: -130.0,
|
||||
sat: 0.9,
|
||||
},
|
||||
Palette {
|
||||
id: "ember",
|
||||
name: "Ember",
|
||||
hue_deg: 105.0,
|
||||
sat: 1.0,
|
||||
},
|
||||
Palette {
|
||||
id: "rose",
|
||||
name: "Rose",
|
||||
hue_deg: 60.0,
|
||||
sat: 0.95,
|
||||
},
|
||||
Palette {
|
||||
id: "graphite",
|
||||
name: "Graphite",
|
||||
hue_deg: 0.0,
|
||||
sat: 0.12,
|
||||
},
|
||||
];
|
||||
|
||||
/// The palette stored under `id`, falling back to the brand default — an unknown name is a
|
||||
/// palette a newer client shipped, not a reason to draw nothing.
|
||||
pub fn palette(id: &str) -> &'static Palette {
|
||||
PALETTES.iter().find(|p| p.id == id).unwrap_or(&PALETTES[0])
|
||||
}
|
||||
|
||||
/// Rotate `(r, g, b)` about the grey axis by `deg` (Rodrigues — the same rotation the shader
|
||||
/// already uses for the ±8° warm/cool sway) and scale its saturation about luminance. Clamped,
|
||||
/// because a large rotation can push a channel out of gamut. Ported verbatim to Swift and
|
||||
/// Kotlin: keep the three copies in step or the palettes drift apart between clients.
|
||||
pub fn tint(c: (f64, f64, f64), deg: f64, sat: f64) -> (f64, f64, f64) {
|
||||
let (r, g, b) = c;
|
||||
let a = deg.to_radians();
|
||||
let (sn, cs) = a.sin_cos();
|
||||
let inv_sqrt3 = 1.0 / 3.0f64.sqrt();
|
||||
let grey = (r + g + b) / 3.0 * (1.0 - cs);
|
||||
// The `sn` term is `cross(k, c)` with k = (1,1,1)/√3 — the SAME orientation the shader's
|
||||
// own `hue()` uses, so a palette rotation and the ±8° sway agree on which way is warmer.
|
||||
let rot = (
|
||||
r * cs + (b - g) * inv_sqrt3 * sn + grey,
|
||||
g * cs + (r - b) * inv_sqrt3 * sn + grey,
|
||||
b * cs + (g - r) * inv_sqrt3 * sn + grey,
|
||||
);
|
||||
let luma = 0.2126 * rot.0 + 0.7152 * rot.1 + 0.0722 * rot.2;
|
||||
let mix = |v: f64| (luma + (v - luma) * sat).clamp(0.0, 1.0);
|
||||
(mix(rot.0), mix(rot.1), mix(rot.2))
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// [`MESH_COLORS`] under this palette's transform.
|
||||
pub fn mesh_colors(&self) -> [(f64, f64, f64); 16] {
|
||||
core::array::from_fn(|i| tint(MESH_COLORS[i], self.hue_deg, self.sat))
|
||||
}
|
||||
}
|
||||
|
||||
/// The mesh gradient as SkSL, palette + motion baked into the source (resolution, time and
|
||||
/// the calm mix are uniforms). A smooth bicubic blend of the 16 colours — a separable
|
||||
/// cubic-Bézier basis in x then y, C∞ and edge-to-edge, the fragment-shader analogue of
|
||||
/// SwiftUI's `MeshGradient(smoothsColors: true)`. The four interior points drive a
|
||||
/// bounded (weighted-average) domain warp so the bright pools drift; then the whole field
|
||||
/// gets the ±8°/~5-min hue sway, an elliptical vignette, and the vertical legibility scrim,
|
||||
/// all matching the Swift `composite(at:)`. Runs on the GPU at full rate.
|
||||
pub fn mesh_sksl() -> String {
|
||||
///
|
||||
/// `u_tc.y` is the CALM mix, 0 → 1: at 1 the same living field is flattened toward its own
|
||||
/// corner colour (`u_lift`), which is how the form screens (settings, add-host, pair) stay
|
||||
/// restful while still drifting — the motion never changes speed, only the contrast, so the
|
||||
/// crossfade between a launcher screen and a form screen can't make the field jump.
|
||||
pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String {
|
||||
// Colours as `float3(r, g, b)` literals, indices 0..15 (row-major 4×4).
|
||||
let c = |i: usize| {
|
||||
let (r, g, b) = MESH_COLORS[i];
|
||||
let (r, g, b) = colors[i];
|
||||
format!("float3({r}, {g}, {b})")
|
||||
};
|
||||
// The four interior-point domain-warp accumulators. Displacement matches Swift `wob()`:
|
||||
@@ -224,14 +325,18 @@ pub fn mesh_sksl() -> String {
|
||||
warp.push_str(&format!(
|
||||
" q = uv - float2({bx}, {by});\n\
|
||||
ww = exp(-dot(q, q) / (2.0 * 0.30 * 0.30));\n\
|
||||
d = float2({amp} * sin(u_t * {sx} + {ph}), \
|
||||
{amp} * cos(u_t * {sy} + {ph} * 1.3));\n\
|
||||
d = float2({amp} * sin(tt * {sx} + {ph}), \
|
||||
{amp} * cos(tt * {sy} + {ph} * 1.3));\n\
|
||||
wsum += d * ww; wtot += ww;\n",
|
||||
));
|
||||
}
|
||||
format!(
|
||||
"uniform float2 u_res;\n\
|
||||
uniform float u_t;\n\
|
||||
// x = seconds since the shell started, y = the calm mix (0 launcher, 1 form).\n\
|
||||
uniform float2 u_tc;\n\
|
||||
// rgb = the palette's corner colour scaled for the calm lift; a is unused (float4\n\
|
||||
// so the uniform block stays 16-byte aligned under any packing rule).\n\
|
||||
uniform float4 u_lift;\n\
|
||||
\n\
|
||||
// Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.\n\
|
||||
float bz(float t, float a, float b, float c, float d) {{\n\
|
||||
@@ -250,6 +355,7 @@ pub fn mesh_sksl() -> String {
|
||||
}}\n\
|
||||
\n\
|
||||
half4 main(float2 xy) {{\n\
|
||||
\x20 float tt = u_tc.x; float calm = u_tc.y;\n\
|
||||
\x20 float2 uv = xy / u_res;\n\
|
||||
\x20 // Interior control points wander → bounded domain warp (pools follow them).\n\
|
||||
\x20 float2 wsum = float2(0.0); float wtot = 0.0; float2 q; float ww; float2 d;\n\
|
||||
@@ -263,11 +369,18 @@ pub fn mesh_sksl() -> String {
|
||||
\x20 float3 r3 = bz3(uv.x, {c12}, {c13}, {c14}, {c15});\n\
|
||||
\x20 float3 col = bz3(uv.y, r0, r1, r2, r3);\n\
|
||||
\n\
|
||||
\x20 col = hue(col, sin(u_t * 0.021) * 0.1396263);\n\
|
||||
\x20 col = hue(col, sin(tt * 0.021) * 0.1396263);\n\
|
||||
\n\
|
||||
\x20 // Calm: flatten the field toward its own corner colour — the pools dim and the\n\
|
||||
\x20 // corners lift, so a form screen keeps real colour under its glass rows while\n\
|
||||
\x20 // losing the launcher's contrast. Motion is untouched (see the doc comment).\n\
|
||||
\x20 col = mix(col, col * 0.60 + u_lift.rgb, calm);\n\
|
||||
\n\
|
||||
\x20 // Elliptical vignette: clear at r=0.25 → black·0.42 at r=1.15 (aspect-fit ellipse).\n\
|
||||
\x20 // Halved under calm: a launcher's cards sit in the pooled centre, but a form\n\
|
||||
\x20 // screen's rows run out toward the edges, where crushing to black just eats them.\n\
|
||||
\x20 float2 e = (xy / u_res - 0.5) * 2.0;\n\
|
||||
\x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * 0.42;\n\
|
||||
\x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm);\n\
|
||||
\x20 col *= 1.0 - vig;\n\
|
||||
\n\
|
||||
\x20 // Vertical legibility scrim: black 0.38/0.06/0.08/0.40 at 0/0.32/0.68/1.\n\
|
||||
@@ -306,6 +419,11 @@ pub struct LibraryGame {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub store: String,
|
||||
/// This entry opens the launcher itself (Steam Big Picture, Heroic, Lutris) rather than a
|
||||
/// title — design D4. The host's `role` field, already reduced to a boolean by
|
||||
/// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't
|
||||
/// `launcher` is a game" rule lives in exactly one place.
|
||||
pub launcher: bool,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
@@ -341,7 +459,15 @@ impl LibraryShared {
|
||||
}
|
||||
|
||||
/// Loaded games → the carousel (empty = the empty scene).
|
||||
///
|
||||
/// **Launcher entries are moved to the front, keeping the host's title order within each
|
||||
/// group.** Grouping here rather than in the renderer means the carousel's cursor arithmetic,
|
||||
/// the art pump and every future consumer of this model all inherit the invariant for free —
|
||||
/// a launcher tile is never buried in the middle of a 400-title shelf.
|
||||
pub fn set_games(&self, games: Vec<LibraryGame>) {
|
||||
let mut games = games;
|
||||
// `sort_by_key` is stable, so this is a partition that preserves the incoming order.
|
||||
games.sort_by_key(|g| !g.launcher);
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.phase = if games.is_empty() {
|
||||
LibraryPhase::Empty
|
||||
@@ -408,6 +534,52 @@ mod tests {
|
||||
assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary);
|
||||
}
|
||||
|
||||
/// Design D4: launcher entries lead the shelf, and the host's title order survives within
|
||||
/// each group. The renderer's `launcher_count()` reads the launcher group as the prefix
|
||||
/// `0..n`, so an interleaved list would silently mislabel the group heading.
|
||||
#[test]
|
||||
fn set_games_groups_launchers_first_and_keeps_title_order() {
|
||||
let g = |title: &str, launcher: bool| LibraryGame {
|
||||
id: format!("steam:{title}"),
|
||||
title: title.to_string(),
|
||||
store: "steam".into(),
|
||||
launcher,
|
||||
};
|
||||
let shared = LibraryShared::default();
|
||||
shared.set_games(vec![
|
||||
g("Celeste", false),
|
||||
g("Big Picture", true),
|
||||
g("Portal 2", false),
|
||||
g("Heroic", true),
|
||||
]);
|
||||
let (phase, games, _) = shared.snapshot();
|
||||
assert!(matches!(phase, LibraryPhase::Ready));
|
||||
let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Big Picture", "Heroic", "Celeste", "Portal 2"]);
|
||||
assert_eq!(games.iter().take_while(|g| g.launcher).count(), 2);
|
||||
}
|
||||
|
||||
/// A library with no launcher entries is untouched — the whole point of the grouping being
|
||||
/// invisible until a plugin actually publishes a launcher tile.
|
||||
#[test]
|
||||
fn set_games_leaves_a_launcher_less_library_alone() {
|
||||
let shared = LibraryShared::default();
|
||||
shared.set_games(
|
||||
["Celeste", "Portal 2", "Tunic"]
|
||||
.iter()
|
||||
.map(|t| LibraryGame {
|
||||
id: format!("steam:{t}"),
|
||||
title: (*t).to_string(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let (_, games, _) = shared.snapshot();
|
||||
let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Celeste", "Portal 2", "Tunic"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_clamps_onto_the_ends() {
|
||||
assert_eq!(step_cursor(1, 5, -JUMP, true), StepResult::Moved(0));
|
||||
@@ -482,10 +654,56 @@ mod tests {
|
||||
/// 16 colours baked in, the five bicubic evals and four interior warp terms present).
|
||||
#[test]
|
||||
fn mesh_sksl_shape() {
|
||||
let src = mesh_sksl();
|
||||
let src = mesh_sksl(&MESH_COLORS);
|
||||
assert!(src.matches("float3(").count() >= 16, "16 colours baked");
|
||||
assert_eq!(src.matches("bz3(").count(), 6); // 1 definition + 5 call sites
|
||||
assert_eq!(src.matches("wtot +=").count(), 4); // one per interior point
|
||||
assert_eq!(src.matches('{').count(), src.matches('}').count());
|
||||
}
|
||||
|
||||
/// The brand default must be the IDENTITY transform — the shipped violet backdrop is
|
||||
/// what every existing install already sees, and a palette table that quietly restyled
|
||||
/// it would be a regression dressed as a feature.
|
||||
#[test]
|
||||
fn violet_is_the_untouched_shipped_field() {
|
||||
assert_eq!(PALETTES[0].id, "violet");
|
||||
for (a, b) in palette("violet").mesh_colors().iter().zip(&MESH_COLORS) {
|
||||
assert!((a.0 - b.0).abs() < 1e-9, "{a:?} vs {b:?}");
|
||||
assert!((a.1 - b.1).abs() < 1e-9, "{a:?} vs {b:?}");
|
||||
assert!((a.2 - b.2).abs() < 1e-9, "{a:?} vs {b:?}");
|
||||
}
|
||||
// An unknown name is a newer client's palette, not an error.
|
||||
assert_eq!(palette("chartreuse").id, "violet");
|
||||
assert_eq!(palette("").id, "violet");
|
||||
}
|
||||
|
||||
/// The transform's two knobs do what they claim: a rotation moves the hue while holding
|
||||
/// roughly the same luminance, and the saturation scale collapses toward grey. These are
|
||||
/// the numbers the Swift and Kotlin ports have to reproduce.
|
||||
#[test]
|
||||
fn tint_rotates_hue_and_scales_saturation() {
|
||||
let violet = MESH_COLORS[5]; // the brightest interior pool: blue dominates
|
||||
assert!(violet.2 > violet.0 && violet.2 > violet.1);
|
||||
// +105° (Ember) turns the blue-dominant pool red-dominant.
|
||||
let ember = tint(violet, 105.0, 1.0);
|
||||
assert!(ember.0 > ember.2, "{ember:?} should be warm");
|
||||
// −130° (Forest) turns it green-dominant.
|
||||
let forest = tint(violet, -130.0, 1.0);
|
||||
assert!(forest.1 > forest.0 && forest.1 > forest.2, "{forest:?}");
|
||||
// Graphite's saturation scale leaves the three channels nearly equal…
|
||||
let grey = tint(violet, 0.0, 0.12);
|
||||
let spread = grey.0.max(grey.1).max(grey.2) - grey.0.min(grey.1).min(grey.2);
|
||||
assert!(spread < 0.08, "{grey:?} spread {spread}");
|
||||
// …at about the source's luminance (it desaturates, it doesn't dim).
|
||||
let luma = 0.2126 * violet.0 + 0.7152 * violet.1 + 0.0722 * violet.2;
|
||||
assert!((grey.1 - luma).abs() < 0.05, "{grey:?} vs luma {luma}");
|
||||
// Every palette stays in gamut on every mesh colour.
|
||||
for p in &PALETTES {
|
||||
for c in p.mesh_colors() {
|
||||
for v in [c.0, c.1, c.2] {
|
||||
assert!((0.0..=1.0).contains(&v), "{} {c:?}", p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ use skia_safe::{Canvas, Rect};
|
||||
/// What a screen draws over (the shell crossfades between them on push/pop).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Bg {
|
||||
/// The living mesh aurora (home, library).
|
||||
/// The living mesh aurora at full contrast (home, library).
|
||||
Aurora,
|
||||
/// The quiet indigo form backdrop (settings, add-host, pair).
|
||||
/// The SAME living mesh, calmed — dimmed pools, lifted corners (settings, add-host,
|
||||
/// pair). Not a second backdrop: the shell chases one `calm` uniform between the two.
|
||||
Form,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::library::{
|
||||
};
|
||||
use crate::model::{ConsoleCmd, HostRow};
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
||||
use crate::theme::{white, Fonts, DIM, W, WHITE};
|
||||
use crate::theme::{brand, white, Fonts, DIM, W, WHITE};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44};
|
||||
use std::collections::HashMap;
|
||||
@@ -168,10 +168,31 @@ impl LibraryScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many launcher entries lead the shelf — [`LibraryShared::set_games`] groups them at the
|
||||
/// front, so the launcher group is always the prefix `0..launcher_count()`.
|
||||
fn launcher_count(&self) -> usize {
|
||||
self.games.iter().take_while(|g| g.launcher).count()
|
||||
}
|
||||
|
||||
/// Is the focused entry a launcher? (Drives the confirm hint: you *open* Steam, you *play* a
|
||||
/// game.)
|
||||
fn focused_is_launcher(&self) -> bool {
|
||||
self.games
|
||||
.get(self.cursor as usize)
|
||||
.is_some_and(|g| g.launcher)
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
match &self.phase {
|
||||
LibraryPhase::Ready => vec![
|
||||
Hint::new(HintKey::Confirm, "Play"),
|
||||
Hint::new(
|
||||
HintKey::Confirm,
|
||||
if self.focused_is_launcher() {
|
||||
"Open"
|
||||
} else {
|
||||
"Play"
|
||||
},
|
||||
),
|
||||
Hint::new(HintKey::Shoulders, "Jump"),
|
||||
Hint::new(HintKey::Back, "Back"),
|
||||
],
|
||||
@@ -277,6 +298,30 @@ impl LibraryScreen {
|
||||
let pos = self.anim.pos;
|
||||
let bump = self.bump.pos * k;
|
||||
|
||||
// Group heading. The model groups launcher entries at the front (design D4), and a
|
||||
// coverflow is one-dimensional — so instead of a second focus rail (a new up/down nav
|
||||
// model, in three renderers, for two or three tiles) the heading names the group the
|
||||
// cursor is in and changes as it crosses the boundary. Drawn only when the shelf
|
||||
// actually has both groups, so a library without launchers looks exactly as before.
|
||||
let launchers = self.launcher_count();
|
||||
if launchers > 0 && launchers < self.games.len() {
|
||||
let heading = if (self.cursor as usize) < launchers {
|
||||
"LAUNCHERS"
|
||||
} else {
|
||||
"GAMES"
|
||||
};
|
||||
fonts.centered(
|
||||
canvas,
|
||||
heading,
|
||||
W::SemiBold,
|
||||
12.0 * k,
|
||||
white(0.5),
|
||||
f64::from(rect.left) + w / 2.0,
|
||||
cy - card_h / 2.0 - 22.0 * k,
|
||||
w * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint order = draw order: farthest from the (integer) cursor first, so the
|
||||
// dense side stacks overlap toward the focus.
|
||||
let mut order: Vec<usize> = (0..self.games.len()).collect();
|
||||
@@ -326,21 +371,31 @@ impl LibraryScreen {
|
||||
}
|
||||
None => {
|
||||
// Solid face, not glass: the side cards OVERLAP.
|
||||
canvas.draw_rect(
|
||||
crect,
|
||||
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
|
||||
);
|
||||
let mono = initials(&game.title);
|
||||
let font = fonts.font(W::Bold, 38.0 * k);
|
||||
let tw = font.measure_str(&mono, None).0;
|
||||
//
|
||||
// A launcher tile usually has no poster, and an art-less launcher drawn like
|
||||
// an art-less game reads as "a game whose cover failed to load". So it gets
|
||||
// the brand-tinted face and names its launcher, instead of a title monogram.
|
||||
let face = if game.launcher {
|
||||
Color4f::new(0.153, 0.137, 0.267, 1.0)
|
||||
} else {
|
||||
Color4f::new(0.118, 0.118, 0.145, 1.0)
|
||||
};
|
||||
canvas.draw_rect(crect, &Paint::new(face, None));
|
||||
let (glyph, size, ink) = if game.launcher {
|
||||
(store_label(&game.store).to_string(), 22.0 * k, white(0.85))
|
||||
} else {
|
||||
(initials(&game.title), 38.0 * k, white(0.45))
|
||||
};
|
||||
let font = fonts.font(W::Bold, size);
|
||||
let tw = font.measure_str(&glyph, None).0;
|
||||
canvas.draw_str(
|
||||
&mono,
|
||||
&glyph,
|
||||
Point::new(
|
||||
(card_w as f32 - tw) / 2.0,
|
||||
card_h as f32 / 2.0 + 13.0 * k as f32,
|
||||
),
|
||||
&font,
|
||||
&Paint::new(white(0.45), None),
|
||||
&Paint::new(ink, None),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -351,13 +406,20 @@ impl LibraryScreen {
|
||||
let tw = fonts.measure(label, W::SemiBold, size) as f64;
|
||||
let (px, py) = (8.0 * k, 8.0 * k);
|
||||
let (bw, bh) = (tw + 16.0 * k, 20.0 * k);
|
||||
// Brand-filled for a launcher, smoked glass for a game — the one cue that
|
||||
// survives being three cards deep in the recede.
|
||||
let pill = if game.launcher {
|
||||
brand(0.85)
|
||||
} else {
|
||||
Color4f::new(0.0, 0.0, 0.0, 0.55)
|
||||
};
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(
|
||||
Rect::from_xywh(px as f32, py as f32, bw as f32, bh as f32),
|
||||
(bh / 2.0) as f32,
|
||||
(bh / 2.0) as f32,
|
||||
),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
|
||||
&Paint::new(pill, None),
|
||||
);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
@@ -395,9 +457,14 @@ impl LibraryScreen {
|
||||
f64::from(rect.bottom) - 64.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
let sub = if g.launcher {
|
||||
format!("{} · LAUNCHER", store_label(&g.store).to_uppercase())
|
||||
} else {
|
||||
store_label(&g.store).to_uppercase()
|
||||
};
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&store_label(&g.store).to_uppercase(),
|
||||
&sub,
|
||||
W::Regular,
|
||||
12.0 * k,
|
||||
white(0.5),
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
//! restyled as glass rows and fully controller-navigable (the Swift
|
||||
//! `GamepadSettingsView`, re-homed): up/down moves focus, left/right steps the focused
|
||||
//! value (clamped — the boundary thud tells the thumb it's the last option), A cycles
|
||||
//! forward wrapping, B closes. Every change persists immediately; the desktop shells
|
||||
//! read the same file, so values round-trip freely.
|
||||
//! forward wrapping, L1/R1 change SECTION, B closes. Every change persists immediately;
|
||||
//! the desktop shells read the same file, so values round-trip freely.
|
||||
//!
|
||||
//! The rows are split across tabs (see [`TABS`]). They used to be one 30-row scroll with
|
||||
//! inline headers, which on a Deck meant thumbing past Video and Audio to reach the pad
|
||||
//! settings; a tab is one shoulder press, and each tab remembers where its cursor was.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::screens::{Ctx, Outbox, Screen};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
@@ -51,6 +55,10 @@ enum RowId {
|
||||
Fullscreen,
|
||||
AutoWake,
|
||||
Library,
|
||||
/// The gamepad UI's background colour family — see [`crate::library::PALETTES`]. The
|
||||
/// backdrop behind this very row re-colours as it steps, which is the whole reason the
|
||||
/// picker lives on a screen rather than in a dialog.
|
||||
Palette,
|
||||
}
|
||||
|
||||
// The couch-relevant subset grew 2026-07-31: this screen is the ONLY settings editor in
|
||||
@@ -58,39 +66,77 @@ enum RowId {
|
||||
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
|
||||
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
|
||||
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
|
||||
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
|
||||
const ROWS: [RowId; 29] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::RenderScale,
|
||||
RowId::Bitrate,
|
||||
RowId::Compositor,
|
||||
RowId::Codec,
|
||||
RowId::Decoder,
|
||||
RowId::Hdr,
|
||||
RowId::Chroma444,
|
||||
RowId::PresentPriority,
|
||||
RowId::SmoothBuffer,
|
||||
RowId::Vsync,
|
||||
RowId::AllowVrr,
|
||||
RowId::Audio,
|
||||
RowId::Mic,
|
||||
RowId::EchoCancel,
|
||||
RowId::PadForward,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::SystemButtons,
|
||||
RowId::GuideGesture,
|
||||
RowId::Touch,
|
||||
RowId::Mouse,
|
||||
RowId::InvertScroll,
|
||||
RowId::Shortcuts,
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::Library,
|
||||
// trailing Profiles tab) but created and edited only in the desktop app (design §5.4).
|
||||
//
|
||||
// The tab names are shared with the Apple and Android gamepad settings, so a setting is
|
||||
// found under the same word on every client. Profiles is the trailing tab and is built
|
||||
// from the catalog at render time, which is why it carries no rows here.
|
||||
const TABS: [(&str, &[RowId]); 7] = [
|
||||
(
|
||||
"Stream",
|
||||
&[
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
RowId::RenderScale,
|
||||
RowId::Bitrate,
|
||||
RowId::Compositor,
|
||||
],
|
||||
),
|
||||
(
|
||||
"Video",
|
||||
&[
|
||||
RowId::Codec,
|
||||
RowId::Decoder,
|
||||
RowId::Hdr,
|
||||
RowId::Chroma444,
|
||||
RowId::PresentPriority,
|
||||
RowId::SmoothBuffer,
|
||||
RowId::Vsync,
|
||||
RowId::AllowVrr,
|
||||
],
|
||||
),
|
||||
("Audio", &[RowId::Audio, RowId::Mic, RowId::EchoCancel]),
|
||||
(
|
||||
"Controller",
|
||||
&[
|
||||
RowId::PadForward,
|
||||
RowId::Pad,
|
||||
RowId::PadType,
|
||||
RowId::SystemButtons,
|
||||
RowId::GuideGesture,
|
||||
],
|
||||
),
|
||||
(
|
||||
"Input",
|
||||
&[
|
||||
RowId::Touch,
|
||||
RowId::Mouse,
|
||||
RowId::InvertScroll,
|
||||
RowId::Shortcuts,
|
||||
],
|
||||
),
|
||||
(
|
||||
"Interface",
|
||||
&[
|
||||
RowId::Palette,
|
||||
RowId::Stats,
|
||||
RowId::Fullscreen,
|
||||
RowId::AutoWake,
|
||||
RowId::Library,
|
||||
],
|
||||
),
|
||||
("Profiles", &[]),
|
||||
];
|
||||
|
||||
/// The index of the trailing Profiles tab (built from the catalog, not from [`TABS`]).
|
||||
const PROFILES_TAB: usize = TABS.len() - 1;
|
||||
|
||||
/// How many sections the strip shows — for the shell's raster test, which walks all of them.
|
||||
/// `cfg(test)` because nothing in a shipping build needs the count: a plain `cargo build` would
|
||||
/// otherwise warn it dead, and this crate's lanes treat warnings as errors.
|
||||
#[cfg(test)]
|
||||
pub(crate) const TAB_COUNT: usize = TABS.len();
|
||||
|
||||
const RESOLUTIONS: [(u32, u32); 6] = [
|
||||
(0, 0), // native
|
||||
(1280, 720),
|
||||
@@ -169,6 +215,12 @@ const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), (
|
||||
|
||||
pub(crate) struct SettingsScreen {
|
||||
list: MenuList,
|
||||
strip: TabStrip,
|
||||
/// Which of [`TABS`] is showing.
|
||||
tab: usize,
|
||||
/// Where each tab's cursor was when it was last left. Coming back to Controller after a
|
||||
/// detour through Video should land where you were, not at the top.
|
||||
tab_cursors: [usize; TABS.len()],
|
||||
/// The profile catalog's `(id, name)` pairs, loaded once at construction — the console
|
||||
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
|
||||
/// for the screen's lifetime.
|
||||
@@ -189,20 +241,37 @@ impl SettingsScreen {
|
||||
fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen {
|
||||
SettingsScreen {
|
||||
list: MenuList::new(),
|
||||
strip: TabStrip::new(),
|
||||
tab: 0,
|
||||
tab_cursors: [0; TABS.len()],
|
||||
profiles,
|
||||
}
|
||||
}
|
||||
|
||||
/// The full row list: the fixed settings rows, then the Profiles section — one row
|
||||
/// per catalog profile, or the explainer placeholder while there are none.
|
||||
/// The rows of the CURRENT tab. Profiles is built from the catalog: one row per
|
||||
/// profile, or the explainer placeholder while there are none.
|
||||
fn row_ids(&self) -> Vec<RowId> {
|
||||
let mut ids = ROWS.to_vec();
|
||||
if self.profiles.is_empty() {
|
||||
ids.push(RowId::NoProfiles);
|
||||
} else {
|
||||
ids.extend((0..self.profiles.len()).map(RowId::Profile));
|
||||
if self.tab != PROFILES_TAB {
|
||||
return TABS[self.tab].1.to_vec();
|
||||
}
|
||||
ids
|
||||
if self.profiles.is_empty() {
|
||||
vec![RowId::NoProfiles]
|
||||
} else {
|
||||
(0..self.profiles.len()).map(RowId::Profile).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// L1/R1 — move one tab, wrapping (the strip is a ring, like A's value cycle), keeping
|
||||
/// each tab's own cursor.
|
||||
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
|
||||
self.tab_cursors[self.tab] = self.list.cursor;
|
||||
let n = TABS.len() as i32;
|
||||
self.tab = (self.tab as i32 + delta).rem_euclid(n) as usize;
|
||||
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
|
||||
let len = self.row_ids().len();
|
||||
self.list
|
||||
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
@@ -211,9 +280,14 @@ impl SettingsScreen {
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
return None;
|
||||
match ev {
|
||||
MenuEvent::Back => {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
MenuEvent::JumpBack => return self.switch_tab(-1),
|
||||
MenuEvent::JumpForward => return self.switch_tab(1),
|
||||
_ => {}
|
||||
}
|
||||
let ids = self.row_ids();
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
@@ -271,18 +345,22 @@ impl SettingsScreen {
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
match self.row_ids()[self.list.cursor] {
|
||||
RowId::Profile(_) => vec![
|
||||
let ids = self.row_ids();
|
||||
// The shoulders always change section, so that hint leads on every row.
|
||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||
hints.extend(match ids.get(self.list.cursor) {
|
||||
Some(RowId::Profile(_)) => vec![
|
||||
Hint::new(HintKey::Confirm, "Pin to hosts…"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")],
|
||||
_ => vec![
|
||||
Some(RowId::NoProfiles) | None => vec![Hint::new(HintKey::Back, "Done")],
|
||||
Some(_) => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
}
|
||||
});
|
||||
hints
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
@@ -294,11 +372,23 @@ impl SettingsScreen {
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
// The focused row's explainer sits in a reserved band under the list.
|
||||
// The tab strip takes the top band, the focused row's explainer a reserved band
|
||||
// under the list; the rows get what's between.
|
||||
let detail_h = 34.0 * k;
|
||||
let strip_h = TAB_STRIP_H * k;
|
||||
let labels: Vec<&str> = TABS.iter().map(|(name, _)| *name).collect();
|
||||
self.strip.render(
|
||||
canvas,
|
||||
Rect::from_ltrb(rect.left, rect.top, rect.right, rect.top + strip_h as f32),
|
||||
&labels,
|
||||
self.tab,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
);
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.top + strip_h as f32,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
@@ -309,7 +399,7 @@ impl SettingsScreen {
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
let detail = detail(ids[self.list.cursor]);
|
||||
let detail = ids.get(self.list.cursor).copied().map_or("", detail);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
detail,
|
||||
@@ -335,7 +425,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
.filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid))
|
||||
.count();
|
||||
return RowSpec {
|
||||
header: (i == 0).then_some("Profiles"),
|
||||
header: None,
|
||||
label: name.clone(),
|
||||
value: Some(match pins {
|
||||
0 => "Not pinned".into(),
|
||||
@@ -349,9 +439,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
};
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
let mut row = RowSpec::action("No profiles yet", false);
|
||||
row.header = Some("Profiles");
|
||||
return row;
|
||||
return RowSpec::action("No profiles yet", false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -372,7 +460,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
};
|
||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||
RowId::Resolution => (
|
||||
Some("Stream"),
|
||||
None,
|
||||
"Resolution",
|
||||
if s.match_window {
|
||||
"Match window".into()
|
||||
@@ -416,11 +504,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Compositor",
|
||||
label_for(&COMPOSITORS, &s.compositor).into(),
|
||||
),
|
||||
RowId::Codec => (
|
||||
Some("Video"),
|
||||
"Video codec",
|
||||
label_for(&CODECS, &s.codec).into(),
|
||||
),
|
||||
RowId::Codec => (None, "Video codec", label_for(&CODECS, &s.codec).into()),
|
||||
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
|
||||
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
|
||||
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
|
||||
@@ -441,7 +525,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()),
|
||||
RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()),
|
||||
RowId::Audio => (
|
||||
Some("Audio"),
|
||||
None,
|
||||
"Audio channels",
|
||||
AUDIO
|
||||
.iter()
|
||||
@@ -452,7 +536,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
|
||||
RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()),
|
||||
RowId::PadForward => (
|
||||
Some("Controller"),
|
||||
None,
|
||||
"Forward controllers",
|
||||
on_off(s.gamepad_forwarding).into(),
|
||||
),
|
||||
@@ -483,11 +567,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Hold Select for guide",
|
||||
label_for(&GUIDE_GESTURE, &s.guide_gesture).into(),
|
||||
),
|
||||
RowId::Touch => (
|
||||
Some("Touchscreen"),
|
||||
"Touch mode",
|
||||
s.touch_mode().label().into(),
|
||||
),
|
||||
RowId::Touch => (None, "Touch mode", s.touch_mode().label().into()),
|
||||
RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()),
|
||||
RowId::InvertScroll => (None, "Invert scroll", on_off(s.invert_scroll).into()),
|
||||
RowId::Shortcuts => (
|
||||
@@ -495,8 +575,13 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
"Capture system shortcuts",
|
||||
on_off(s.inhibit_shortcuts).into(),
|
||||
),
|
||||
RowId::Palette => (
|
||||
None,
|
||||
"Background",
|
||||
crate::library::palette(&s.ui_palette).name.into(),
|
||||
),
|
||||
RowId::Stats => (
|
||||
Some("Interface"),
|
||||
None,
|
||||
"Statistics overlay",
|
||||
s.stats_verbosity().label().into(),
|
||||
),
|
||||
@@ -603,6 +688,10 @@ fn detail(id: RowId) -> &'static str {
|
||||
"Alt+Tab, Super and friends reach the host while input is captured. \
|
||||
Off, they act on this device instead."
|
||||
}
|
||||
RowId::Palette => {
|
||||
"The colour family this backdrop drifts through — it changes as you step, so \
|
||||
pick by looking. Appearance only; nothing about a stream depends on it."
|
||||
}
|
||||
RowId::Stats => {
|
||||
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||
@@ -766,6 +855,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
step_option(cur, StatsVerbosity::ALL.len(), delta, wrap)
|
||||
.map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i]))
|
||||
}
|
||||
RowId::Palette => {
|
||||
let all = &crate::library::PALETTES;
|
||||
let cur = all.iter().position(|p| p.id == s.ui_palette);
|
||||
step_option(cur, all.len(), delta, wrap).map(|i| s.ui_palette = all[i].id.to_string())
|
||||
}
|
||||
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
|
||||
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
|
||||
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
|
||||
@@ -1071,19 +1165,18 @@ mod tests {
|
||||
("p1".into(), "Work".into()),
|
||||
("p2".into(), "Game".into()),
|
||||
]);
|
||||
s.tab = PROFILES_TAB;
|
||||
let ids = s.row_ids();
|
||||
assert_eq!(ids.len(), ROWS.len() + 2);
|
||||
assert_eq!(ids[ROWS.len()], RowId::Profile(0));
|
||||
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
|
||||
|
||||
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, Some("Profiles"));
|
||||
assert_eq!(spec.header, None, "the tab pill names the section");
|
||||
assert_eq!(spec.label, "Work");
|
||||
assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host"));
|
||||
let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, None, "only the first row carries the header");
|
||||
assert_eq!(spec.value.as_deref(), Some("Not pinned"));
|
||||
|
||||
s.list.cursor = ROWS.len(); // onto "Work"
|
||||
s.list.cursor = 0; // onto "Work"
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(
|
||||
@@ -1118,10 +1211,10 @@ mod tests {
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
s.tab = PROFILES_TAB;
|
||||
let ids = s.row_ids();
|
||||
assert_eq!(*ids.last().unwrap(), RowId::NoProfiles);
|
||||
assert_eq!(ids, vec![RowId::NoProfiles]);
|
||||
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, Some("Profiles"));
|
||||
assert!(!spec.enabled);
|
||||
|
||||
s.list.cursor = ids.len() - 1;
|
||||
@@ -1130,4 +1223,103 @@ mod tests {
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
assert!(fx.nav.is_none());
|
||||
}
|
||||
|
||||
/// Every row the screen knows about must live in exactly one tab — a row missing from
|
||||
/// [`TABS`] is a setting that became unreachable in Gaming Mode, which is precisely
|
||||
/// what this screen exists to prevent.
|
||||
#[test]
|
||||
fn every_row_has_exactly_one_tab() {
|
||||
let mut seen: Vec<RowId> = Vec::new();
|
||||
for (_, rows) in &TABS {
|
||||
for id in *rows {
|
||||
assert!(!seen.contains(id), "{id:?} is in two tabs");
|
||||
seen.push(*id);
|
||||
}
|
||||
}
|
||||
// The pre-tab flat list, plus the palette row this change added.
|
||||
assert_eq!(seen.len(), 30, "{seen:?}");
|
||||
assert!(seen.contains(&RowId::Palette));
|
||||
// The catalog rows belong to the trailing tab, which builds them at render time.
|
||||
assert!(TABS[PROFILES_TAB].1.is_empty());
|
||||
assert_eq!(TABS[PROFILES_TAB].0, "Profiles");
|
||||
}
|
||||
|
||||
/// L1/R1 wrap around the strip and each tab keeps its own cursor, so a detour into
|
||||
/// another section doesn't lose your place.
|
||||
#[test]
|
||||
fn shoulders_cycle_tabs_and_keep_each_cursor() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
let mut fx = Outbox::default();
|
||||
assert_eq!(s.tab, 0);
|
||||
s.list.cursor = 3; // "Bitrate", in Stream
|
||||
s.menu(MenuEvent::JumpForward, &mut ctx, &mut fx);
|
||||
assert_eq!(s.tab, 1);
|
||||
assert_eq!(s.list.cursor, 0, "a fresh tab starts at its first row");
|
||||
s.list.cursor = 2; // "10-bit HDR", in Video
|
||||
s.menu(MenuEvent::JumpBack, &mut ctx, &mut fx);
|
||||
assert_eq!((s.tab, s.list.cursor), (0, 3), "Stream kept its place");
|
||||
// Backwards off the first tab wraps to the last…
|
||||
s.menu(MenuEvent::JumpBack, &mut ctx, &mut fx);
|
||||
assert_eq!(s.tab, PROFILES_TAB);
|
||||
// …whose (catalog-built) length clamps a remembered cursor that no longer fits.
|
||||
assert_eq!(s.list.cursor, 0);
|
||||
s.menu(MenuEvent::JumpForward, &mut ctx, &mut fx);
|
||||
assert_eq!(s.tab, 0);
|
||||
// Switching sections is navigation, never a settings write.
|
||||
assert!(fx.nav.is_none() && fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
/// The palette row steps the shared `ui_palette` key through the table and wraps on A,
|
||||
/// like every other choice row.
|
||||
#[test]
|
||||
fn palette_row_steps_the_shared_key() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert_eq!(ctx.settings.ui_palette, "violet", "the brand default ships");
|
||||
assert_eq!(
|
||||
row_spec(RowId::Palette, &ctx, &[]).value.as_deref(),
|
||||
Some("Violet")
|
||||
);
|
||||
assert!(
|
||||
!adjust(RowId::Palette, -1, false, &mut ctx),
|
||||
"already the first = thud"
|
||||
);
|
||||
assert!(adjust(RowId::Palette, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.ui_palette, crate::library::PALETTES[1].id);
|
||||
// A from the last entry wraps home.
|
||||
ctx.settings.ui_palette = crate::library::PALETTES
|
||||
.last()
|
||||
.expect("non-empty")
|
||||
.id
|
||||
.to_string();
|
||||
assert!(adjust(RowId::Palette, 1, true, &mut ctx));
|
||||
assert_eq!(ctx.settings.ui_palette, "violet");
|
||||
// A store written by a newer client shows that client's value, not a blank row.
|
||||
ctx.settings.ui_palette = "chartreuse".into();
|
||||
assert_eq!(
|
||||
row_spec(RowId::Palette, &ctx, &[]).value.as_deref(),
|
||||
Some("Violet"),
|
||||
"an unknown palette reads as the default it actually draws"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use crate::anim::Progress;
|
||||
use crate::glyphs::GlyphStyle;
|
||||
use crate::library::{mesh_sksl, LibraryShared};
|
||||
use crate::library::{mesh_sksl, palette, LibraryShared};
|
||||
use crate::model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
||||
use crate::screens::{Bg, ConnectIntent, Ctx, Nav, Outbox, Screen};
|
||||
use anyhow::{anyhow, Result};
|
||||
@@ -81,7 +81,17 @@ pub(crate) struct Shell {
|
||||
wake_optimistic: bool,
|
||||
toast: Option<Toast>,
|
||||
mesh: RuntimeEffect,
|
||||
/// 0 = aurora, 1 = form — chased, so backdrops crossfade with the transition.
|
||||
/// The `ui_palette` the compiled `mesh` bakes. The settings screen can change the palette
|
||||
/// mid-frame-loop, so [`Self::sync`] recompiles when this falls out of step — the backdrop
|
||||
/// re-colours under the cursor as the row is stepped, which is the whole point of putting
|
||||
/// the picker on a screen the backdrop is behind.
|
||||
mesh_palette: String,
|
||||
/// The palette's corner colour × 0.4 — the calm lift, precomputed with `mesh`. Chosen so
|
||||
/// `col*0.6 + lift` leaves a corner EXACTLY where it was and pulls the bright pools down
|
||||
/// to it: the form screens lose the launcher's contrast, not its colour.
|
||||
mesh_lift: [f32; 3],
|
||||
/// 0 = launcher aurora, 1 = the calm form field — chased, so the backdrop settles into
|
||||
/// (or out of) calm alongside the screen transition.
|
||||
bg_mix: f64,
|
||||
glyphs: GlyphStyle,
|
||||
chip: Option<String>,
|
||||
@@ -99,8 +109,8 @@ impl Shell {
|
||||
stack: Vec<Screen>,
|
||||
) -> Result<Shell> {
|
||||
anyhow::ensure!(!stack.is_empty(), "the console needs a root screen");
|
||||
let mesh = RuntimeEffect::make_for_shader(mesh_sksl(), None)
|
||||
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
|
||||
let settings = trust::Settings::load();
|
||||
let (mesh, mesh_lift) = build_mesh(&settings.ui_palette)?;
|
||||
let bg_mix = match stack.last().expect("non-empty").background() {
|
||||
Bg::Aurora => 0.0,
|
||||
Bg::Form => 1.0,
|
||||
@@ -112,7 +122,8 @@ impl Shell {
|
||||
library,
|
||||
bus,
|
||||
actions: VecDeque::new(),
|
||||
settings: trust::Settings::load(),
|
||||
mesh_palette: settings.ui_palette.clone(),
|
||||
settings,
|
||||
hosts: Vec::new(),
|
||||
hosts_gen: u64::MAX,
|
||||
device_name: opts.device_name,
|
||||
@@ -123,6 +134,7 @@ impl Shell {
|
||||
wake_optimistic: false,
|
||||
toast: None,
|
||||
mesh,
|
||||
mesh_lift,
|
||||
bg_mix,
|
||||
glyphs: GlyphStyle::Keyboard,
|
||||
chip: None,
|
||||
@@ -188,6 +200,26 @@ impl Shell {
|
||||
// --- Model sync (hosts, pairing, wake) — before input and before render --------------
|
||||
|
||||
fn sync(&mut self) {
|
||||
// The settings screen writes `ui_palette` straight into `self.settings`; recompiling
|
||||
// here is what makes the backdrop re-colour live under the row being stepped. A
|
||||
// rejected compile keeps the palette that IS drawing — the field never goes black
|
||||
// because someone picked a colour.
|
||||
if self.settings.ui_palette != self.mesh_palette {
|
||||
match build_mesh(&self.settings.ui_palette) {
|
||||
Ok((mesh, lift)) => {
|
||||
self.mesh = mesh;
|
||||
self.mesh_lift = lift;
|
||||
self.mesh_palette = self.settings.ui_palette.clone();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"console: {} palette rejected: {e}",
|
||||
self.settings.ui_palette
|
||||
);
|
||||
self.mesh_palette = self.settings.ui_palette.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.console.hosts_gen() != self.hosts_gen {
|
||||
(self.hosts, self.hosts_gen) = self.console.hosts_snapshot();
|
||||
}
|
||||
@@ -432,12 +464,25 @@ impl Shell {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64) {
|
||||
let uniforms: [f32; 3] = [w as f32, h as f32, t as f32];
|
||||
// SAFETY: `uniforms` is a local `[f32; 3]` — exactly 12 bytes — and `f32` has no padding or
|
||||
/// The living backdrop. `calm` 0 = the launcher's aurora, 1 = the quiet field the form
|
||||
/// screens sit on; the shell chases it, so there is only ever ONE backdrop pass — the
|
||||
/// former aurora-over-static-form crossfade is now a single uniform.
|
||||
fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64, calm: f64) {
|
||||
// Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4).
|
||||
let uniforms: [f32; 8] = [
|
||||
w as f32,
|
||||
h as f32,
|
||||
t as f32,
|
||||
calm as f32,
|
||||
self.mesh_lift[0],
|
||||
self.mesh_lift[1],
|
||||
self.mesh_lift[2],
|
||||
0.0,
|
||||
];
|
||||
// SAFETY: `uniforms` is a local `[f32; 8]` — exactly 32 bytes — and `f32` has no padding or
|
||||
// invalid bit patterns, so reading it as bytes is sound; the slice is copied by
|
||||
// `Data::new_copy` before `uniforms` goes out of scope.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 12) };
|
||||
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 32) };
|
||||
match self.mesh.make_shader(Data::new_copy(bytes), &[], None) {
|
||||
Some(shader) => {
|
||||
let mut paint = Paint::default();
|
||||
@@ -451,5 +496,30 @@ impl Shell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile the mesh shader for a palette, returning it with its precomputed calm lift.
|
||||
/// `uniform_size` is checked rather than assumed: the byte buffer [`Shell::draw_aurora`]
|
||||
/// hands Skia is hand-packed, and a silent layout change would feed the field garbage
|
||||
/// instead of failing.
|
||||
fn build_mesh(palette_id: &str) -> Result<(RuntimeEffect, [f32; 3])> {
|
||||
let p = palette(palette_id);
|
||||
let colors = p.mesh_colors();
|
||||
let effect = RuntimeEffect::make_for_shader(mesh_sksl(&colors), None)
|
||||
.map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?;
|
||||
anyhow::ensure!(
|
||||
effect.uniform_size() == 32,
|
||||
"mesh uniform block is {} bytes, expected 32 (u_res, u_tc, u_lift)",
|
||||
effect.uniform_size()
|
||||
);
|
||||
let corner = colors[0];
|
||||
Ok((
|
||||
effect,
|
||||
[
|
||||
(corner.0 * 0.4) as f32,
|
||||
(corner.1 * 0.4) as f32,
|
||||
(corner.2 * 0.4) as f32,
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -166,7 +166,7 @@ impl Shell {
|
||||
canvas.save_layer_alpha_f(None, appear as f32);
|
||||
// Opaque aurora — the same living backdrop the home wears, so the takeover reads as the
|
||||
// console taking over rather than a card popping up.
|
||||
self.draw_aurora(canvas, w, h, t);
|
||||
self.draw_aurora(canvas, w, h, t, 0.0);
|
||||
// A soft pool of shade under the centre seats the white text against a bright aurora.
|
||||
let mut vignette = Paint::default();
|
||||
vignette.set_shader(gradient_shader::radial(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user