Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d715225960 | ||
|
|
e1198495e0 | ||
|
|
6336e16350 | ||
|
|
f62a48d4a9 | ||
|
|
c817e4bec0 | ||
|
|
ad806be70a | ||
|
|
bde4276632 | ||
|
|
147bc82130 | ||
|
|
30c47eb691 | ||
|
|
bd26949e0a | ||
|
|
61dfc3dadc | ||
|
|
1befa8a2c4 | ||
|
|
f8cde0adaf | ||
|
|
e93947969f | ||
|
|
159bbdbfc2 | ||
|
|
002702bcec | ||
|
|
88c1e94d94 | ||
|
|
657e82cd29 | ||
|
|
e283f17ab4 | ||
|
|
1fc184516a | ||
|
|
7a8f63e906 | ||
|
|
5ca0dfdcd2 | ||
|
|
a14b000c9b | ||
|
|
35b5ee6a36 | ||
|
|
548eb4fa14 | ||
|
|
84faeb1bf1 | ||
|
|
ad63994cb9 | ||
|
|
bac63059a9 | ||
|
|
3daead7d71 | ||
|
|
18d0009c35 | ||
|
|
8ef5350431 | ||
|
|
522ac7bd49 | ||
|
|
8ab4918923 | ||
|
|
09bac99090 | ||
|
|
5db3b3c4fd | ||
|
|
a23c028492 | ||
|
|
5b3ea6e8db | ||
|
|
ffb1ecfebe | ||
|
|
08e462fee9 | ||
|
|
2590238b8f | ||
|
|
beb639f067 | ||
|
|
90450ff1f6 | ||
|
|
890b67a863 | ||
|
|
9cdbfabd4d | ||
|
|
fdef4c90ce | ||
|
|
8508f8f3c3 | ||
|
|
6695300b67 | ||
|
|
73d435b967 | ||
|
|
c7df7b45af | ||
|
|
5d7091bf87 | ||
|
|
e19f11bb0d | ||
|
|
7f1f7ba87c | ||
|
|
d39843a858 | ||
|
|
fb309e0262 | ||
|
|
94c2f62490 | ||
|
|
2b1843ed1c | ||
|
|
4f9071b980 | ||
|
|
1317901122 | ||
|
|
620f017d9a | ||
|
|
854b14a52e | ||
|
|
bd5735b803 | ||
|
|
7a9fa4501c | ||
|
|
d87a8df28d | ||
|
|
46390739d8 | ||
|
|
77f0a25d18 | ||
|
|
3500e95660 | ||
|
|
f9fe496dbc | ||
|
|
13438b1287 | ||
|
|
ae35e8b4d7 | ||
|
|
f34acf1d73 | ||
|
|
bc9201d136 | ||
|
|
003ce8bea7 | ||
|
|
235b8e55d4 | ||
|
|
d2a2bcc25d | ||
|
|
0b252403cd | ||
|
|
0ab17ee81d | ||
|
|
4e04c2bbf8 | ||
|
|
31aef4b09f | ||
|
|
97928516a0 | ||
|
|
d498ff4a60 | ||
|
|
bbc0513f0c | ||
|
|
d13d253c2f | ||
|
|
2de604ecab | ||
|
|
f26d21125d | ||
|
|
4f8cce6751 | ||
|
|
4a4118e3ce | ||
|
|
dcfba07803 | ||
|
|
99f2130b28 | ||
|
|
f266636392 | ||
|
|
516a295432 | ||
|
|
2c190b27b4 | ||
|
|
3cfa5ca194 | ||
|
|
bf913c5706 | ||
|
|
5bd92dac5d | ||
|
|
e8a4f54c07 | ||
|
|
f80636f901 | ||
|
|
0f79587dd6 | ||
|
|
651a7a82a1 | ||
|
|
4d383811c0 | ||
|
|
42ee6c5628 | ||
|
|
08eaf337e8 | ||
|
|
39869031be | ||
|
|
55f361cb92 |
@@ -280,6 +280,21 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
|
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
|
||||||
|
|
||||||
|
# 0.26.0-1 setcap'd `cap_sys_nice=ep` on the host from this package's .INSTALL scriptlet and
|
||||||
|
# killed desktop streaming on every KDE box — with a green board, because nothing here ever
|
||||||
|
# looked at what the built package would DO. The lesson recorded then was "verify the
|
||||||
|
# PACKAGE, never the board"; this is that, and pacman is the channel where it matters most,
|
||||||
|
# since capabilities live in the scriptlet rather than in package metadata.
|
||||||
|
#
|
||||||
|
# Host must carry NOTHING, the worker exactly cap_sys_nice=ep. `--self-test` runs first so a
|
||||||
|
# guard that has quietly lost the ability to fail takes the job down rather than approving a
|
||||||
|
# release. (Only the host package is checked: the client/web/scripting packages ship neither
|
||||||
|
# binary and the script skips them by itself.)
|
||||||
|
- name: Assert the capability matrix (Arch package)
|
||||||
|
run: |
|
||||||
|
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||||
|
bash scripts/ci/assert-cap-matrix.sh "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst
|
||||||
|
|
||||||
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
|
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
|
||||||
# completely different dependency set, published into the same repo so `pacman -S
|
# completely different dependency set, published into the same repo so `pacman -S
|
||||||
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
|
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
|
||||||
|
|||||||
@@ -91,8 +91,27 @@ jobs:
|
|||||||
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
|
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
|
||||||
# bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
|
# bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
|
||||||
# noting why here).
|
# noting why here).
|
||||||
|
#
|
||||||
|
# web carries two ignores, the ONLY ones in a blocking tree — both image-size advisories
|
||||||
|
# (GHSA-w3rx-r6r6-pgpr ICNS, GHSA-5p2g-fcmc-qvqq JXL/HEIF infinite-loop DoS). They are
|
||||||
|
# unfixable AND unreachable:
|
||||||
|
# * unfixable — the vulnerable range is `<= 2.0.2` and 2.0.2 IS latest; upstream has
|
||||||
|
# published no patched release, so no override can clear them.
|
||||||
|
# * unreachable — image-size rides in under `@unom/ui › @payloadcms/richtext-lexical ›
|
||||||
|
# … › payload`, and @payloadcms/richtext-lexical is a PEER of @unom/ui that only its
|
||||||
|
# `./richtext` export needs. The console imports section/toast/button/card/dialog/
|
||||||
|
# form/*/material/tabs — never `./richtext` — so payload is auto-installed peer weight
|
||||||
|
# that no bundle, and no request path, ever touches.
|
||||||
|
# Drop these the moment image-size ships a fix, or @unom/ui marks that peer optional
|
||||||
|
# (peerDependenciesMeta) and the chain leaves web/bun.lock entirely — either one makes the
|
||||||
|
# bare `bun audit` green again. Scoped per-tree so sdk/plugin-kit stay strictly fail-on-any.
|
||||||
- name: bun audit
|
- name: bun audit
|
||||||
run: bun audit
|
run: |
|
||||||
|
if [ "${{ matrix.tree }}" = "web" ]; then
|
||||||
|
bun audit --ignore=GHSA-w3rx-r6r6-pgpr --ignore=GHSA-5p2g-fcmc-qvqq
|
||||||
|
else
|
||||||
|
bun audit
|
||||||
|
fi
|
||||||
|
|
||||||
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
|
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
|
||||||
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
|
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
|
||||||
|
|||||||
@@ -310,8 +310,14 @@ jobs:
|
|||||||
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
|
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
|
||||||
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
|
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
|
||||||
# Arch packages — which already split it — were fine.
|
# Arch packages — which already split it — were fine.
|
||||||
|
#
|
||||||
|
# punktfunk-encode-worker IS in this invocation: it is the capability-carrying PyroWave
|
||||||
|
# encode worker that ships next to the host in /usr/bin, and build-deb.sh only builds it
|
||||||
|
# if the artifact is missing — building it here keeps it on the same sccache pass as the
|
||||||
|
# host. Unlike the tray it shares the host's dependency graph by design (v1 accepts that
|
||||||
|
# the worker links the same FFmpeg), so feature unification here is harmless.
|
||||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||||
-p punktfunk-host
|
-p punktfunk-host -p punktfunk-encode-worker
|
||||||
|
|
||||||
- name: Build host .deb (FFmpeg bundled)
|
- name: Build host .deb (FFmpeg bundled)
|
||||||
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
||||||
@@ -320,6 +326,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||||
|
|
||||||
|
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
|
||||||
|
# capability metadata — the postinst applies them — so this reads the postinst that will
|
||||||
|
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
|
||||||
|
# from exactly that postinst and killed every KDE desktop session while every board stayed
|
||||||
|
# green: host must carry NOTHING, worker exactly cap_sys_nice=ep. `--self-test` first so a
|
||||||
|
# guard that can no longer fail takes the job down instead of waving the release through.
|
||||||
|
- name: Assert the capability matrix (host .deb)
|
||||||
|
run: |
|
||||||
|
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||||
|
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
|
||||||
|
|
||||||
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
||||||
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
||||||
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
||||||
@@ -344,10 +361,45 @@ jobs:
|
|||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
|
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
|
||||||
apt-get build-dep -y gamescope || true
|
apt-get build-dep -y gamescope || true
|
||||||
|
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
|
||||||
|
# gamescope — where noble has one at all — so it misses what the master tree needs, and
|
||||||
|
# wayland-protocols is the gap that actually stops the build: meson dies in
|
||||||
|
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
|
||||||
|
# file was found", because the tree has no wrap fallback for it. That is what happened on
|
||||||
|
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
|
||||||
|
# shipped with no gamescope .deb while the notes said it had one.
|
||||||
|
apt-get install -y --no-install-recommends wayland-protocols
|
||||||
|
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
|
||||||
|
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
|
||||||
|
# releases should not fail the job. (No libstdc++ static package is needed here — g++
|
||||||
|
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
|
||||||
|
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
|
||||||
|
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
|
||||||
|
# purpose: a single transaction aborts wholesale on one unknown package, which would
|
||||||
|
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
|
||||||
|
# the missing one named; the end-of-job gate below is what actually decides.
|
||||||
|
for p in libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||||
|
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||||
|
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||||
|
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||||
|
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||||
|
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
|
||||||
|
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
|
||||||
|
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
|
||||||
|
cmake xwayland; do
|
||||||
|
apt-get install -y --no-install-recommends "$p" \
|
||||||
|
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
|
||||||
|
done
|
||||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||||
else
|
else
|
||||||
|
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
|
||||||
|
# skips the host .deb's own publish + release-attach steps below, which is how the
|
||||||
|
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
|
||||||
|
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
|
||||||
|
# meant to protect the release. Never let a missing EXTRA stop a good artifact
|
||||||
|
# shipping; go red afterwards instead.
|
||||||
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
|
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -357,6 +409,7 @@ jobs:
|
|||||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||||
else
|
else
|
||||||
|
# Warn only — see the note on the build step. The gate is the last step of this job.
|
||||||
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
|
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -387,6 +440,26 @@ jobs:
|
|||||||
upsert_asset "$RID" "$DEB"
|
upsert_asset "$RID" "$DEB"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
|
||||||
|
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
|
||||||
|
# because every failure on this path was a `::warning::` that returned 0.
|
||||||
|
#
|
||||||
|
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
|
||||||
|
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
|
||||||
|
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
|
||||||
|
# gate protecting the release withheld the fix for it. Everything good ships first; the job
|
||||||
|
# goes red afterwards.
|
||||||
|
- name: A stable tag must ship the gamescope .deb
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
built=(dist/punktfunk-gamescope_*.deb)
|
||||||
|
if [ ${#built[@]} -eq 0 ]; then
|
||||||
|
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "gamescope .deb present: ${built[*]}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------------
|
||||||
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
||||||
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
|
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
|
||||||
|
|||||||
@@ -7,10 +7,24 @@
|
|||||||
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||||
# run for an hour on every push:
|
# run for an hour on every push:
|
||||||
#
|
#
|
||||||
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
|
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
|
||||||
# the NixOS module without building them. Catches the failures that actually happen to
|
# without building them. Catches the failures that actually happen to this flake — a
|
||||||
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
|
# renamed file, a callPackage argument that no longer exists, a syntax error, a package
|
||||||
# error, a package attribute dropped from packages.nix.
|
# attribute dropped from packages.nix.
|
||||||
|
#
|
||||||
|
# ⚠ It does NOT, on its own, check the NixOS module. `nix flake check` handles
|
||||||
|
# `nixosModules` by forcing the value and asserting it is a lambda taking an open
|
||||||
|
# attribute set — nothing more (nix's own source: `// FIXME: if we have a 'nixpkgs'
|
||||||
|
# input, use it to check the module.`). MEASURED: a module setting a nonexistent
|
||||||
|
# OPTION, referencing a nonexistent `pkgs` attribute AND calling a nonexistent `lib`
|
||||||
|
# function passes clean, printing `checking NixOS module ... all checks passed!`. This
|
||||||
|
# header used to claim the module was covered here; it was not, for the module's whole
|
||||||
|
# life. It is covered NOW because `checks.<system>.nixos-module`
|
||||||
|
# (packaging/nix/module-check.nix) evaluates it against real nixpkgs and asserts on the
|
||||||
|
# rendered systemd units — and because those assertions are pure Nix, INSTANTIATING
|
||||||
|
# that check runs them, so `--no-build` is enough. Keep them pure: a shell script in
|
||||||
|
# the derivation body would only run under a full `nix flake check`, which builds the
|
||||||
|
# hour-long Rust packages.
|
||||||
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
|
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
|
||||||
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
|
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
|
||||||
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
|
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
|
||||||
@@ -22,6 +36,12 @@
|
|||||||
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
|
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
|
||||||
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
|
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
|
||||||
#
|
#
|
||||||
|
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
|
||||||
|
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
|
||||||
|
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
|
||||||
|
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
|
||||||
|
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
|
||||||
|
#
|
||||||
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
|
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
|
||||||
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
|
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
|
||||||
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
|
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
|
||||||
@@ -66,6 +86,10 @@ on:
|
|||||||
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
|
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
build-gamescope:
|
||||||
|
description: "Also build punktfunk-gamescope (patched gamescope from source; run after a flake.lock bump)"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
flake:
|
flake:
|
||||||
@@ -165,3 +189,13 @@ jobs:
|
|||||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||||
run: |
|
run: |
|
||||||
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||||
|
|
||||||
|
# The patched compositor. Separate from build-rust because its failure mode is different: it
|
||||||
|
# tracks nixpkgs' gamescope, not our Rust, so it wants a run after a flake.lock bump rather
|
||||||
|
# than after a code change. `gamescope.nix` fails loudly (an eval-time `throw` if nixpkgs no
|
||||||
|
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
|
||||||
|
# something actually builds it.
|
||||||
|
- name: Build the patched gamescope (dispatch opt-in)
|
||||||
|
if: ${{ github.event.inputs.build-gamescope == 'true' }}
|
||||||
|
run: |
|
||||||
|
"$NIX" build --print-build-logs .#punktfunk-gamescope
|
||||||
|
|||||||
@@ -103,7 +103,11 @@ jobs:
|
|||||||
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
|
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
|
||||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
# libcap = setcap/getcap: the sysext is the ONLY place the image can acquire
|
||||||
|
# cap_sys_nice=ep on punktfunk-encode-worker (a merged /usr is read-only squashfs and no
|
||||||
|
# scriptlet ever runs), and it is also what the build's host-must-be-uncapped assertion
|
||||||
|
# and the capability-matrix CI leg read with. Without it the image ships the lever inert.
|
||||||
|
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted libcap
|
||||||
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
|
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
|
||||||
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
|
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
|
||||||
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
|
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
|
||||||
@@ -155,6 +159,20 @@ jobs:
|
|||||||
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
|
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
|
||||||
run: bash packaging/rpm/sign-rpms.sh
|
run: bash packaging/rpm/sign-rpms.sh
|
||||||
|
|
||||||
|
# Read the file-capability matrix out of the BUILT rpm, before anything is signed or
|
||||||
|
# published. 0.26.0-1 shipped `%caps(cap_sys_nice=ep)` on the host through this very spec —
|
||||||
|
# on Fedora and, via rpm-ostree layering, on Bazzite — and every board was green while every
|
||||||
|
# KDE desktop session died in the field. The lesson recorded then was "verify the PACKAGE,
|
||||||
|
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
|
||||||
|
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
|
||||||
|
# fail takes the job down instead of waving the release through.
|
||||||
|
- name: Assert the capability matrix (rpm)
|
||||||
|
run: |
|
||||||
|
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||||
|
# Only the main host package carries binaries; -debuginfo/-debugsource and the
|
||||||
|
# client/web/scripting subpackages ship neither and are skipped by the script itself.
|
||||||
|
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-[0-9]*.rpm
|
||||||
|
|
||||||
- name: Publish to the Gitea RPM registry
|
- name: Publish to the Gitea RPM registry
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
@@ -206,10 +224,26 @@ jobs:
|
|||||||
dnf -y install dnf-plugins-core meson ninja-build glslc || true
|
dnf -y install dnf-plugins-core meson ninja-build glslc || true
|
||||||
dnf builddep -y gamescope || true
|
dnf builddep -y gamescope || true
|
||||||
dnf -y install xorg-x11-server-Xwayland-devel || true
|
dnf -y install xorg-x11-server-Xwayland-devel || true
|
||||||
|
# NOT best-effort: build-punktfunk-gamescope.sh appends `-static-libstdc++` to LDFLAGS
|
||||||
|
# (so the binary still starts on SteamOS's older libstdc++ — see its comment), and
|
||||||
|
# without the static library meson's very FIRST sanity check dies with
|
||||||
|
# "cannot find -lstdc++ / have you installed the static version", so nothing builds at
|
||||||
|
# all. That is what happened on the v0.26.0 tag: both Fedora bases warned and skipped,
|
||||||
|
# the job stayed green, and the release shipped with no gamescope RPM while the notes
|
||||||
|
# said it had one. A rename here must be LOUD, hence no `|| true`.
|
||||||
|
dnf -y install libstdc++-static
|
||||||
|
# The rest of the Arch package's makedepends that Fedora's older packaged gamescope does
|
||||||
|
# not necessarily pull. Best-effort: unlike the static runtime, meson finds fallbacks or
|
||||||
|
# does without, and a name that moves between Fedora releases should not fail the job.
|
||||||
|
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
|
||||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||||
else
|
else
|
||||||
|
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
|
||||||
|
# would skip the sysext build, the sysext feed, AND the release attach below, so a
|
||||||
|
# missing gamescope would also withhold the punktfunk RPMs and the .raw images that
|
||||||
|
# built perfectly well. deb.yml learned that the expensive way on v0.26.0.
|
||||||
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -227,9 +261,35 @@ jobs:
|
|||||||
--binary gs-cache/punktfunk-gamescope \
|
--binary gs-cache/punktfunk-gamescope \
|
||||||
--release "$PF_RELEASE"
|
--release "$PF_RELEASE"
|
||||||
else
|
else
|
||||||
|
# Warn only — see the note on the build step. The gate is the last step of this job.
|
||||||
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
|
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# A SECOND signing pass, for this package only. The main "Sign RPMs" step ran back at build
|
||||||
|
# time, long before this RPM existed — the gamescope build sits behind its own ~10-minute
|
||||||
|
# cache and deliberately runs after the host RPMs are already published. So every
|
||||||
|
# punktfunk-gamescope RPM went to the registry UNSIGNED, and the repo file we tell users to
|
||||||
|
# install carries gpgcheck=1: `dnf install punktfunk-gamescope` failed with "The package is
|
||||||
|
# not signed" on every Fedora and Nobara box. The package was in the channel the whole time
|
||||||
|
# and could not be installed from it — which is worse than absent, because the release notes
|
||||||
|
# and the docs-site both say it is there.
|
||||||
|
#
|
||||||
|
# Same fail-closed rule as the first pass: sign-rpms.sh hard-fails on refs/tags/v* if the org
|
||||||
|
# secret is missing, rather than republishing something a user's dnf will reject.
|
||||||
|
- name: Sign punktfunk-gamescope
|
||||||
|
env:
|
||||||
|
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
|
||||||
|
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
rpms=(dist/punktfunk-gamescope-*.rpm)
|
||||||
|
# No RPM here is the best-effort skip above, already warned about — not a signing failure.
|
||||||
|
if [ "${#rpms[@]}" -eq 0 ]; then
|
||||||
|
echo "no punktfunk-gamescope RPM to sign (see the packaging step above)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
bash packaging/rpm/sign-rpms.sh "${rpms[@]}"
|
||||||
|
|
||||||
- name: Publish punktfunk-gamescope to the Gitea RPM registry
|
- name: Publish punktfunk-gamescope to the Gitea RPM registry
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
@@ -270,6 +330,19 @@ jobs:
|
|||||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||||
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||||
|
|
||||||
|
# Read the capability matrix back OUT of the image that is about to be published — the one
|
||||||
|
# channel where getting it wrong is unrepairable, because a merged sysext's /usr is read-only
|
||||||
|
# squashfs and the only fix is a new image plus a feed republish. 0.26.0-1's Bazzite breakage
|
||||||
|
# was confirmed exactly this way, after the fact, by mounting the published .raw and running
|
||||||
|
# getcap on it. Doing it here means the .raw never reaches the feed.
|
||||||
|
#
|
||||||
|
# The script proves its own reader first (cap a file, squash it, unsquash it, read it back)
|
||||||
|
# so a runner that cannot see file capabilities FAILS the leg instead of blessing the image.
|
||||||
|
- name: Assert the capability matrix (sysext image)
|
||||||
|
run: |
|
||||||
|
bash scripts/ci/assert-cap-matrix.sh \
|
||||||
|
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
|
||||||
|
|
||||||
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
|
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
|
||||||
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
|
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
|
||||||
# anything, sitting on the same registry as the images they describe.
|
# anything, sitting on the same registry as the images they describe.
|
||||||
@@ -310,3 +383,26 @@ jobs:
|
|||||||
for raw in dist-sysext/*.raw; do
|
for raw in dist-sysext/*.raw; do
|
||||||
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
|
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# A release must not be able to make a claim its own CI silently dropped — v0.26.0's notes
|
||||||
|
# said the patched gamescope was dnf-installable while both Fedora bases had skipped it on a
|
||||||
|
# `::warning::` (missing libstdc++-static, which the -static-libstdc++ link needs).
|
||||||
|
#
|
||||||
|
# ⚠ LAST step on purpose, matching deb.yml: failing at the build step instead would skip the
|
||||||
|
# sysext image, the feed publish AND the attach above, withholding the punktfunk RPMs and
|
||||||
|
# .raw images that built perfectly well. Everything good ships first; the job goes red after.
|
||||||
|
- name: A stable tag must ship the gamescope RPM
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
built=(dist/punktfunk-gamescope-*.rpm)
|
||||||
|
keep=()
|
||||||
|
for r in "${built[@]}"; do
|
||||||
|
case "$r" in *debuginfo*|*debugsource*) continue;; esac
|
||||||
|
keep+=("$r")
|
||||||
|
done
|
||||||
|
if [ ${#keep[@]} -eq 0 ]; then
|
||||||
|
echo "::error::no punktfunk-gamescope RPM was built for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "gamescope RPM present: ${keep[*]}"
|
||||||
|
|||||||
@@ -12,9 +12,369 @@ with the version table of the release you are moving to, then read **Breaking ch
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.27.1 — in development
|
||||||
|
|
||||||
|
### NixOS + KDE — session detection, the other half
|
||||||
|
|
||||||
|
🛑 **v0.27.0's NixOS session-detection fix did not reach a stock NixOS + Plasma 6 box.** It resolved
|
||||||
|
the nixpkgs wrapper decoration through `/proc/<pid>/exe` (below) — and on that exact box the kernel
|
||||||
|
refuses to let us read that link. Reading `/proc/<pid>/exe` is not gated on owning the process: it
|
||||||
|
goes through `cap_ptrace_access_check`, which requires the reader's effective set to be a superset
|
||||||
|
of the target's **permitted** set. NixOS's own Plasma module ships
|
||||||
|
`security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, so KWin holds a capability
|
||||||
|
and the host — which must stay uncapped, because a capability is exactly what makes it
|
||||||
|
unidentifiable to KWin (v0.27.0, above) — gets `EACCES`. The two traps compose: the name *needs*
|
||||||
|
`exe` because nixpkgs wrapped the binary, and `exe` is *denied* because NixOS capped it. Detection
|
||||||
|
went straight back to `ActiveKind::None`, `wayland` to `-`, and every connect to
|
||||||
|
`no usable compositor`. It presents identically to the v0.27.0 bug, which is why a box that had been
|
||||||
|
worked around with a decoy process broke again the moment the decoy was removed.
|
||||||
|
|
||||||
|
Name resolution now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when the kernel refuses `exe`.
|
||||||
|
That reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
|
||||||
|
binary, so `argv[0]` survives the decoration `comm` does not. Measured on Linux 6.x against a capped
|
||||||
|
target, for a file capability and for the ambient form `security.wrappers` uses, identically: the
|
||||||
|
`/proc/<pid>` directory keeps its real owner (so the uid filter was never the problem), `comm` and
|
||||||
|
`cmdline` stay readable, and only `exe` fails. `argv[0]` is consulted **last** and never overrides a
|
||||||
|
readable `exe` — it is the process's own claim about itself, and a same-uid process can set it to
|
||||||
|
anything; the worst a spoof achieves is aiming detection at a backend that then fails its own
|
||||||
|
availability probe. The `comm` fast path is still one read for every ordinary distro.
|
||||||
|
|
||||||
|
Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a
|
||||||
|
*wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe.
|
||||||
|
|
||||||
|
## v0.27.0
|
||||||
|
|
||||||
|
87 commits since v0.26.0.
|
||||||
|
|
||||||
|
### Versions
|
||||||
|
|
||||||
|
| | v0.26.0 | v0.27.0 | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Wire protocol | 2 | **2** | unchanged |
|
||||||
|
| C ABI | 17 | **18** | `punktfunk_connection_next_rumble_cmd2` **added**; nothing removed or changed |
|
||||||
|
| Workspace crate dirs | 26 | **27** | `crates/punktfunk-encode-worker` (39 members; two `tools/` crates deliberately *excluded*) |
|
||||||
|
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
|
||||||
|
| Windows virtual-gamepad channel | 3 | **3** | unchanged — three `device_type`s added additively |
|
||||||
|
| Plugin index schema | 1 | **1** | unchanged |
|
||||||
|
| `api/openapi.json` | 0.25.0 | **0.25.0** | unchanged — no management-API edits this release |
|
||||||
|
| gamescope patch level (`+pfhdrN`) | 4 | **5** | 6 patches → 7 (the PipeWire use-after-free); `pkgrel` resets 3 → 1 |
|
||||||
|
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
|
||||||
|
| `@punktfunk/plugin-kit` | 0.4.0 | **0.4.0** | unchanged |
|
||||||
|
|
||||||
|
⚠ **`crates/pf-driver-proto` is no longer byte-identical to the previous release.** It was through
|
||||||
|
both v0.25.0 and v0.26.0, so if you ship the virtual-display driver or the gamepad channel and have
|
||||||
|
been skipping this crate, stop skipping it here. The change is purely additive — three `device_type`
|
||||||
|
constants, no field moved, no size changed.
|
||||||
|
|
||||||
|
### ⚠ Breaking changes
|
||||||
|
|
||||||
|
**None** for embedders or the wire. Every embedder, packager and plugin that works against v0.26.0
|
||||||
|
works against v0.27.0 unchanged; the C ABI moves, but by addition only (below).
|
||||||
|
|
||||||
|
Two things change shape for **packagers** and one **default** flips:
|
||||||
|
|
||||||
|
- **A second installed binary**, `punktfunk-encode-worker` — see the section below. It is the only
|
||||||
|
file that may carry `cap_sys_nice=ep`, and it must be a separate file.
|
||||||
|
- **`PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`** on Windows, so an Xbox pad is built as a real
|
||||||
|
HID device rather than the XUSB companion. `=xusb` is the escape hatch.
|
||||||
|
- **NixOS `scripting.autoStart` now defaults ON**, matching every other packaging (detailed below).
|
||||||
|
|
||||||
|
### `punktfunk-encode-worker` — the GPU-priority capability moves off the host
|
||||||
|
|
||||||
|
0.26.0 left the PyroWave priority ladder wired and inert: it needs `CAP_SYS_NICE`, and 0.26.0-1
|
||||||
|
proved the host can never hold one — see **PyroWave on Linux — Wave 2**, PW1, under v0.26.0 below. A
|
||||||
|
capability-carrying process cannot be identified by KWin (`cap_ptrace_access_check` refuses
|
||||||
|
`/proc/<pid>/exe` to a reader whose effective set is not a superset of the target's **permitted**
|
||||||
|
set), so it never gets `zkde_screencast_unstable_v1` and every KDE desktop session dies. Neither
|
||||||
|
`prctl(PR_SET_DUMPABLE, 1)` nor systemd `AmbientCapabilities=` nor a NixOS `security.wrappers` entry
|
||||||
|
changes that — all three land the capability in the same permitted set.
|
||||||
|
|
||||||
|
The capability therefore moves to a process that fronts nothing. **`punktfunk-encode-worker`** is a
|
||||||
|
new workspace member and a new installed binary: it owns the priority-elevated Vulkan device for
|
||||||
|
PyroWave sessions, receives capture dmabufs over a `SOCK_SEQPACKET` pair from its parent, and returns
|
||||||
|
compressed access units. It connects to no compositor, no D-Bus and no network, so its
|
||||||
|
non-dumpability costs nothing and its blast radius is one socket to the host that spawned it.
|
||||||
|
|
||||||
|
🛑 **The invariant, for anyone packaging this:** the worker is a **separate file**. Never a hardlink
|
||||||
|
to `punktfunk-host` and never a subcommand of it — a shared inode shares the file capability, which
|
||||||
|
silently re-creates 0.26.0-1 on every KDE box. `punktfunk-host` carries no capability, on any
|
||||||
|
channel, ever.
|
||||||
|
|
||||||
|
- **The grants are re-targeted, not re-introduced.** Every channel that granted in 0.26.0-1 grants
|
||||||
|
again, at the worker: Arch `.install` (`post_install` **and** `post_upgrade` — a replaced binary is
|
||||||
|
a new inode), RPM `%caps(cap_sys_nice=ep)` in `%files` (never a `%post setcap`; this covers Fedora
|
||||||
|
and Bazzite layering), the Bazzite sysext staging tree pre-`mksquashfs` (which does record
|
||||||
|
`security.capability`), the deb `postinst`, the Deck installer, and NixOS
|
||||||
|
`security.wrappers.punktfunk-encode-worker`. Every #136 host-side removal stays verbatim, including
|
||||||
|
the sysext's host hard-fail.
|
||||||
|
- **The sysext assertion is amended, not removed** — host must be empty (hard fail), worker must
|
||||||
|
carry **exactly** `cap_sys_nice=ep`. A *missing* worker capability is not an error: the grant is
|
||||||
|
best-effort everywhere.
|
||||||
|
- **A new release-CI leg asserts the getcap matrix** on the built Arch package, the deb and the
|
||||||
|
mounted sysext raw. The 0.26.0-1 lesson was "verify the package, never the board"; this is that,
|
||||||
|
mechanized, and it is what would have caught the original break.
|
||||||
|
- **On NixOS the env override is load-bearing**, not a convenience: a file capability cannot live on
|
||||||
|
a read-only store path, so the module wraps the worker and sets `PUNKTFUNK_ENCODE_WORKER` to the
|
||||||
|
wrapper path in the unit. An ambient grant is fine *here* — the worker is not a KWin client. The
|
||||||
|
host's `ExecStart` stays on the plain store path (the #136 fix stands).
|
||||||
|
|
||||||
|
**Fallback ladder — no rung can kill a negotiated session.** Binary not found → spawn failure →
|
||||||
|
handshake timeout → protocol or workspace-version mismatch → socket EOF mid-session all fall back to
|
||||||
|
the **in-process encoder exactly as today**, at default priority, with one warning. Host and worker
|
||||||
|
are different files now, so the version check is load-bearing rather than decorative; they ship
|
||||||
|
lockstep in every channel. The in-process path stays compiled and tested — it is the floor, not dead
|
||||||
|
code. `PYROWAVE_QUEUE_PRIORITY` keeps its 0.26.0 grammar and is now forwarded **explicitly** in the
|
||||||
|
handshake rather than read from the worker's environment, which is sanitized at spawn; one env var
|
||||||
|
still means one thing on both platforms.
|
||||||
|
|
||||||
|
### NixOS — session detection, module defaults, and a CI gate that was never running
|
||||||
|
|
||||||
|
🛑 **The host could not detect any graphical session on NixOS, at all.** The live-session probe
|
||||||
|
matched `/proc/<pid>/comm` exactly against `kwin_wayland` / `gamescope` / `gnome-shell` /
|
||||||
|
`Hyprland`. `comm` is the kernel's name for the **executed file**, truncated to 15 bytes — not
|
||||||
|
`argv[0]` — and nixpkgs wraps essentially every graphical binary: `wrapProgram` moves the real ELF
|
||||||
|
aside to `.<name>-wrapped` and installs a wrapper that `exec -a "$0"`s it. So the kernel reports
|
||||||
|
`.kwin_wayland-w` while `ps` and `pgrep -a` show a perfectly ordinary `kwin_wayland`, because they
|
||||||
|
read argv. Every probe answered `ActiveKind::None` on a running desktop, and nothing downstream
|
||||||
|
could recover: `wayland` logged as `-`, a correct `WAYLAND_DISPLAY` changed nothing, `Auto` returned
|
||||||
|
the *detected* backend so a live KWin already in `available()` was never chosen, and a
|
||||||
|
`PUNKTFUNK_COMPOSITOR` pin turned the miss into a hard error through `pinned_at_a_dead_session`.
|
||||||
|
sway and river survived by accident — nixpkgs' wrapper execs a binary still called `sway`.
|
||||||
|
|
||||||
|
Names are now resolved through `/proc/<pid>/exe`, whose file name is untruncated, with the nixpkgs
|
||||||
|
decoration stripped. Stripping requires **both** the leading `.` and a trailing `-wrapped`, so
|
||||||
|
KWin's own real `kwin_wayland_wrapper` binary keeps its name instead of collapsing into
|
||||||
|
`kwin_wayland` and handing the probe the parent's PID. The `comm` fast path is unchanged for every
|
||||||
|
ordinary distro — one read, no readlink — and no name that matched before can stop matching. Also
|
||||||
|
applied to the foreign-gamescope probe, which had the same defect.
|
||||||
|
|
||||||
|
**Module changes** (`services.punktfunk`):
|
||||||
|
|
||||||
|
- **`host.desktopSession`** *(new, default `false`)* — binds the host to `graphical-session.target`,
|
||||||
|
the declarative form of the `punktfunk-host-desktop-session.conf` drop-in. Without it a
|
||||||
|
Plasma/GNOME restart leaves the host holding a Wayland socket and portal D-Bus connection that
|
||||||
|
died with the old compositor: it still listens, still answers, and every session after that fails
|
||||||
|
at capture. Off by default because an appliance may never reach that target and would be left
|
||||||
|
permanently stopped.
|
||||||
|
- ⚠ **`scripting.autoStart` now defaults ON** *(behaviour change)*, matching the deb `postinst` and
|
||||||
|
RPM `%post`, which both `systemctl --global enable` the runner, and the sysext's baked-in
|
||||||
|
`default.target.wants` symlink. It was opt-in here on the reasoning that the runner is inert until
|
||||||
|
you add automation — untrue since the game-library scanners became plugins, so a NixOS host came
|
||||||
|
up with an empty library and no obvious cause. Opt out with `scripting.autoStart = false` or
|
||||||
|
`systemctl --user mask punktfunk-scripting`.
|
||||||
|
- **Three divergences from the shipped units, ported.** `punktfunk-web` gains
|
||||||
|
`StartLimitIntervalSec=0` (without it, 5 starts / 10 s against `RestartSec=2` gives up permanently
|
||||||
|
after ~10 s — exactly the window before the host's first `serve` writes the mgmt token, so a
|
||||||
|
console enabled before the host's first run stayed dead) and `Restart=always` rather than
|
||||||
|
`on-failure`. `punktfunk-scripting` gains the sandbox the deb/rpm unit has all along
|
||||||
|
(`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=%h /tmp`, restricted address families,
|
||||||
|
`PrivateTmp=no`) — it is the one unit that runs arbitrary operator TypeScript by design, and it
|
||||||
|
had been running strictly less confined on NixOS than anywhere else.
|
||||||
|
- A **warning** when the host is enabled and `xdg.portal.enable` is not.
|
||||||
|
|
||||||
|
🛑 **`nix flake check` does not check `nixosModules`** — worth knowing for anyone maintaining a
|
||||||
|
flake. It forces the value and asserts it is a lambda taking an open attribute set, and stops;
|
||||||
|
nix's source still carries `// FIXME: if we have a 'nixpkgs' input, use it to check the module.`
|
||||||
|
Measured: a module with a nonexistent option, a nonexistent `pkgs` attribute **and** a nonexistent
|
||||||
|
`lib` function passes, printing `checking NixOS module ... all checks passed!`. `nix.yml`'s header
|
||||||
|
claimed that leg covered the module; it never had. `checks.<system>.nixos-module`
|
||||||
|
(`packaging/nix/module-check.nix`) now evaluates it against real nixpkgs across four scenarios and
|
||||||
|
asserts on the rendered units, including a guard that the host's `ExecStart` stays on the plain
|
||||||
|
store path while the encode worker points at the wrapper. Its assertions are pure Nix, so
|
||||||
|
instantiation runs them and the existing `--no-build` leg is enough. `punktfunk-gamescope` gains a
|
||||||
|
`build-gamescope` dispatch input — it is on the critical path of every host build yet nothing
|
||||||
|
compiled it, and it tracks nixpkgs' gamescope, so a `flake.lock` bump is what breaks it.
|
||||||
|
|
||||||
|
### C ABI 17 → 18
|
||||||
|
|
||||||
|
**`punktfunk_connection_next_rumble_cmd2` is new.** The `0xCA` rumble plane carries the two Xbox
|
||||||
|
impulse-trigger motors (v3, below) and `punktfunk_connection_next_rumble_cmd`'s fixed out-params
|
||||||
|
have no room for them:
|
||||||
|
|
||||||
|
```c
|
||||||
|
PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
|
||||||
|
PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
|
||||||
|
uint16_t *left_trigger, uint16_t *right_trigger,
|
||||||
|
uint32_t *backstop_ms, uint32_t timeout_ms);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Added, not widened.** `_cmd` keeps its signature *and* its values bit-identical for handle-only
|
||||||
|
traffic; all four rumble entry points remain exported. An exported parameter list is part of the
|
||||||
|
contract, and growing one in place breaks every out-of-tree embedder at once — with a
|
||||||
|
stack-corruption signature rather than a link error. This follows the existing
|
||||||
|
`next_rumble` → `next_rumble2` precedent.
|
||||||
|
|
||||||
|
⚠ **One behavioural delta on the old symbol**, documented in `abi.rs` and pinned by a test: against
|
||||||
|
a host driving the trigger motors, a `_cmd` caller now receives commands with `low == high == 0`
|
||||||
|
where the demux previously dropped the update entirely. They are idempotent handle stops — the
|
||||||
|
command as a whole is not silent, so redundant-stop suppression cannot fold them. Zero cost today:
|
||||||
|
nothing sources non-zero trigger levels yet.
|
||||||
|
|
||||||
|
**Render trigger levels only on a pad that has trigger motors.** Do not fold them into the handles —
|
||||||
|
impulse-trigger content is continuous, so folding it drones the handle motors flat-out. Query
|
||||||
|
`SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN` or `GCDeviceHaptics.supportedLocalities`.
|
||||||
|
|
||||||
|
🛑 **This delivery path is deliberately built ahead of its producer and nothing here claims
|
||||||
|
otherwise.** Exactly one backend can ever source these levels — the Windows HID Xbox pad's output
|
||||||
|
report `0x03` — because `XINPUT_VIBRATION` and evdev `FF_RUMBLE` both have two members. That
|
||||||
|
producer is reachable only through GameInput, which does not enumerate an `xinputhid`-promoted Xbox
|
||||||
|
pad at all (measured against a real Microsoft Elite, equally invisible there while classic XInput
|
||||||
|
reads it live). The wire, the engine and this entry point are exercised by synthetic levels only.
|
||||||
|
|
||||||
|
### Gamepads
|
||||||
|
|
||||||
|
- **`PUNKTFUNK_GAMEPAD_XBOXELITE = 11`** — a new `GamepadPref` wire byte, appended to
|
||||||
|
`Hello`/`Welcome`. The `Auto` sentinel in the round-trip test moved 11 → 12. An older peer
|
||||||
|
degrades an unknown byte to `Auto`, so this is graceful in both directions.
|
||||||
|
- **`XboxOne` is now a distinct HID identity on Windows** (`045E:02FD`, Bluetooth Xbox One S)
|
||||||
|
through the UMDF minidriver. It used to fold to `Xbox360` there, because the only Windows Xbox
|
||||||
|
backend was the XUSB companion, which presents one fixed 360 identity and cannot vary it.
|
||||||
|
- **Three new `pf_driver_proto::gamepad` device types**, contiguous and sharing one report
|
||||||
|
descriptor byte for byte (they are the same pad in HID terms; the descriptor is the report
|
||||||
|
*shape*, the identity is what the OS keys mappings off):
|
||||||
|
|
||||||
|
| const | value | identity |
|
||||||
|
|---|---|---|
|
||||||
|
| `DEVTYPE_XBOX` | 4 | `045E:0B13` Xbox Wireless Controller |
|
||||||
|
| `DEVTYPE_XBOX_ONE_S` | 5 | `045E:02FD` Xbox Wireless Controller (One S) |
|
||||||
|
| `DEVTYPE_XBOX_ELITE` | 6 | `045E:0B22` Xbox Elite Wireless Controller Series 2 |
|
||||||
|
|
||||||
|
⚠ The Xbox input report is **not** 64 bytes like its siblings — it is `XBOX_INPUT_REPORT_LEN`
|
||||||
|
(16). The driver serves per-identity report lengths, because hidclass sizes its buffer from the
|
||||||
|
descriptor and refuses an over-long source.
|
||||||
|
- ⚠ **Elite paddles are not implemented.** `BTN_PADDLE1..4` still fold or drop exactly as on the
|
||||||
|
other Xbox classes. `DualSenseEdge` remains the only virtual pad with native back-button slots.
|
||||||
|
- **All three Xbox identities install `pfGamepadXbox`**, their own DDInstall section, which attaches
|
||||||
|
the `xinputhid` bus filter. Merging it back into the shared `pfGamepad` section is a one-line edit
|
||||||
|
that looks like tidying and would hand a DualSense, DualShock 4, Edge and Steam Deck to
|
||||||
|
Microsoft's Xbox translator. `only_the_xbox_identity_installs_the_xinputhid_section` asserts the
|
||||||
|
split in both directions.
|
||||||
|
|
||||||
|
**What actually promotes the pad — two registry values, and the pairing is the whole finding.**
|
||||||
|
`UpperFilters=xinputhid` is a `.HW` AddReg (hardware key); `DevicePropertyFlags=1` is a DDInstall
|
||||||
|
AddReg (software key). A one-value A/B on real hardware: removing `DevicePropertyFlags` alone
|
||||||
|
reverts everything — no `IG_00`, no XUSB interface, no XInput, no WGI entry — while `UpperFilters`
|
||||||
|
alone is completely inert. `1` = `BusDevice`, which Microsoft's own comment glosses as "a focused
|
||||||
|
bus filter driver for the IG_ problem". **This retracts an earlier in-tree conclusion that the
|
||||||
|
filter should never ship**: it was never broken, it had simply never been switched on.
|
||||||
|
⚠ Microsoft's allow-list contains `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — neither `02FD`
|
||||||
|
nor `0B22` is on it, and promotion happens anyway, because it comes from our own AddReg.
|
||||||
|
|
||||||
|
### Wire (no version change)
|
||||||
|
|
||||||
|
**The `0xCA` rumble datagram gains a v3 form**, `PUNKTFUNK_RUMBLE_V3_LEN = 14`:
|
||||||
|
|
||||||
|
```
|
||||||
|
v1 7 B: [0xCA][u16 pad][u16 low][u16 high]
|
||||||
|
v2 10 B: … [u8 seq][u16 ttl_ms]
|
||||||
|
v3 14 B: … [u16 left_trigger][u16 right_trigger]
|
||||||
|
```
|
||||||
|
|
||||||
|
v3 is built *from* v2's bytes, so the prefix relationship is structural rather than a convention two
|
||||||
|
encoders must keep agreeing on, and every reader gates with `>=`. All four levels share one `seq`
|
||||||
|
and one TTL deliberately: they are one statement of the pad's feedback at one instant, so the entire
|
||||||
|
v2 apparatus — renewal cadence, stop burst, the client's seq gate, the lease clamp — governs the
|
||||||
|
triggers with no new code. The new `RumbleUpdate` fields are plain `u16`, not `Option`: on a
|
||||||
|
level-triggered plane "absent" must mean zero, because "absent → keep the previous value" is the
|
||||||
|
stuck-rumble bug in a new costume.
|
||||||
|
|
||||||
|
⚠ **The two trigger `enable`-mask bits remain conjecture.** Bits 2/3 (the handles) are measured;
|
||||||
|
bits 0/1 are inferred from field order and nothing else. No test asserts them. XInput cannot settle
|
||||||
|
this; it has two motors.
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
|
||||||
|
- **gamescope pin `8c676c39` → `5fb8dce4`** (3.16.25-1 → 3.16.25-11), all six patches rebased, plus
|
||||||
|
a **seventh**: the PipeWire use-after-free that aborted a session on every connect. The marker
|
||||||
|
moves `+pfhdr4` → **`+pfhdr5`**, so `pkgrel` resets to 1.
|
||||||
|
- **Patch 0001 offers `xBGR_210LE` before `xRGB_210LE`.** ⚠ Deliberately *not* done by calling
|
||||||
|
upstream's `vulkan_get_rgb10_capture_format()` — that symbol landed after 3.16.25 and would break
|
||||||
|
`packaging/nix/gamescope.nix` with an opaque C++ error instead of a patch conflict.
|
||||||
|
- **Every `punktfunk-gamescope` RPM ever published was unsigned.** `Sign RPMs` runs right after
|
||||||
|
`Build RPM`, while the gamescope RPM is built ~90 steps later behind its own cache, so it missed
|
||||||
|
the signing pass entirely — and the repo file we ship carries `gpgcheck=1`. A second pass signs it
|
||||||
|
before publish, fail-closed on a tag.
|
||||||
|
- ⚠ **The v0.26.0 gamescope gate failed the job at the *build* step**, which in `deb.yml` runs before
|
||||||
|
both the apt publish and the release attach — so a missing *extra* withheld the host `.deb` itself,
|
||||||
|
and the `.deb` published on v0.26.0 still carries the `CAP_SYS_NICE` grant. `rpm.yml` had the
|
||||||
|
identical latent bug. Both now warn at build/package time and gate as the **last** step of the job.
|
||||||
|
- **`driver uninstall --audio`** — a third Inno `[UninstallRun]` entry that removes the MEDIA-class
|
||||||
|
devnodes the host mints at runtime. Marker-matched, never name-matched: our instances are
|
||||||
|
name-identical to Steam's, and a `ROOT\` enumeration guard means a marker-shaped value on a real
|
||||||
|
sound card can never cost the user their hardware.
|
||||||
|
- **The sysext `post_merge` step re-runs when already current, plus a new `reapply` verb.** A sysext
|
||||||
|
upgrade is driven by the script from the **old** image, so a `post_merge` step added in a release
|
||||||
|
is executed by nobody, permanently, on exactly the installs that need it.
|
||||||
|
|
||||||
|
### Host
|
||||||
|
|
||||||
|
- **HDR capture offers `xBGR_210LE` before `xRGB_210LE`.** gamescope's capture textures are
|
||||||
|
mappable, hence linear-tiled, and NVIDIA does not implement linear-tiled STORAGE for
|
||||||
|
`A2R10G10B10_UNORM_PACK32` — so `imageStore` lands in XBGR order while the buffer is still
|
||||||
|
*labelled* `XRGB2101010`. Every mapping on both ends audits clean because the label was right and
|
||||||
|
only the content was wrong. Fixed host-side because the deployed gamescope cannot self-correct.
|
||||||
|
- **One NVENC open failure no longer kills every session on the box**, and the 10-bit capability
|
||||||
|
probe no longer wedges a direct-SDK host process-wide with `NV_ENC_ERR_INVALID_VERSION`.
|
||||||
|
- **`/api/v1/local/summary` reports the resolution the session actually got**, not the negotiated
|
||||||
|
one it was seeded with.
|
||||||
|
|
||||||
|
### Workspace
|
||||||
|
|
||||||
|
`crates/punktfunk-encode-worker` joins as a member (above). Two bring-your-own-hardware measurement
|
||||||
|
tools are added and **excluded** in the root manifest, so `cargo build --workspace` and CI never see
|
||||||
|
them: `tools/hid-descriptor-dump` (dumps and decodes a real HID report descriptor; pulls `hidapi`)
|
||||||
|
and `tools/win-input-matrix` (asks each Windows input API what it can see — ⚠ `wake_wgi()` is not
|
||||||
|
optional there: both WGI collections return a cache a console app has never started filling, so
|
||||||
|
without subscribing first they come back empty with real controllers attached).
|
||||||
|
|
||||||
|
### Host and client environment variables
|
||||||
|
|
||||||
|
- **`PUNKTFUNK_XBOX_BACKEND`** *(new, host, Windows)* — `hid` (the new **default**) or `xusb` (the
|
||||||
|
escape hatch). The HID pad is now a superset of the XUSB companion: it keeps classic XInput while
|
||||||
|
gaining Steam, SDL, RawInput, DirectInput, `joy.cpl` and WGI, plus rumble, which XUSB could not
|
||||||
|
source at all. The escape hatch stays because promotion leans on Microsoft's inbox
|
||||||
|
`xinputhid.inf`; if a servicing update changes it, one env var restores the old behaviour with no
|
||||||
|
reinstall. An unrecognised value takes the **default**, not the opt-out, so a typo cannot silently
|
||||||
|
drop a user onto the path with no HID collection.
|
||||||
|
- **`PUNKTFUNK_GAMESCOPE_BIND`** *(new, host, Linux)* — unset = auto, `0` = never, `1` = force.
|
||||||
|
Governs whether the host binds the patched gamescope over the distribution's `/usr/bin/gamescope`
|
||||||
|
inside a session's mount namespace.
|
||||||
|
- **`PUNKTFUNK_ENCODE_WORKER`** *(new, host, Linux)* — where to find the encode worker. Resolution
|
||||||
|
order: this variable → alongside `/proc/self/exe` → `PATH`. `off` forces the in-process encoder,
|
||||||
|
the debug escape hatch that makes the A/B a one-line change. Load-bearing on NixOS (above).
|
||||||
|
- **`PYROWAVE_QUEUE_PRIORITY`** *(unchanged grammar, new consumer)* — the *intent*, forwarded to the
|
||||||
|
worker; the granted class comes back in the handshake and the host logs it centrally, so the
|
||||||
|
in-process INERT warning does not double-fire. When the worker is uncapped as well — an operator
|
||||||
|
stripped it, or the filesystem cannot store the capability — the same INERT wording fires, now
|
||||||
|
naming the worker binary rather than the host.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `docs-site` **Running as a service → GPU scheduling priority** rewritten around the split: the
|
||||||
|
worker carries the capability, the host never does, and `setcap` on `punktfunk-host` is called out
|
||||||
|
as the thing an operator must never do, with the `zkde_screencast_unstable_v1` symptom spelled out
|
||||||
|
so anyone who already did it can self-diagnose. The anchor is unchanged, so existing links hold.
|
||||||
|
- `configuration.md` gains the `PUNKTFUNK_ENCODE_WORKER` row and rewrites `PYROWAVE_QUEUE_PRIORITY`
|
||||||
|
off "the packages deliberately do not grant this".
|
||||||
|
- The 0.26.0 user-facing notes describe a privilege that is deliberately not granted. That is the
|
||||||
|
record of what 0.26.0 shipped and is **not** rewritten; the new phrasing — granted to the worker,
|
||||||
|
never to the host — lives in `docs/releases/v0.27.0.md`.
|
||||||
|
- `install.md` **NixOS** documents `desktopSession`, and its `punktfunk-scripting` bullet no longer
|
||||||
|
claims the runner "ships disabled": that was true only of Arch and source installs — apt, dnf, the
|
||||||
|
Bazzite sysext and now the NixOS module all start it, because the library scanners are plugins.
|
||||||
|
`bazzite.md` carried the same stale claim and is corrected. **Running as a service → Restart the
|
||||||
|
host with your desktop** gains the NixOS one-liner beside the drop-in.
|
||||||
|
- `packaging/nix/README.md`: `desktopSession`, `gamescopeHdr`/`gamescopePackage` and the
|
||||||
|
`punktfunk` group added to the option tables; the "what the module configures" list gains the
|
||||||
|
`security.wrappers` entry, with the KWin-identification reasoning for why the capability is on the
|
||||||
|
worker and not the host; and a caveat recording that `nix flake check` does not check the module,
|
||||||
|
plus the two rules for editing `module-check.nix`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.26.0
|
## v0.26.0
|
||||||
|
|
||||||
47 commits since v0.25.0.
|
52 commits since v0.25.0.
|
||||||
|
|
||||||
### Versions
|
### Versions
|
||||||
|
|
||||||
@@ -290,7 +650,19 @@ same shader cores a game saturates; NVENC is immune because it has its own ASIC.
|
|||||||
ladder REALTIME → HIGH → no-priority, stepping only on refusal; a refused class can never fail the
|
ladder REALTIME → HIGH → no-priority, stepping only on refusal; a refused class can never fail the
|
||||||
open. The extension probe reuses the `dev_ext_props` already fetched for `queue_family_foreign` and
|
open. The extension probe reuses the `dev_ext_props` already fetched for `queue_family_foreign` and
|
||||||
takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
|
takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
|
||||||
⭐ **Needs `CAP_SYS_NICE`**, which the packaging now grants; without it the lever does nothing.
|
⭐ **Needs `CAP_SYS_NICE`**, which the packaging granted in `0.26.0-1`; without it the lever does
|
||||||
|
nothing.
|
||||||
|
🛑 **Corrected in `0.26.0-2`: the packaging no longer grants it, and must not.** Every channel that
|
||||||
|
did (Arch `.install`, RPM `%caps()`, the Bazzite sysext image, the deb postinst, the NixOS
|
||||||
|
`security.wrappers` entry) broke desktop streaming on KDE outright — field-reported on CachyOS and
|
||||||
|
Bazzite as `KWin does not expose zkde_screencast_unstable_v1 to this client`. KWin identifies a
|
||||||
|
client by resolving its `/proc/<pid>/exe` against an installed `.desktop`, and the kernel refuses
|
||||||
|
that readlink to any reader whose effective set is not a superset of the target's **permitted**
|
||||||
|
set (`cap_ptrace_access_check`) — KWin has no capabilities, so a capability-carrying host is
|
||||||
|
unidentifiable and the restricted globals are never advertised. Neither `prctl(PR_SET_DUMPABLE, 1)`
|
||||||
|
nor systemd `AmbientCapabilities=` rescues it; only an uncapped process is identifiable. The lever
|
||||||
|
therefore stays wired but unexercised on a stock install (the ladder degrades to default priority),
|
||||||
|
and is opt-in for gamescope-only hosts, which have no such identity check.
|
||||||
- **PW5 — two encoder handles.** `Encoder::Impl` owns exactly one each of `wavelet_img_high_res`,
|
- **PW5 — two encoder handles.** `Encoder::Impl` owns exactly one each of `wavelet_img_high_res`,
|
||||||
`bucket_buffer`, `meta_buffer`, `block_stat_buffer`, `payload_data`, `quant_buffer`, and
|
`bucket_buffer`, `meta_buffer`, `block_stat_buffer`, `payload_data`, `quant_buffer`, and
|
||||||
`Impl::encode` *opens* by discarding them (an image barrier with `VK_IMAGE_LAYOUT_UNDEFINED` as the
|
`Impl::encode` *opens* by discarding them (an image barrier with `VK_IMAGE_LAYOUT_UNDEFINED` as the
|
||||||
@@ -414,7 +786,9 @@ emulator itself would land it outside both.
|
|||||||
|
|
||||||
⏳ **Owed on glass:** iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
|
⏳ **Owed on glass:** iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
|
||||||
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the PyroWave-under-game-load A/B on a
|
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the PyroWave-under-game-load A/B on a
|
||||||
Linux host with `CAP_SYS_NICE` actually granted — the number this whole wave is aimed at.
|
Linux host with `CAP_SYS_NICE` actually granted — the number this whole wave is aimed at. ⚠ That
|
||||||
|
last one now needs a **gamescope-only** host, or a hand-granted capability on a box you are not
|
||||||
|
streaming the KDE desktop from: see the `0.26.0-2` correction under PW1 above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -994,7 +994,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cursor-probe"
|
name = "cursor-probe"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-capture",
|
"pf-capture",
|
||||||
@@ -1114,7 +1114,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "display-disturb"
|
name = "display-disturb"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
]
|
]
|
||||||
@@ -2358,7 +2358,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2463,7 +2463,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libvpl-sys"
|
name = "libvpl-sys"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-bitstream"
|
name = "pf-bitstream"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cros-codecs",
|
"cros-codecs",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-capture"
|
name = "pf-capture"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3017,7 +3017,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3052,7 +3052,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-clipboard"
|
name = "pf-clipboard"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3070,7 +3070,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3091,7 +3091,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-dxvadec"
|
name = "pf-dxvadec"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cros-codecs",
|
"cros-codecs",
|
||||||
"pf-bitstream",
|
"pf-bitstream",
|
||||||
@@ -3101,7 +3101,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-encode"
|
name = "pf-encode"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3118,6 +3118,8 @@ dependencies = [
|
|||||||
"pf-zerocopy",
|
"pf-zerocopy",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"pyrowave-sys",
|
"pyrowave-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
@@ -3125,7 +3127,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-frame"
|
name = "pf-frame"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3137,7 +3139,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-gpu"
|
name = "pf-gpu"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
@@ -3151,11 +3153,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-host-config"
|
name = "pf-host-config"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-inject"
|
name = "pf-inject"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3184,14 +3186,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-paths"
|
name = "pf-paths"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3206,7 +3208,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-update"
|
name = "pf-update"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3214,7 +3216,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-update-check"
|
name = "pf-update-check"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3226,7 +3228,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vaadec"
|
name = "pf-vaadec"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cros-codecs",
|
"cros-codecs",
|
||||||
"pf-bitstream",
|
"pf-bitstream",
|
||||||
@@ -3235,7 +3237,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vdisplay"
|
name = "pf-vdisplay"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3268,7 +3270,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vkdecode"
|
name = "pf-vkdecode"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"cros-codecs",
|
"cros-codecs",
|
||||||
@@ -3279,7 +3281,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-win-display"
|
name = "pf-win-display"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-paths",
|
"pf-paths",
|
||||||
@@ -3291,7 +3293,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-zerocopy"
|
name = "pf-zerocopy"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3514,7 +3516,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-cli"
|
name = "punktfunk-cli"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
@@ -3525,7 +3527,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3543,7 +3545,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3560,7 +3562,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3575,7 +3577,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3594,7 +3596,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3624,9 +3626,18 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "punktfunk-encode-worker"
|
||||||
|
version = "0.27.0"
|
||||||
|
dependencies = [
|
||||||
|
"pf-encode",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3711,7 +3722,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3725,7 +3736,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3748,7 +3759,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrowave-sys"
|
name = "pyrowave-sys"
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ members = [
|
|||||||
"crates/punktfunk-core",
|
"crates/punktfunk-core",
|
||||||
"crates/punktfunk-host",
|
"crates/punktfunk-host",
|
||||||
"crates/punktfunk-host/vendor/usbip-sim",
|
"crates/punktfunk-host/vendor/usbip-sim",
|
||||||
|
# The capability-carrying PyroWave encode worker. A SEPARATE binary by design — never a
|
||||||
|
# hardlink of, or a subcommand of, punktfunk-host (design/gpu-priority-capability-worker.md).
|
||||||
|
"crates/punktfunk-encode-worker",
|
||||||
"crates/punktfunk-tray",
|
"crates/punktfunk-tray",
|
||||||
"crates/pf-bitstream",
|
"crates/pf-bitstream",
|
||||||
"crates/pf-bitstream/vendor/cros-codecs",
|
"crates/pf-bitstream/vendor/cros-codecs",
|
||||||
@@ -46,6 +49,11 @@ members = [
|
|||||||
exclude = [
|
exclude = [
|
||||||
"packaging/linux/steam-deck-gadget/usbip-poc",
|
"packaging/linux/steam-deck-gadget/usbip-poc",
|
||||||
"clients/android/native/vendor/ndk",
|
"clients/android/native/vendor/ndk",
|
||||||
|
# Bring-your-own-hardware measurement tools. `hid-descriptor-dump` pulls `hidapi`, a C library
|
||||||
|
# wanting libudev on Linux; `win-input-matrix` is Windows-only and asks the live input stacks
|
||||||
|
# what they can see. Neither belongs in `cargo build --workspace` or on a CI leg with no pad.
|
||||||
|
"tools/hid-descriptor-dump",
|
||||||
|
"tools/win-input-matrix",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
|
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
|
||||||
@@ -57,7 +65,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.26.0"
|
version = "0.27.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"name": "MIT OR Apache-2.0",
|
"name": "MIT OR Apache-2.0",
|
||||||
"identifier": "MIT OR Apache-2.0"
|
"identifier": "MIT OR Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.25.0"
|
"version": "0.26.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/clients": {
|
"/api/v1/clients": {
|
||||||
@@ -4489,6 +4489,13 @@
|
|||||||
],
|
],
|
||||||
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
||||||
},
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL."
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||||
@@ -4555,6 +4562,13 @@
|
|||||||
"$ref": "#/components/schemas/DetectHint",
|
"$ref": "#/components/schemas/DetectHint",
|
||||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||||
},
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one."
|
||||||
|
},
|
||||||
"launch": {
|
"launch": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
@@ -5350,6 +5364,14 @@
|
|||||||
"art": {
|
"art": {
|
||||||
"$ref": "#/components/schemas/Artwork"
|
"$ref": "#/components/schemas/Artwork"
|
||||||
},
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.",
|
||||||
|
"example": "steam"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||||
@@ -6869,6 +6891,13 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||||
},
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists."
|
||||||
|
},
|
||||||
"launch": {
|
"launch": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
Font Awesome Free — brand icons (steam, xbox in assets/launcher-icons/) are from
|
||||||
|
Font Awesome Free.
|
||||||
|
|
||||||
|
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||||
|
|
||||||
|
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
|
||||||
|
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
|
||||||
|
The icons are redistributed here as monochrome SVG path data with no
|
||||||
|
modifications beyond color normalization (fill="currentColor").
|
||||||
|
|
||||||
|
Per the Font Awesome Free license (https://fontawesome.com/license/free):
|
||||||
|
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
|
||||||
|
commercial projects, open source projects, or really almost whatever you want.
|
||||||
|
Attribution is required by MIT, SIL OFL, and CC BY licenses."
|
||||||
|
|
||||||
|
Brand icons are trademarks of their respective owners and are used for
|
||||||
|
identification purposes only; their use does not imply endorsement.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
Playnite — the `playnite` mark in assets/launcher-icons/ is the Playnite logo from the
|
||||||
|
Playnite source repository (media/playnite-logo-black.svg).
|
||||||
|
|
||||||
|
Copyright (c) 2020 Josef Nemec (https://github.com/JosefNemec/Playnite)
|
||||||
|
|
||||||
|
Licensed under the MIT License:
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in the
|
||||||
|
Software without restriction, including without limitation the rights to use, copy,
|
||||||
|
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||||
|
and to permit persons to whom the Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||||
|
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||||
|
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||||
|
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
Modifications: the colour was normalized to fill="currentColor"; the original viewBox
|
||||||
|
(0 0 1024 1024) and path geometry are unchanged.
|
||||||
|
|
||||||
|
Brand icons are trademarks of their respective owners and are used for identification
|
||||||
|
purposes only; their use does not imply endorsement.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Simple Icons — brand icons (lutris, heroic, epic, gog in assets/launcher-icons/) are
|
||||||
|
from Simple Icons
|
||||||
|
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||||
|
|
||||||
|
Upstream slugs: lutris, heroicgameslauncher, epicgames, gogdotcom.
|
||||||
|
|
||||||
|
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||||
|
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||||
|
required; this notice is provided for provenance.
|
||||||
|
|
||||||
|
Brand icons are trademarks of their respective owners and are used for
|
||||||
|
identification purposes only; their use does not imply endorsement. See
|
||||||
|
https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Launcher icon masters
|
||||||
|
|
||||||
|
The brand marks a **launcher tile** draws — the entries a library plugin publishes with
|
||||||
|
`role: "launcher"` (design D4), which open Steam Big Picture or Heroic or Playnite rather
|
||||||
|
than a game. One file per **icon token**, the value a plugin puts in an entry's `icon`
|
||||||
|
field and every client resolves against the set it ships.
|
||||||
|
|
||||||
|
| token | mark | emitted by | source |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `steam` | Steam | punktfunk-plugin-steam (Big Picture + desktop) | Font Awesome Free brands (CC BY 4.0) |
|
||||||
|
| `lutris` | Lutris | punktfunk-plugin-lutris | Simple Icons (CC0 1.0) |
|
||||||
|
| `heroic` | Heroic Games Launcher | punktfunk-plugin-heroic | Simple Icons (CC0 1.0, slug `heroicgameslauncher`) |
|
||||||
|
| `playnite` | Playnite | punktfunk-plugin-playnite | JosefNemec/Playnite (MIT) |
|
||||||
|
| `epic` | Epic Games | punktfunk-plugin-epic — **dormant** | Simple Icons (CC0 1.0, slug `epicgames`) |
|
||||||
|
| `gog` | GOG.com | punktfunk-plugin-gog — **dormant** | Simple Icons (CC0 1.0, slug `gogdotcom`) |
|
||||||
|
| `xbox` | Xbox | punktfunk-plugin-xbox — **dormant** | Font Awesome Free brands (CC BY 4.0) |
|
||||||
|
|
||||||
|
The last three are **dormant on purpose**: those plugins carry a `launcher` config switch that
|
||||||
|
is off by default and whose `launcherEntries` returns nothing, because the host has no verified
|
||||||
|
`launcher_ui` activation for them yet — a tile would be a card that does nothing. Their marks
|
||||||
|
ship anyway so that turning one on stays the one-line plugin change those plugins promise,
|
||||||
|
instead of also needing a release of all six clients.
|
||||||
|
|
||||||
|
`steam` is the same mark as `assets/os-icons/steam.svg`, generated from that file rather than
|
||||||
|
re-sourced, so the SteamOS host badge and the Steam launcher tile can never drift apart.
|
||||||
|
|
||||||
|
## Why a token and not the icon itself
|
||||||
|
|
||||||
|
A plugin sends the **name** of a mark, never its bytes, and never a URL.
|
||||||
|
|
||||||
|
The obvious alternative — a plugin ships its own `icon.svg` and the host's art proxy serves it —
|
||||||
|
is closed by construction, and deliberately: `local_art_bytes` serves what the bytes *are*
|
||||||
|
(`sniff_image_type`, `crates/punktfunk-host/src/library/art.rs`), and SVG is not on that list
|
||||||
|
because it is script-capable XML and the web console renders library art in a browser. Widening
|
||||||
|
that sniff to admit SVG would trade a rendering nicety for a stored-XSS surface.
|
||||||
|
|
||||||
|
Sending a token instead keeps that refusal intact and buys three things a proxied image could
|
||||||
|
not have given us anyway: the glyph stays vector at every tile size a client picks, it takes the
|
||||||
|
tile's own ink instead of arriving pre-coloured, and it costs no fetch, no cache and no bytes on
|
||||||
|
a reconcile that is already body-limited.
|
||||||
|
|
||||||
|
The cost is that a **third-party** plugin cannot ship a mark no client bundles. Its tile falls
|
||||||
|
back to the launcher's name on an accent face — exactly what every launcher tile looked like
|
||||||
|
before this existed — and the fix is a pull request adding the master here.
|
||||||
|
|
||||||
|
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Those
|
||||||
|
viewBoxes are not all square (`0 0 24 24`, `0 0 496 512`, `0 0 1024 1024`), so **a client must
|
||||||
|
letterbox rather than stretch** — a mark drawn to a square box is a squashed mark.
|
||||||
|
|
||||||
|
## Regenerating the per-client derivatives
|
||||||
|
|
||||||
|
`bash scripts/gen-launcher-icons.sh [token ...]` turns a master into the three baked forms (GTK
|
||||||
|
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
|
||||||
|
that inline it (web console, Android, the in-session console UI). Adding a **new** token also
|
||||||
|
means adding it to each client's shipped-token list — the script prints that checklist too.
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
|
||||||
|
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
|
||||||
|
are used here nominatively — to *identify* the launcher a tile opens, the standard practice in
|
||||||
|
this ecosystem — and imply no affiliation or endorsement.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>
|
||||||
|
After Width: | Height: | Size: 957 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 956 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -84,7 +84,11 @@ class GamepadPalette(
|
|||||||
// pure black too: the calm mix on the form screens lifts toward nothing. What is
|
// pure black too: the calm mix on the form screens lifts toward nothing. What is
|
||||||
// left is a faint indigo→violet ember in the bright corner. The accent stays the
|
// left is a faint indigo→violet ember in the bright corner. The accent stays the
|
||||||
// brand violet — focus has to be findable on black.
|
// brand violet — focus has to be findable on black.
|
||||||
"oled", "OLED",
|
// Named for the look, not the panel technology — black with a thin violet corona
|
||||||
|
// belongs beside Nebula and Abyss. ⚠ The ID stays "oled": it is the stored
|
||||||
|
// `ui_palette` value and the cross-client key, so renaming it would orphan saved
|
||||||
|
// choices and desync the clients.
|
||||||
|
"oled", "Eclipse",
|
||||||
listOf(
|
listOf(
|
||||||
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
|
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
|
||||||
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
|
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.pager.PageSize
|
|||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -54,6 +55,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
|
import io.unom.punktfunk.components.launcherIcon
|
||||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||||
import io.unom.punktfunk.kit.library.GameEntry
|
import io.unom.punktfunk.kit.library.GameEntry
|
||||||
import io.unom.punktfunk.kit.library.LibraryClient
|
import io.unom.punktfunk.kit.library.LibraryClient
|
||||||
@@ -367,16 +369,28 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
|||||||
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
|
// A launcher ships no poster by design, so its brand mark IS the poster — drawn big and
|
||||||
// would read as "a game whose cover failed to load".
|
// centred, tinted like the text it replaces. Falling back to the launcher's name says
|
||||||
Text(
|
// "opens Steam" for a mark we don't ship; the title would read as "a game whose cover
|
||||||
if (game.isLauncher) game.storeLabel else game.title,
|
// failed to load".
|
||||||
style = MaterialTheme.typography.titleMedium,
|
val mark = launcherIcon(game.iconToken)
|
||||||
fontWeight = FontWeight.SemiBold,
|
if (mark != null) {
|
||||||
color = ink.fg(0.75f),
|
Icon(
|
||||||
textAlign = TextAlign.Center,
|
imageVector = mark,
|
||||||
modifier = Modifier.padding(12.dp),
|
contentDescription = game.title,
|
||||||
)
|
tint = ink.fg(0.75f),
|
||||||
|
modifier = Modifier.fillMaxSize(0.45f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
if (game.isLauncher) game.storeLabel else game.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = ink.fg(0.75f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package io.unom.punktfunk.components
|
||||||
|
|
||||||
|
// GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.
|
||||||
|
// Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.
|
||||||
|
// Per-mark provenance and licensing: assets/launcher-icons/README.md.
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.PathParser
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
|
||||||
|
* Material ships no brand icons, so this is a curated registry — the sibling of [OsIcons],
|
||||||
|
* which does the equivalent job for the host cards.
|
||||||
|
*
|
||||||
|
* Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds
|
||||||
|
* the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT
|
||||||
|
* all square, so the builder letterboxes — a mark forced into a square box is a squashed mark.
|
||||||
|
*/
|
||||||
|
private class LauncherGlyph(
|
||||||
|
val viewportWidth: Float,
|
||||||
|
val viewportHeight: Float,
|
||||||
|
val d: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val GLYPHS: Map<String, LauncherGlyph> = mapOf(
|
||||||
|
"steam" to LauncherGlyph(
|
||||||
|
viewportWidth = 496f,
|
||||||
|
viewportHeight = 512f,
|
||||||
|
d = "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
|
||||||
|
),
|
||||||
|
"lutris" to LauncherGlyph(
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f,
|
||||||
|
d = "m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z",
|
||||||
|
),
|
||||||
|
"heroic" to LauncherGlyph(
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f,
|
||||||
|
d = "M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z",
|
||||||
|
),
|
||||||
|
"playnite" to LauncherGlyph(
|
||||||
|
viewportWidth = 1024f,
|
||||||
|
viewportHeight = 1024f,
|
||||||
|
d = "M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z",
|
||||||
|
),
|
||||||
|
"epic" to LauncherGlyph(
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f,
|
||||||
|
d = "M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z",
|
||||||
|
),
|
||||||
|
"gog" to LauncherGlyph(
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f,
|
||||||
|
d = "M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z",
|
||||||
|
),
|
||||||
|
"xbox" to LauncherGlyph(
|
||||||
|
viewportWidth = 512f,
|
||||||
|
viewportHeight = 512f,
|
||||||
|
d = "M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val CACHE = HashMap<String, ImageVector>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark
|
||||||
|
* this build ships no art for — the caller then falls back to naming the launcher, which is
|
||||||
|
* what every launcher tile looked like before the token existed.
|
||||||
|
*
|
||||||
|
* Tinted by the caller via `tint`, so one mark serves every palette.
|
||||||
|
*/
|
||||||
|
fun launcherIcon(token: String?): ImageVector? {
|
||||||
|
val glyph = GLYPHS[token ?: return null] ?: return null
|
||||||
|
return CACHE.getOrPut(token) {
|
||||||
|
// Square the box and centre the mark in it, so a wide or tall master keeps its aspect
|
||||||
|
// ratio instead of being stretched to the tile.
|
||||||
|
val side = max(glyph.viewportWidth, glyph.viewportHeight)
|
||||||
|
val dx = (side - glyph.viewportWidth) / 2f
|
||||||
|
val dy = (side - glyph.viewportHeight) / 2f
|
||||||
|
ImageVector.Builder(
|
||||||
|
name = "launcher_$token",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = side,
|
||||||
|
viewportHeight = side,
|
||||||
|
).apply {
|
||||||
|
addGroup(translationX = dx, translationY = dy)
|
||||||
|
addPath(
|
||||||
|
pathData = PathParser().parsePathString(glyph.d).toNodes(),
|
||||||
|
fill = SolidColor(Color.White),
|
||||||
|
)
|
||||||
|
clearGroup()
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
|||||||
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
|
* 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
|
* 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).
|
* game rather than break the decode (design D4).
|
||||||
|
*
|
||||||
|
* [icon] is the token for the entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||||
|
* Null on every older host and on every ordinary title.
|
||||||
*/
|
*/
|
||||||
data class GameEntry(
|
data class GameEntry(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -51,12 +54,25 @@ data class GameEntry(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val art: Artwork,
|
val art: Artwork,
|
||||||
val role: String? = null,
|
val role: String? = null,
|
||||||
|
val icon: String? = null,
|
||||||
) {
|
) {
|
||||||
val isCustom: Boolean get() = store == "custom"
|
val isCustom: Boolean get() = store == "custom"
|
||||||
|
|
||||||
/** Whether this entry opens a launcher rather than a game. */
|
/** Whether this entry opens a launcher rather than a game. */
|
||||||
val isLauncher: Boolean get() = role == "launcher"
|
val isLauncher: Boolean get() = role == "launcher"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The brand-icon token, re-validated rather than taken on trust.
|
||||||
|
*
|
||||||
|
* The host checks the shape on the way in, so this only fires for a host older than that
|
||||||
|
* check or one that isn't ours. It costs a scan of a short string and means no consumer has
|
||||||
|
* to wonder what it is about to look up.
|
||||||
|
*/
|
||||||
|
val iconToken: String? get() = icon?.takeIf { t ->
|
||||||
|
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
|
||||||
|
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display name for the store badge — the same table the other clients use
|
* 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
|
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
|
||||||
@@ -151,6 +167,7 @@ object LibraryClient {
|
|||||||
hero = resolveArt(str(art, "hero"), base),
|
hero = resolveArt(str(art, "hero"), base),
|
||||||
),
|
),
|
||||||
role = str(o, "role"),
|
role = str(o, "role"),
|
||||||
|
icon = str(o, "icon"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "glur",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/joogps/Glur.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
@@ -16,6 +16,17 @@ let package = Package(
|
|||||||
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
|
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
|
||||||
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
||||||
],
|
],
|
||||||
|
dependencies: [
|
||||||
|
// Progressive (gradient) backdrop blur for the form screens' trays — a real blur with no
|
||||||
|
// material tint stage (see GamepadTrayBlur). Pinned by REVISION, not `from:`: the
|
||||||
|
// GlurBackdrop product exists only on main — no release carries it (the newest tag,
|
||||||
|
// `1.1`, predates it, and is not three-component semver anyway, so version-based
|
||||||
|
// resolution stops at 1.0.4). The revision is main's head at adoption time; a revision
|
||||||
|
// pin stays reproducible when the branch moves.
|
||||||
|
.package(
|
||||||
|
url: "https://github.com/joogps/Glur.git",
|
||||||
|
revision: "ba4f05d3c9a608ec773b9305f2af6089390de68a"),
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
||||||
// No dependencies by design — an extension process links this alone.
|
// No dependencies by design — an extension process links this alone.
|
||||||
@@ -39,6 +50,10 @@ let package = Package(
|
|||||||
// licensing in that README). `.process` compiles the catalog; loaded via
|
// licensing in that README). `.process` compiles the catalog; loaded via
|
||||||
// OsIcon.swift.
|
// OsIcon.swift.
|
||||||
.process("Resources/OsIcons.xcassets"),
|
.process("Resources/OsIcons.xcassets"),
|
||||||
|
// The launcher tiles' brand marks (template vector imagesets generated from the
|
||||||
|
// assets/launcher-icons masters by scripts/gen-launcher-icons.sh — per-mark
|
||||||
|
// provenance and licensing in that README). Loaded via LauncherIcon.swift.
|
||||||
|
.process("Resources/LauncherIcons.xcassets"),
|
||||||
],
|
],
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
// Rust staticlib system deps.
|
// Rust staticlib system deps.
|
||||||
@@ -51,7 +66,12 @@ let package = Package(
|
|||||||
// (The tvOS slide-transition package is referenced by the Xcode PROJECT only —
|
// (The tvOS slide-transition package is referenced by the Xcode PROJECT only —
|
||||||
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
||||||
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
||||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
.executableTarget(
|
||||||
|
name: "PunktfunkClient",
|
||||||
|
dependencies: [
|
||||||
|
"PunktfunkKit",
|
||||||
|
.product(name: "GlurBackdrop", package: "Glur"),
|
||||||
|
]),
|
||||||
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
|
||||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||||
.testTarget(
|
.testTarget(
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
||||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
||||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||||
|
EE0000000000000000000012 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000002 /* Glur */; };
|
||||||
|
EE0000000000000000000013 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000003 /* GlurBackdrop */; };
|
||||||
|
EE0000000000000000000014 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000004 /* Glur */; };
|
||||||
|
EE0000000000000000000015 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000005 /* GlurBackdrop */; };
|
||||||
|
EE0000000000000000000016 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000006 /* Glur */; };
|
||||||
|
EE0000000000000000000017 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000007 /* GlurBackdrop */; };
|
||||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
||||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
||||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
@@ -88,6 +94,8 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
AA0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
AA0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||||
|
EE0000000000000000000012 /* Glur in Frameworks */,
|
||||||
|
EE0000000000000000000013 /* GlurBackdrop in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -96,6 +104,8 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
BB0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||||
|
EE0000000000000000000014 /* Glur in Frameworks */,
|
||||||
|
EE0000000000000000000015 /* GlurBackdrop in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -105,6 +115,8 @@
|
|||||||
files = (
|
files = (
|
||||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */,
|
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */,
|
||||||
|
EE0000000000000000000016 /* Glur in Frameworks */,
|
||||||
|
EE0000000000000000000017 /* GlurBackdrop in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -175,6 +187,8 @@
|
|||||||
name = Punktfunk;
|
name = Punktfunk;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
AA0000000000000000000006 /* PunktfunkKit */,
|
AA0000000000000000000006 /* PunktfunkKit */,
|
||||||
|
EE0000000000000000000002 /* Glur */,
|
||||||
|
EE0000000000000000000003 /* GlurBackdrop */,
|
||||||
);
|
);
|
||||||
productName = Punktfunk;
|
productName = Punktfunk;
|
||||||
productReference = AA0000000000000000000001 /* Punktfunk.app */;
|
productReference = AA0000000000000000000001 /* Punktfunk.app */;
|
||||||
@@ -201,6 +215,8 @@
|
|||||||
name = "Punktfunk-iOS";
|
name = "Punktfunk-iOS";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
BB0000000000000000000006 /* PunktfunkKit */,
|
BB0000000000000000000006 /* PunktfunkKit */,
|
||||||
|
EE0000000000000000000004 /* Glur */,
|
||||||
|
EE0000000000000000000005 /* GlurBackdrop */,
|
||||||
);
|
);
|
||||||
productName = "Punktfunk-iOS";
|
productName = "Punktfunk-iOS";
|
||||||
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
|
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
|
||||||
@@ -226,6 +242,8 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
CC0000000000000000000006 /* PunktfunkKit */,
|
CC0000000000000000000006 /* PunktfunkKit */,
|
||||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */,
|
DD0000000000000000000002 /* SwiftUINavigationTransitions */,
|
||||||
|
EE0000000000000000000006 /* Glur */,
|
||||||
|
EE0000000000000000000007 /* GlurBackdrop */,
|
||||||
);
|
);
|
||||||
productName = "Punktfunk-tvOS";
|
productName = "Punktfunk-tvOS";
|
||||||
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
||||||
@@ -283,6 +301,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
AA000000000000000000000F /* XCLocalSwiftPackageReference "." */,
|
AA000000000000000000000F /* XCLocalSwiftPackageReference "." */,
|
||||||
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */,
|
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */,
|
||||||
|
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = AA0000000000000000000008 /* Products */;
|
productRefGroup = AA0000000000000000000008 /* Products */;
|
||||||
@@ -848,6 +867,14 @@
|
|||||||
minimumVersion = 0.18.0;
|
minimumVersion = 0.18.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/joogps/Glur.git";
|
||||||
|
requirement = {
|
||||||
|
kind = revision;
|
||||||
|
revision = ba4f05d3c9a608ec773b9305f2af6089390de68a;
|
||||||
|
};
|
||||||
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
@@ -868,6 +895,36 @@
|
|||||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||||
productName = SwiftUINavigationTransitions;
|
productName = SwiftUINavigationTransitions;
|
||||||
};
|
};
|
||||||
|
EE0000000000000000000002 /* Glur */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||||
|
productName = Glur;
|
||||||
|
};
|
||||||
|
EE0000000000000000000003 /* GlurBackdrop */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||||
|
productName = GlurBackdrop;
|
||||||
|
};
|
||||||
|
EE0000000000000000000004 /* Glur */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||||
|
productName = Glur;
|
||||||
|
};
|
||||||
|
EE0000000000000000000005 /* GlurBackdrop */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||||
|
productName = GlurBackdrop;
|
||||||
|
};
|
||||||
|
EE0000000000000000000006 /* Glur */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||||
|
productName = Glur;
|
||||||
|
};
|
||||||
|
EE0000000000000000000007 /* GlurBackdrop */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||||
|
productName = GlurBackdrop;
|
||||||
|
};
|
||||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = PunktfunkShared;
|
productName = PunktfunkShared;
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "5d17a752eb57d190a90cbd663718ff44034b24fe0ae1baafea7677db2d49da6f",
|
"originHash" : "bb1ce9bc6042f166bd0aad78a15081e673781d3a90fa52fd8ec8a08875878ef6",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "glur",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/joogps/Glur.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "objc-runtime-tools",
|
"identity" : "objc-runtime-tools",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Configurable Home-Screen / Lock-Screen library widget (kind "PunktfunkLibrary"). The user picks
|
||||||
|
// a saved host in the widget's configuration (long-press → Edit Widget — the picker is
|
||||||
|
// `HostEntity`'s query over the shared App-Group store, running in this extension process); a tap
|
||||||
|
// deep-links into that host's game library via `punktfunk://browse/<uuid>` — the app's onOpenURL
|
||||||
|
// routes it to the same library presentation every internal surface drives. No session starts
|
||||||
|
// until a title is picked there.
|
||||||
|
//
|
||||||
|
// Unconfigured, it follows the most recently connected host (the same order the hosts widget
|
||||||
|
// leads with). A configured host that no longer exists shows the empty state rather than silently
|
||||||
|
// following a different host — a widget that says "Studio" must never open someone else's library.
|
||||||
|
//
|
||||||
|
// Timeline is a single `.never` entry — the app pushes reloads on store changes (HostStore →
|
||||||
|
// WidgetCenter.reloadTimelines), exactly like the hosts widget.
|
||||||
|
|
||||||
|
import AppIntents
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
|
// MARK: - Configuration intent
|
||||||
|
|
||||||
|
/// The widget's per-instance configuration. Executes in the EXTENSION process — which is why
|
||||||
|
/// `HostEntity` and its query live in PunktfunkShared, not the app.
|
||||||
|
struct LibraryWidgetConfigIntent: WidgetConfigurationIntent {
|
||||||
|
static let title: LocalizedStringResource = "Choose Host"
|
||||||
|
static let description = IntentDescription("Pick whose game library this widget opens.")
|
||||||
|
|
||||||
|
@Parameter(title: "Host", description: "Leave empty to follow your most recent host.")
|
||||||
|
var host: HostEntity?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timeline
|
||||||
|
|
||||||
|
struct LibraryEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
/// The resolved target: the configured host if it still exists, the most recent one when
|
||||||
|
/// unconfigured, nil when there's nothing to open (empty store, or a removed configured host).
|
||||||
|
let host: StoredHost?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LibraryProvider: AppIntentTimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> LibraryEntry {
|
||||||
|
LibraryEntry(date: .now, host: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(for configuration: LibraryWidgetConfigIntent, in context: Context) async
|
||||||
|
-> LibraryEntry {
|
||||||
|
LibraryEntry(date: .now, host: Self.resolve(configuration.host))
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for configuration: LibraryWidgetConfigIntent, in context: Context) async
|
||||||
|
-> Timeline<LibraryEntry> {
|
||||||
|
// Single entry, never auto-refresh: the app reloads this timeline on every store change.
|
||||||
|
Timeline(entries: [LibraryEntry(date: .now, host: Self.resolve(configuration.host))],
|
||||||
|
policy: .never)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The configured host by id — nil (NOT a fallback) when it's gone; most-recent when nothing
|
||||||
|
/// was configured.
|
||||||
|
static func resolve(_ configured: HostEntity?) -> StoredHost? {
|
||||||
|
let hosts = HostsProvider.loadHosts() // shared-suite JSON, most-recent first
|
||||||
|
guard let configured else { return hosts.first }
|
||||||
|
return hosts.first { $0.id == configured.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget
|
||||||
|
|
||||||
|
struct LibraryWidget: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: "PunktfunkLibrary", intent: LibraryWidgetConfigIntent.self,
|
||||||
|
provider: LibraryProvider()
|
||||||
|
) { entry in
|
||||||
|
LibraryWidgetView(entry: entry)
|
||||||
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Game Library")
|
||||||
|
.description("Jump straight into a host's game library.")
|
||||||
|
.supportedFamilies([.systemSmall, .accessoryCircular, .accessoryRectangular])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Views
|
||||||
|
|
||||||
|
/// Deep link that opens a stored host's library.
|
||||||
|
private func browseURL(_ host: StoredHost) -> URL {
|
||||||
|
DeepLink.browse(host: host.id).url
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LibraryWidgetView: View {
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
let entry: LibraryEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryCircular:
|
||||||
|
CircularLibraryView(host: entry.host)
|
||||||
|
case .accessoryRectangular:
|
||||||
|
RectangularLibraryView(host: entry.host)
|
||||||
|
default: // systemSmall + fallback
|
||||||
|
SmallLibraryView(host: entry.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SmallLibraryView: View {
|
||||||
|
let host: StoredHost?
|
||||||
|
var body: some View {
|
||||||
|
if let host {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Image(systemName: "square.grid.2x2.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(host.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text("Game Library")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.widgetURL(browseURL(host))
|
||||||
|
} else {
|
||||||
|
EmptyLibraryView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CircularLibraryView: View {
|
||||||
|
let host: StoredHost?
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
Image(systemName: "square.grid.2x2.fill")
|
||||||
|
}
|
||||||
|
.widgetURL(host.map(browseURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RectangularLibraryView: View {
|
||||||
|
let host: StoredHost?
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "square.grid.2x2.fill")
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(host?.displayName ?? "Punktfunk")
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("Library")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.widgetURL(host.map(browseURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EmptyLibraryView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: "square.grid.2x2")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Open Punktfunk to pick a host.")
|
||||||
|
.font(.caption)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews (Xcode canvas)
|
||||||
|
//
|
||||||
|
// Same pattern as the hosts widget: `#Preview(as:widget:timeline:)` feeds sample entries directly,
|
||||||
|
// so the canvas works without a paired device or saved hosts. The small preview's second entry
|
||||||
|
// shows the empty state one timeline click away.
|
||||||
|
|
||||||
|
private let previewHost = StoredHost(
|
||||||
|
name: "Studio", address: "192.168.1.20",
|
||||||
|
lastConnected: .now.addingTimeInterval(-40 * 60))
|
||||||
|
|
||||||
|
#Preview("Small", as: .systemSmall) {
|
||||||
|
LibraryWidget()
|
||||||
|
} timeline: {
|
||||||
|
LibraryEntry(date: .now, host: previewHost)
|
||||||
|
LibraryEntry(date: .now, host: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Lock Screen circular", as: .accessoryCircular) {
|
||||||
|
LibraryWidget()
|
||||||
|
} timeline: {
|
||||||
|
LibraryEntry(date: .now, host: previewHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
|
||||||
|
LibraryWidget()
|
||||||
|
} timeline: {
|
||||||
|
LibraryEntry(date: .now, host: previewHost)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import WidgetKit
|
|||||||
struct PunktfunkWidgetBundle: WidgetBundle {
|
struct PunktfunkWidgetBundle: WidgetBundle {
|
||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
HostsWidget()
|
HostsWidget()
|
||||||
|
LibraryWidget()
|
||||||
PunktfunkSessionLiveActivity()
|
PunktfunkSessionLiveActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ struct ContentView: View {
|
|||||||
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
||||||
/// the session model's published state below; iPhone/iPad only.
|
/// the session model's published state below; iPhone/iPad only.
|
||||||
@State private var liveActivity = SessionActivityController()
|
@State private var liveActivity = SessionActivityController()
|
||||||
|
/// The window's bottom safe-area inset (the home-indicator strip), reported by
|
||||||
|
/// DisplayBottomInsetProbe from UIKit's own callbacks and published as
|
||||||
|
/// `\.displayBottomInset` for the screens that pin a legend to the display's corner. Held
|
||||||
|
/// HERE and read through the environment because asking UIKit for it during a body severs
|
||||||
|
/// the asking view's updates on device (see the probe).
|
||||||
|
@State private var displayBottomInset: CGFloat = 0
|
||||||
#endif
|
#endif
|
||||||
@State private var pairingTarget: StoredHost?
|
@State private var pairingTarget: StoredHost?
|
||||||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||||
@@ -115,6 +121,23 @@ struct ContentView: View {
|
|||||||
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers — resign-active
|
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers — resign-active
|
||||||
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
|
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
#if os(iOS)
|
||||||
|
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||||
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// The gamepad UI's form-metric tier for this window, published from HERE — the app's root.
|
||||||
|
/// A screen that applies `gamepadPaletteInk` itself sits ABOVE its own copy of the environment,
|
||||||
|
/// so its `@Environment` resolves against its parent; publishing at the root is what makes
|
||||||
|
/// every one of them (including the ones presented as sheets and covers, which inherit the
|
||||||
|
/// environment) read its own window's tier instead of the bare default.
|
||||||
|
private var gamepadMetrics: GamepadFormMetrics {
|
||||||
|
#if os(iOS)
|
||||||
|
.forWindow(h: hSizeClass, v: vSizeClass)
|
||||||
|
#else
|
||||||
|
.platformDefault
|
||||||
|
#endif
|
||||||
|
}
|
||||||
private var gamepadUIActive: Bool {
|
private var gamepadUIActive: Bool {
|
||||||
GamepadUIEnvironment.isActive(
|
GamepadUIEnvironment.isActive(
|
||||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||||
@@ -181,6 +204,36 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var driven: some View {
|
private var driven: some View {
|
||||||
|
drivenBase
|
||||||
|
.environment(\.gamepadMetrics, gamepadMetrics)
|
||||||
|
#if os(iOS)
|
||||||
|
.environment(\.displayBottomInset, displayBottomInset)
|
||||||
|
// The probe is UIKit's, not any screen's: mounted once here as a background so the
|
||||||
|
// legend-pinning screens can READ the inset from the environment without ever asking
|
||||||
|
// UIKit during their own body (which severs their updates — see the probe).
|
||||||
|
.background {
|
||||||
|
DisplayBottomInsetProbe { displayBottomInset = $0 }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
// The console's own modal, over WHICHEVER screen is up. Not attached to `home`, which
|
||||||
|
// renders only while `model.connection == nil`: a connection exists through the
|
||||||
|
// pair-required and approval handshakes, which is precisely when these prompts fire.
|
||||||
|
// It sits above the connect takeover too — the delegated-approval wait is raised
|
||||||
|
// DURING a dial and owns the only Cancel for it. (The takeover draws nothing in that
|
||||||
|
// state: `connectingOverlayName` is nil while `awaitingApproval` is set, so the two
|
||||||
|
// never poll the pad at once.)
|
||||||
|
.overlay {
|
||||||
|
if let prompt = consolePrompt {
|
||||||
|
GamepadPromptView(prompt: prompt)
|
||||||
|
.gamepadPaletteInk()
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var drivenBase: some View {
|
||||||
Group {
|
Group {
|
||||||
// The stream view's structural identity MUST be stable across the
|
// The stream view's structural identity MUST be stable across the
|
||||||
// awaiting-trust → streaming transition: recreating it restarts the pump,
|
// awaiting-trust → streaming transition: recreating it restarts the pump,
|
||||||
@@ -368,8 +421,21 @@ struct ContentView: View {
|
|||||||
// (the "Pair with PIN instead" path disconnects first — the host's accept loop
|
// (the "Pair with PIN instead" path disconnects first — the host's accept loop
|
||||||
// is sequential, a pairing connection would queue behind the live session).
|
// is sequential, a pairing connection would queue behind the live session).
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.sheet(item: $pairingTarget) { host in
|
// macOS presents BOTH pairing UIs from here, picking by mode (the console UI's screen is
|
||||||
|
// gamepad-navigable; PairSheet's Form is not). iOS hides this sheet in gamepad mode
|
||||||
|
// instead — there the pair screen is one of the shell's in-place layers, exactly like
|
||||||
|
// settings and add-host (see `touchPairingTarget`).
|
||||||
|
.sheet(item: touchPairingTarget) { host in
|
||||||
|
#if os(macOS)
|
||||||
|
if gamepadUIActive {
|
||||||
|
GamepadPairView(host: host, onPaired: { handlePaired(host, fingerprint: $0) })
|
||||||
|
.frame(width: 660, height: 620)
|
||||||
|
} else {
|
||||||
|
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||||
|
}
|
||||||
|
#else
|
||||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.sheet(item: $speedTestTarget) { host in
|
.sheet(item: $speedTestTarget) { host in
|
||||||
SpeedTestSheet(host: host)
|
SpeedTestSheet(host: host)
|
||||||
@@ -409,9 +475,102 @@ struct ContentView: View {
|
|||||||
// budget (inline, they tip SwiftUI's per-expression limit — see the split sections idiom).
|
// budget (inline, they tip SwiftUI's per-expression limit — see the split sections idiom).
|
||||||
|
|
||||||
private var deepLinkNoticePresented: Binding<Bool> {
|
private var deepLinkNoticePresented: Binding<Bool> {
|
||||||
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
|
Binding(
|
||||||
|
get: { deepLinkNotice != nil && !consolePromptShowing },
|
||||||
|
set: { if !$0 { deepLinkNotice = nil } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True while the console prompt owns the modal state (see `consolePrompt`). Always false on
|
||||||
|
/// tvOS, whose alerts the focus engine drives natively.
|
||||||
|
private var consolePromptShowing: Bool {
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
consolePrompt != nil
|
||||||
|
#else
|
||||||
|
false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
/// The modal state the console UI should present ITSELF, as a pad-navigable prompt, instead of
|
||||||
|
/// letting a system alert take it. `.alert`/`.confirmationDialog` are UIKit/AppKit surfaces a
|
||||||
|
/// controller cannot navigate, and these are not incidental prompts: "Pairing required" is the
|
||||||
|
/// FIRST thing an unpaired host shows, "Connection failed" strands the console UI behind a
|
||||||
|
/// modal only a finger can dismiss, and "Waiting for approval" owns the only Cancel for a
|
||||||
|
/// connect that may never complete. One at a time, most-urgent first — a system alert stack
|
||||||
|
/// would layer these, but a console shows one screen.
|
||||||
|
///
|
||||||
|
/// Gated on not STREAMING, not on `model.connection == nil`: a connection object exists well
|
||||||
|
/// before a stream does, through exactly the handshakes these prompts belong to. Streaming is
|
||||||
|
/// the one case that must stay with the system alert — there the pad belongs to
|
||||||
|
/// `GamepadCapture` and is being forwarded to the host.
|
||||||
|
private var consolePrompt: GamepadPrompt? {
|
||||||
|
guard gamepadUIActive, model.phase != .streaming else { return nil }
|
||||||
|
if let req = approvalChoice {
|
||||||
|
return GamepadPrompt(
|
||||||
|
id: "pairing-required",
|
||||||
|
title: "Pairing required",
|
||||||
|
message: "\(req.host.displayName) requires pairing. Request access and approve "
|
||||||
|
+ "this device in the host's web console (port 47992 → Pairing) — no PIN "
|
||||||
|
+ "needed. Or pair with the 4-digit PIN it can display.",
|
||||||
|
actions: [
|
||||||
|
// The follow-on presentation is deferred a tick exactly as the system dialog
|
||||||
|
// does it, so this prompt is fully torn down before the next screen mounts —
|
||||||
|
// two controller pollers overlapping for a frame is how one A press reaches
|
||||||
|
// both.
|
||||||
|
GamepadPromptAction(id: "request", title: "Request Access", isPrimary: true) {
|
||||||
|
approvalChoice = nil
|
||||||
|
DispatchQueue.main.async { requestAccess(req) }
|
||||||
|
},
|
||||||
|
GamepadPromptAction(id: "pin", title: "Pair with PIN…") {
|
||||||
|
approvalChoice = nil
|
||||||
|
DispatchQueue.main.async { pairingTarget = req.host }
|
||||||
|
},
|
||||||
|
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
|
||||||
|
approvalChoice = nil
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if let req = awaitingApproval {
|
||||||
|
return GamepadPrompt(
|
||||||
|
id: "awaiting-approval",
|
||||||
|
title: "Waiting for approval",
|
||||||
|
message: "Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s "
|
||||||
|
+ "web console (port 47992 → Pairing). This device connects automatically "
|
||||||
|
+ "once you approve it — no need to reconnect.",
|
||||||
|
actions: [
|
||||||
|
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
|
||||||
|
awaitingApproval = nil
|
||||||
|
model.disconnect()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
busy: true)
|
||||||
|
}
|
||||||
|
if connectionErrorReady {
|
||||||
|
return GamepadPrompt(
|
||||||
|
id: "connection-failed",
|
||||||
|
title: "Connection failed",
|
||||||
|
message: model.errorMessage ?? "",
|
||||||
|
actions: [
|
||||||
|
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
|
||||||
|
model.errorMessage = nil
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if let notice = deepLinkNotice {
|
||||||
|
return GamepadPrompt(
|
||||||
|
id: "cant-open",
|
||||||
|
title: "Can't open",
|
||||||
|
message: notice,
|
||||||
|
actions: [
|
||||||
|
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
|
||||||
|
deepLinkNotice = nil
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
|
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
|
||||||
/// the library in place (see the cover's comment).
|
/// the library in place (see the cover's comment).
|
||||||
private var touchLibraryTarget: Binding<StoredHost?> {
|
private var touchLibraryTarget: Binding<StoredHost?> {
|
||||||
@@ -420,19 +579,37 @@ struct ContentView: View {
|
|||||||
set: { libraryTarget = $0 })
|
set: { libraryTarget = $0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pairing sheet's item. On iOS it hides while the gamepad shell presents the pair screen
|
||||||
|
/// in place — the same proxy the library uses, and for the same reason: every writer keeps
|
||||||
|
/// writing `pairingTarget`, and whichever presentation the current mode owns picks it up.
|
||||||
|
/// macOS has no shell, so the sheet stays and switches its CONTENT by mode instead.
|
||||||
|
private var touchPairingTarget: Binding<StoredHost?> {
|
||||||
|
#if os(macOS)
|
||||||
|
Binding(get: { pairingTarget }, set: { pairingTarget = $0 })
|
||||||
|
#else
|
||||||
|
Binding(
|
||||||
|
get: { gamepadUIActive ? nil : pairingTarget },
|
||||||
|
set: { pairingTarget = $0 })
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private var approvalChoicePresented: Binding<Bool> {
|
private var approvalChoicePresented: Binding<Bool> {
|
||||||
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
|
Binding(
|
||||||
|
get: { approvalChoice != nil && !consolePromptShowing },
|
||||||
|
set: { if !$0 { approvalChoice = nil } })
|
||||||
}
|
}
|
||||||
|
|
||||||
private var awaitingApprovalPresented: Binding<Bool> {
|
private var awaitingApprovalPresented: Binding<Bool> {
|
||||||
Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
|
Binding(
|
||||||
|
get: { awaitingApproval != nil && !consolePromptShowing },
|
||||||
|
set: { if !$0 { awaitingApproval = nil } })
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connectionErrorPresented: Binding<Bool> {
|
/// Whether the "Connection failed" state is ready to be shown at all — shared by the system
|
||||||
Binding(
|
/// alert and the console prompt so the two can never disagree about the macOS deferral below.
|
||||||
get: {
|
private var connectionErrorReady: Bool {
|
||||||
guard model.errorMessage != nil else { return false }
|
guard model.errorMessage != nil else { return false }
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
|
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
|
||||||
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
|
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
|
||||||
// presenting it now strands the window fullscreen on the home screen after a
|
// presenting it now strands the window fullscreen on the home screen after a
|
||||||
@@ -441,10 +618,14 @@ struct ContentView: View {
|
|||||||
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
|
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
|
||||||
// over the windowed home UI. Not gated when fullscreen is the user's own manual
|
// over the windowed home UI. Not gated when fullscreen is the user's own manual
|
||||||
// choice (opt-out setting) — nothing is auto-exiting there to conflict with.
|
// choice (opt-out setting) — nothing is auto-exiting there to conflict with.
|
||||||
if fullscreenForSession && isFullscreen { return false }
|
if fullscreenForSession && isFullscreen { return false }
|
||||||
#endif
|
#endif
|
||||||
return true
|
return true
|
||||||
},
|
}
|
||||||
|
|
||||||
|
private var connectionErrorPresented: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { connectionErrorReady && !consolePromptShowing },
|
||||||
set: { if !$0 { model.errorMessage = nil } })
|
set: { if !$0 { model.errorMessage = nil } })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,10 +668,20 @@ struct ContentView: View {
|
|||||||
?? "That link is malformed and was ignored."
|
?? "That link is malformed and was ignored."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard link.route == .connect else {
|
switch link.route {
|
||||||
// `wake` and `browse` are reserved in the grammar and parse today; this build routes
|
case .connect:
|
||||||
// neither, and saying so beats silently connecting instead.
|
break
|
||||||
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
|
case .browse:
|
||||||
|
// The reserved library route, now real: open the host's game library without starting
|
||||||
|
// a session. `launch=`/`profile=` are meaningless on a browse (nothing streams until a
|
||||||
|
// title is picked, and that connect resolves its own profile) — ignored, not refused,
|
||||||
|
// per the unknown-parameter rule.
|
||||||
|
openLibrary(from: link)
|
||||||
|
return
|
||||||
|
case .wake:
|
||||||
|
// Still reserved: saying so beats silently connecting instead. (Shortcuts users have
|
||||||
|
// the Wake Host intent, which never round-trips through a URL.)
|
||||||
|
deepLinkNotice = "Punktfunk links can't do “wake” yet."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
|
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
|
||||||
@@ -544,6 +735,38 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `punktfunk://browse/<host-ref>` — jump into a host's game library. Drives the SAME
|
||||||
|
/// `libraryTarget` every internal surface writes, so the link lands in whichever presentation
|
||||||
|
/// the current mode owns: the gamepad console's in-place library screen, the touch cover, the
|
||||||
|
/// macOS sheet, or tvOS's cover. Connect's posture minus the connect itself: a pin conflict
|
||||||
|
/// refuses, a live session is never preempted, and an unsaved host can't be browsed — the
|
||||||
|
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
|
||||||
|
/// is saved (the notice says what to do instead).
|
||||||
|
private func openLibrary(from link: DeepLink) {
|
||||||
|
switch link.resolveHost(in: store.hosts) {
|
||||||
|
case .known(let host):
|
||||||
|
guard !link.pinConflict(with: host) else {
|
||||||
|
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
|
||||||
|
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard model.phase == .idle else {
|
||||||
|
let current = model.activeHost?.displayName ?? "a host"
|
||||||
|
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
libraryTarget = host
|
||||||
|
case .unknown(let address, _, let name, _):
|
||||||
|
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
|
||||||
|
+ "Add it with the + button first — a library can only be browsed on a saved host."
|
||||||
|
case .ambiguous:
|
||||||
|
deepLinkNotice = "More than one saved host is called “\(link.hostRef)”. "
|
||||||
|
+ "Rename one, or link to it by its address."
|
||||||
|
case .unresolvable:
|
||||||
|
deepLinkNotice = "That host isn't saved on this device."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var home: some View {
|
private var home: some View {
|
||||||
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
|
||||||
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
// still `home`, so it covers the whole dial → wake → online → connect sequence): instant
|
||||||
@@ -576,9 +799,11 @@ struct ContentView: View {
|
|||||||
if gamepadUIActive {
|
if gamepadUIActive {
|
||||||
GamepadHomeView(
|
GamepadHomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
libraryTarget: $libraryTarget, waker: waker,
|
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
|
||||||
|
onPaired: handlePaired, waker: waker,
|
||||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||||
launchTitle: launchTitle)
|
launchTitle: launchTitle,
|
||||||
|
promptActive: consolePromptShowing)
|
||||||
} else {
|
} else {
|
||||||
HomeView(
|
HomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
@@ -593,9 +818,11 @@ struct ContentView: View {
|
|||||||
if gamepadUIActive {
|
if gamepadUIActive {
|
||||||
GamepadHomeView(
|
GamepadHomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
libraryTarget: $libraryTarget, waker: waker,
|
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
|
||||||
|
onPaired: handlePaired, waker: waker,
|
||||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||||
launchTitle: launchTitle)
|
launchTitle: launchTitle,
|
||||||
|
promptActive: consolePromptShowing)
|
||||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||||
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import SwiftUI
|
|||||||
|
|
||||||
struct GamepadAddHostView: View {
|
struct GamepadAddHostView: View {
|
||||||
@Environment(\.gamepadInk) private var ink
|
@Environment(\.gamepadInk) private var ink
|
||||||
|
@Environment(\.gamepadMetrics) private var metrics
|
||||||
|
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||||
let onAdd: (StoredHost) -> Void
|
let onAdd: (StoredHost) -> Void
|
||||||
@@ -48,7 +50,7 @@ struct GamepadAddHostView: View {
|
|||||||
isActive: controllerActive && editing == nil
|
isActive: controllerActive && editing == nil
|
||||||
) { row, focused in
|
) { row, focused in
|
||||||
rowView(row, focused: focused)
|
rowView(row, focused: focused)
|
||||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
.frame(maxWidth: metrics.rowMaxWidth)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -61,25 +63,28 @@ struct GamepadAddHostView: View {
|
|||||||
if !compact {
|
if !compact {
|
||||||
Text("Hosts on this network appear automatically — add one by address "
|
Text("Hosts on this network appear automatically — add one by address "
|
||||||
+ "for everything else.")
|
+ "for everything else.")
|
||||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||||
.foregroundStyle(ink.fg(0.55))
|
.foregroundStyle(ink.fg(0.55))
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
|
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background { GamepadTrayScrim(edge: .top) }
|
.background { GamepadTrayBlur(edge: .top) }
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
bottomTray
|
bottomTray
|
||||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||||
.padding(.horizontal, compact ? 12 : 18)
|
.padding(.horizontal, compact ? 12 : 18)
|
||||||
.padding(.bottom, compact ? 12 : 18)
|
.padding(
|
||||||
|
.bottom,
|
||||||
|
gamepadLegendBottomPadding(
|
||||||
|
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||||
.padding(.top, compact ? 6 : 10)
|
.padding(.top, compact ? 6 : 10)
|
||||||
.background { GamepadTrayScrim(edge: .bottom) }
|
.background { GamepadTrayBlur(edge: .bottom) }
|
||||||
}
|
}
|
||||||
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
||||||
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
|
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
|
||||||
@@ -148,17 +153,28 @@ struct GamepadAddHostView: View {
|
|||||||
// binding on appear — new identity forces a rewire to the new field.
|
// binding on appear — new identity forces a rewire to the new field.
|
||||||
.id(editing)
|
.id(editing)
|
||||||
GamepadHintBar(hints: [
|
GamepadHintBar(hints: [
|
||||||
|
// "Type" names what A does to the key under the keyboard's cursor. There is
|
||||||
|
// no tap equivalent — a touch user types by tapping the keycap itself — so
|
||||||
|
// this one cell stays a label.
|
||||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||||
.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete"),
|
.init(
|
||||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
||||||
|
action: { backspace(editing) }),
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||||
|
action: { closeKeyboard() }),
|
||||||
])
|
])
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
} else {
|
} else {
|
||||||
GamepadHintBar(hints: [
|
GamepadHintBar(hints: [
|
||||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
|
.init(
|
||||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
||||||
|
action: { if let focusID { activate(id: focusID) } }),
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
|
||||||
|
action: { performClose() }),
|
||||||
])
|
])
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
@@ -191,7 +207,7 @@ struct GamepadAddHostView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||||
let m = GamepadFormMetrics.self
|
let m = metrics
|
||||||
return HStack(spacing: 14) {
|
return HStack(spacing: 14) {
|
||||||
if row.isAction {
|
if row.isAction {
|
||||||
Label("Add Host", systemImage: "plus.circle.fill")
|
Label("Add Host", systemImage: "plus.circle.fill")
|
||||||
@@ -268,6 +284,15 @@ struct GamepadAddHostView: View {
|
|||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The legend's Delete cell (iOS/macOS). Applied to the field's binding rather than routed
|
||||||
|
/// into `GamepadKeyboard`: the keyboard's X does exactly this to the same binding, and
|
||||||
|
/// reaching into its state to trigger it would need a whole callback channel for one edit.
|
||||||
|
private func backspace(_ id: String) {
|
||||||
|
let binding = editingBinding(id)
|
||||||
|
guard !binding.wrappedValue.isEmpty else { return }
|
||||||
|
binding.wrappedValue.removeLast()
|
||||||
|
}
|
||||||
|
|
||||||
private func editingBinding(_ id: String) -> Binding<String> {
|
private func editingBinding(_ id: String) -> Binding<String> {
|
||||||
switch id {
|
switch id {
|
||||||
case "name": return $name
|
case "name": return $name
|
||||||
|
|||||||
@@ -191,6 +191,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
.sensoryFeedback(.selection, trigger: cursor)
|
.sensoryFeedback(.selection, trigger: cursor)
|
||||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
// A hardware keyboard drives the same cursor as the pad — arrows step, Return activates,
|
||||||
|
// Esc backs out (iPad on a Magic Keyboard, couch Mac). tvOS routes arrows through the
|
||||||
|
// focus engine instead, which owns navigation there.
|
||||||
|
.gamepadKeyNavigation(
|
||||||
|
active: isActive,
|
||||||
|
onMove: { move($0) },
|
||||||
|
onConfirm: { activate() },
|
||||||
|
onBack: onBack)
|
||||||
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
reconcile()
|
reconcile()
|
||||||
wire()
|
wire()
|
||||||
@@ -432,8 +442,8 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card
|
/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card
|
||||||
/// itself only grows, since it is already facing you. Each card carries its own delay (see
|
/// itself only grows, since it is already facing you. Each card carries its own delay (see
|
||||||
/// `entrance(_:)`) — that stagger is what makes the strip read as one gesture instead of a
|
/// `entrance(_:)`) — that stagger is what makes the strip read as one gesture instead of a
|
||||||
/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede
|
/// simultaneous flash, and it is the same hinge language the coverflow's own recede speaks, so the
|
||||||
/// speaks, so the arrival and the scrolling feel like one object.
|
/// arrival and the scrolling feel like one object.
|
||||||
///
|
///
|
||||||
/// ⚠️ APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
|
/// ⚠️ APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
|
||||||
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
|
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
|
||||||
@@ -442,6 +452,23 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
/// collapsed into its focused look as the entrance ended — arriving as a jump. Underneath, the
|
/// collapsed into its focused look as the entrance ended — arriving as a jump. Underneath, the
|
||||||
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
|
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
|
||||||
///
|
///
|
||||||
|
/// ⚠️ NO `rotation3DEffect` HERE, however much the drum language invites one. It was the cause of
|
||||||
|
/// the strip's "flash as the cards settle": a real 3D transform renders the card through an
|
||||||
|
/// offscreen layer, and a card carries translucent glass, which resolves differently in there —
|
||||||
|
/// so every card sat at the wrong fill for as long as the master animation ran and then snapped
|
||||||
|
/// to its true one in a SINGLE frame the moment SwiftUI dropped that layer.
|
||||||
|
///
|
||||||
|
/// Measured on an iPad Pro 13": the centred tile held #4a3d87 for twelve frames in which nothing
|
||||||
|
/// moved, then stepped to #423970 (−23 blue) in one. It is the ANIMATION ending, not the motion:
|
||||||
|
/// stretching the timeline from 1.02 s to 2.82 s moved the step from 0.70 s to 2.50 s after the
|
||||||
|
/// launcher appeared — the same 0.32 s before the end both times. Removing the rotation removed
|
||||||
|
/// the step outright; `compositingGroup()` above or below the transforms did nothing.
|
||||||
|
///
|
||||||
|
/// So the turn is PROJECTED instead: `cos(angle)` as a horizontal squeeze is exactly the
|
||||||
|
/// orthographic projection of a Y-axis rotation, hinged on the edge the card fans from. Affine,
|
||||||
|
/// so no offscreen pass and no layer to drop — and it reads as the same gesture, losing only the
|
||||||
|
/// perspective trapezoid, which at these card sizes was never what sold the motion.
|
||||||
|
///
|
||||||
/// Transforms only — nothing here touches layout, so the scroll view's snapping and the tvOS
|
/// Transforms only — nothing here touches layout, so the scroll view's snapping and the tvOS
|
||||||
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
|
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
|
||||||
/// unstaggered cross-fade.
|
/// unstaggered cross-fade.
|
||||||
@@ -485,14 +512,15 @@ struct CardEntrance: ViewModifier, Animatable {
|
|||||||
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
|
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
|
||||||
// it instead of swinging the opposite way.
|
// it instead of swinging the opposite way.
|
||||||
let away = reduceMotion ? 0 : 1 - travel
|
let away = reduceMotion ? 0 : 1 - travel
|
||||||
|
// The turn, projected rather than rendered in 3D — see the type's note on the flash.
|
||||||
|
// `cos` of the angle IS the orthographic projection of a Y-axis rotation, and hinging it
|
||||||
|
// on the edge the card fans from restores the direction that the rotation's sign carried
|
||||||
|
// (cos is even, so the sign alone would read the same both ways).
|
||||||
|
let turn = cos(Angle.degrees(64 * away).radians)
|
||||||
return content
|
return content
|
||||||
.opacity(reduceMotion ? raw : fade)
|
.opacity(reduceMotion ? raw : fade)
|
||||||
.scaleEffect(1 - 0.26 * away)
|
.scaleEffect(1 - 0.26 * away)
|
||||||
.rotation3DEffect(
|
.scaleEffect(x: turn, y: 1, anchor: side < 0 ? .trailing : .leading)
|
||||||
.degrees(side * -64 * away),
|
|
||||||
axis: (x: 0, y: 1, z: 0),
|
|
||||||
anchor: .center,
|
|
||||||
perspective: 0.65)
|
|
||||||
.offset(y: 34 * away)
|
.offset(y: 34 * away)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,36 @@
|
|||||||
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS — where the same screens are driven by
|
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS — where the same screens are driven by
|
||||||
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
|
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
|
||||||
|
|
||||||
|
import Glur
|
||||||
|
import GlurBackdrop
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS) || os(tvOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
/// The glyph a button wears in a legend: the ACTIVE controller's own (Xbox "A", DualSense ✕, …)
|
||||||
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
|
/// via `sfSymbolsName` while one is attached, else the glyph of the last pad this device ever saw
|
||||||
|
/// (`GamepadManager.lastKnownKind` → `GamepadGlyphs`), else the caller's generic fallback.
|
||||||
|
///
|
||||||
|
/// The middle rung is the whole point. `active` is nil whenever the pad sleeps, disconnects or
|
||||||
|
/// runs flat — and permanently under `gamepadUIMode == "always"`, which puts the console UI up
|
||||||
|
/// with no pad by design — and the fallbacks are letter glyphs, so a DualSense user's ✕/◯ legends
|
||||||
|
/// used to turn into A/B the moment the controller dozed off. The remembered kind keeps the
|
||||||
|
/// legends speaking the pad the user actually owns. The `fallback` still covers the genuinely
|
||||||
|
/// unknown case: a fresh install that has never seen a controller, and any button outside the six
|
||||||
|
/// `GamepadButtonRole` names.
|
||||||
|
///
|
||||||
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
|
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
|
||||||
@MainActor
|
@MainActor
|
||||||
func buttonGlyph(
|
func buttonGlyph(
|
||||||
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
||||||
) -> String {
|
) -> String {
|
||||||
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
let manager = GamepadManager.shared
|
||||||
?? fallback
|
if let live = manager.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName {
|
||||||
|
return live
|
||||||
|
}
|
||||||
|
guard let role = GamepadButtonRole(keyPath: button) else { return fallback }
|
||||||
|
return GamepadGlyphs.symbol(role, for: manager.lastKnownKind)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher
|
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher
|
||||||
@@ -68,43 +84,251 @@ func gamepadTitleSize(compact: Bool) -> CGFloat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
|
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
|
||||||
/// GamepadAddHostView) — one set of numbers so the two screens read as the same surface,
|
/// GamepadAddHostView) — one set of numbers so the screens read as the same surface, at the size
|
||||||
/// sized for the couch on tvOS and for the hand elsewhere.
|
/// the screen they are on calls for.
|
||||||
enum GamepadFormMetrics {
|
///
|
||||||
#if os(tvOS)
|
/// Three tiers, not two. The phone numbers used to serve every non-TV device, so an iPad Pro drew
|
||||||
static let headerFont: CGFloat = 17
|
/// a settings list at iPhone scale in the middle of a 13" display — the field verdict was that the
|
||||||
static let labelFont: CGFloat = 23
|
/// sizing "does not adapt to larger screens". `pad` sits between the in-hand and 10-foot sets.
|
||||||
static let valueFont: CGFloat = 21
|
///
|
||||||
static let iconFont: CGFloat = 24
|
/// Chosen from the SIZE CLASSES rather than the device idiom, so an iPad running a narrow Stage
|
||||||
static let iconWidth: CGFloat = 40
|
/// Manager or Split View window correctly gets the in-hand numbers — the window is what the user
|
||||||
static let chevronFont: CGFloat = 16
|
/// is reading, not the panel it sits on.
|
||||||
static let rowHPad: CGFloat = 24
|
struct GamepadFormMetrics {
|
||||||
static let rowVPad: CGFloat = 19
|
/// Which set this is, for the few things that are a KIND of layout rather than a number.
|
||||||
static let rowCorner: CGFloat = 18
|
enum Tier { case phone, pad, tv }
|
||||||
static let rowMaxWidth: CGFloat = 920
|
|
||||||
static let detailFont: CGFloat = 19
|
let tier: Tier
|
||||||
static let bandWidth: CGFloat = 380
|
let headerFont: CGFloat
|
||||||
#else
|
let labelFont: CGFloat
|
||||||
static let headerFont: CGFloat = 12
|
let valueFont: CGFloat
|
||||||
static let labelFont: CGFloat = 16
|
let iconFont: CGFloat
|
||||||
static let valueFont: CGFloat = 15
|
let iconWidth: CGFloat
|
||||||
static let iconFont: CGFloat = 17
|
let chevronFont: CGFloat
|
||||||
static let iconWidth: CGFloat = 28
|
let rowHPad: CGFloat
|
||||||
static let chevronFont: CGFloat = 12
|
let rowVPad: CGFloat
|
||||||
static let rowHPad: CGFloat = 16
|
let rowCorner: CGFloat
|
||||||
static let rowVPad: CGFloat = 13
|
let rowMaxWidth: CGFloat
|
||||||
static let rowCorner: CGFloat = 14
|
let detailFont: CGFloat
|
||||||
static let rowMaxWidth: CGFloat = 620
|
|
||||||
static let detailFont: CGFloat = 13
|
|
||||||
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
|
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
|
||||||
static let bandWidth: CGFloat = 240
|
let bandWidth: CGFloat
|
||||||
|
/// The settings screen's section-tab pills.
|
||||||
|
let tabFont: CGFloat
|
||||||
|
/// The pinned controls legend (GamepadHintBar).
|
||||||
|
let hintGlyphFont: CGFloat
|
||||||
|
let hintTextFont: CGFloat
|
||||||
|
let hintPad: CGFloat
|
||||||
|
|
||||||
|
/// In-hand: a phone, or any window narrow enough to read like one.
|
||||||
|
static let phone = GamepadFormMetrics(
|
||||||
|
tier: .phone,
|
||||||
|
headerFont: 12, labelFont: 16, valueFont: 15, iconFont: 17, iconWidth: 28,
|
||||||
|
chevronFont: 12, rowHPad: 16, rowVPad: 13, rowCorner: 14, rowMaxWidth: 620,
|
||||||
|
detailFont: 13, bandWidth: 240,
|
||||||
|
tabFont: 13, hintGlyphFont: 19, hintTextFont: 14, hintPad: 13)
|
||||||
|
|
||||||
|
/// A tablet-sized window — an arm's length away rather than in the palm.
|
||||||
|
static let pad = GamepadFormMetrics(
|
||||||
|
tier: .pad,
|
||||||
|
headerFont: 14, labelFont: 20, valueFont: 19, iconFont: 21, iconWidth: 34,
|
||||||
|
chevronFont: 14, rowHPad: 20, rowVPad: 16, rowCorner: 16, rowMaxWidth: 820,
|
||||||
|
detailFont: 16, bandWidth: 320,
|
||||||
|
tabFont: 16, hintGlyphFont: 23, hintTextFont: 17, hintPad: 15)
|
||||||
|
|
||||||
|
/// 10-foot.
|
||||||
|
static let tv = GamepadFormMetrics(
|
||||||
|
tier: .tv,
|
||||||
|
headerFont: 17, labelFont: 23, valueFont: 21, iconFont: 24, iconWidth: 40,
|
||||||
|
chevronFont: 16, rowHPad: 24, rowVPad: 19, rowCorner: 18, rowMaxWidth: 920,
|
||||||
|
detailFont: 19, bandWidth: 380,
|
||||||
|
tabFont: 17, hintGlyphFont: 27, hintTextFont: 20, hintPad: 18)
|
||||||
|
|
||||||
|
/// What a screen gets before anything publishes a tier — and the only tier tvOS and macOS ever
|
||||||
|
/// use (an Apple TV is always 10-foot; a Mac window is read at desk distance).
|
||||||
|
static var platformDefault: GamepadFormMetrics {
|
||||||
|
#if os(tvOS)
|
||||||
|
tv
|
||||||
|
#else
|
||||||
|
phone
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// The tier a window's size classes call for. REGULAR on both axes is the tablet case.
|
||||||
|
static func forWindow(
|
||||||
|
h: UserInterfaceSizeClass?, v: UserInterfaceSizeClass?
|
||||||
|
) -> GamepadFormMetrics {
|
||||||
|
h == .regular && v == .regular ? .pad : .phone
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct GamepadMetricsKey: EnvironmentKey {
|
||||||
|
static let defaultValue = GamepadFormMetrics.platformDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
/// The form metrics for the screen currently drawing. Published from ContentView — the app
|
||||||
|
/// ROOT — rather than only from `gamepadPaletteInk`, because a screen that applies that
|
||||||
|
/// modifier itself sits ABOVE its own copy: its `@Environment` resolves against its parent, so
|
||||||
|
/// it would read the bare default instead of its own window's tier.
|
||||||
|
var gamepadMetrics: GamepadFormMetrics {
|
||||||
|
get { self[GamepadMetricsKey.self] }
|
||||||
|
set { self[GamepadMetricsKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DisplayBottomInsetKey: EnvironmentKey {
|
||||||
|
static let defaultValue: CGFloat = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
/// The display's bottom safe-area inset — the home-indicator strip — measured by
|
||||||
|
/// `DisplayBottomInsetProbe` and published from ContentView. 0 until UIKit's first callback
|
||||||
|
/// lands (the legend keeps its plain margin for that first frame) and always 0 on
|
||||||
|
/// macOS/tvOS, where nothing publishes it.
|
||||||
|
var displayBottomInset: CGFloat {
|
||||||
|
get { self[DisplayBottomInsetKey.self] }
|
||||||
|
set { self[DisplayBottomInsetKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// Reports the hosting window's bottom safe-area inset from UIKit's OWN callbacks — never
|
||||||
|
/// during a SwiftUI render.
|
||||||
|
///
|
||||||
|
/// This number has a history of wrong spellings, each failing silently:
|
||||||
|
/// - a `GeometryReader` carrying `.ignoresSafeArea()` — a proxy reports NO insets for an edge it
|
||||||
|
/// has been told to ignore, so that spelling can only ever answer 0;
|
||||||
|
/// - `.ignoresSafeArea(.container, edges: .bottom)` on `safeAreaInset` CONTENT, which does not
|
||||||
|
/// move content the inset mechanism itself placed; and
|
||||||
|
/// - asking UIKit for the key window (`UIApplication.shared.connectedScenes…`) DURING body,
|
||||||
|
/// which answered correctly and then KILLED the calling view: on an iPad (never the
|
||||||
|
/// simulator) the walk re-enters UIKit layout mid-render and the view's update graph is
|
||||||
|
/// silently severed — every later `@State` write lands in storage without ever re-running
|
||||||
|
/// `body` again, which is how Settings and Add Host stopped opening while their triggers
|
||||||
|
/// kept firing. No AttributeGraph warning, no log line; found by bisecting builds on glass.
|
||||||
|
/// So: UIKit tells THIS view when the window or its insets change, on UIKit's schedule, and the
|
||||||
|
/// answer hops out of the current update before anyone in SwiftUI reads it.
|
||||||
|
struct DisplayBottomInsetProbe: UIViewRepresentable {
|
||||||
|
let onChange: (CGFloat) -> Void
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> ProbeView {
|
||||||
|
let view = ProbeView()
|
||||||
|
view.onChange = onChange
|
||||||
|
// Mounted as a full-size `.background`; it must never eat a touch meant for the UI.
|
||||||
|
view.isUserInteractionEnabled = false
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ view: ProbeView, context: Context) {
|
||||||
|
view.onChange = onChange
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ProbeView: UIView {
|
||||||
|
var onChange: ((CGFloat) -> Void)?
|
||||||
|
private var last: CGFloat?
|
||||||
|
|
||||||
|
override func didMoveToWindow() {
|
||||||
|
super.didMoveToWindow()
|
||||||
|
report()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func safeAreaInsetsDidChange() {
|
||||||
|
super.safeAreaInsetsDidChange()
|
||||||
|
report()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation reshuffles the window's insets without necessarily touching this view's own.
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
report()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func report() {
|
||||||
|
// The WINDOW's inset, not this view's: the probe sits inside the safe area, so its
|
||||||
|
// own inset is 0 — the number the legend needs is the strip the window reserves.
|
||||||
|
guard let bottom = window?.safeAreaInsets.bottom, bottom != last else { return }
|
||||||
|
last = bottom
|
||||||
|
let onChange = onChange
|
||||||
|
// Out of the current UIKit/SwiftUI update before any state write.
|
||||||
|
DispatchQueue.main.async { onChange?(bottom) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// The bottom padding that puts a pinned legend the same distance from the bottom of the DISPLAY
|
||||||
|
/// as it sits from the leading edge — so it lands on the diagonal of the display's rounded corner,
|
||||||
|
/// which is what the corner asks for.
|
||||||
|
///
|
||||||
|
/// A `safeAreaInset` places its content INSIDE the safe area, so a plain margin stacks on top of
|
||||||
|
/// the device's own bottom inset and the pill ends up two to three times further from the bottom
|
||||||
|
/// than from the left. On a tablet this therefore goes NEGATIVE, pulling the pill back down
|
||||||
|
/// through the home-indicator strip; the pill is left-aligned and an iPad's indicator is a short
|
||||||
|
/// bar in the middle, so the two never meet.
|
||||||
|
///
|
||||||
|
/// Phones keep the plain margin. Their inset is the taller indicator bar and their legend runs
|
||||||
|
/// most of the width, so sitting it that low would cross the indicator rather than tuck beside it.
|
||||||
|
///
|
||||||
|
/// `displayBottom` is `\.displayBottomInset` — measured by `DisplayBottomInsetProbe`, NEVER asked
|
||||||
|
/// of UIKit here: this runs during body, and a key-window walk mid-render severs the calling
|
||||||
|
/// view's updates (see the probe's comment). Pure arithmetic only.
|
||||||
|
func gamepadLegendBottomPadding(
|
||||||
|
_ margin: CGFloat, tier: GamepadFormMetrics.Tier, displayBottom: CGFloat
|
||||||
|
) -> CGFloat {
|
||||||
|
guard tier == .pad else { return margin }
|
||||||
|
// Floored at -inset: at worst the pill sits flush with the physical edge, never past it.
|
||||||
|
return max(-displayBottom, margin - displayBottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tray gradient blur, back — as a real progressive BACKDROP blur this time.
|
||||||
|
///
|
||||||
|
/// GamepadTrayScrim did this with `.ultraThinMaterial`, and a material by definition lifts and
|
||||||
|
/// tints whatever it blurs: it read grey over the aurora, and washed with the palette's ground it
|
||||||
|
/// read coloured, which is why 2590238b deleted it. Glur's `GlurView` blurs the backdrop through
|
||||||
|
/// a gradient with NO material stage on top, so the rows soften as they slide under the pinned
|
||||||
|
/// title and legend and nothing carries a colour. It is the library's PRIVATE-API product
|
||||||
|
/// (`GlurBackdrop`) — the public `.glur()` modifier is a shader on a view's own content and
|
||||||
|
/// silently no-ops over platform-backed views like ScrollView, so it cannot reach a backdrop at
|
||||||
|
/// all. Hit testing is disabled inside GlurView; the band never eats a touch.
|
||||||
|
///
|
||||||
|
/// Mounted exactly where the scrim was: `.background` of each form screen's safe-area tray.
|
||||||
|
struct GamepadTrayBlur: View {
|
||||||
|
let edge: VerticalEdge
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// offset 0 puts the ramp's LITERAL ZERO exactly at the band's content edge, so nothing
|
||||||
|
// in the open field is touched — which is why, unlike the scrim, this band takes NO
|
||||||
|
// content-side overhang. The scrim's -44/-72 runway existed because a material carries
|
||||||
|
// body at every alpha and had to dissolve OUTSIDE the tray; carrying those numbers over
|
||||||
|
// here blurred fully-visible rows at rest (field verdict on the first cut). Full
|
||||||
|
// strength lands at 60% of the band, so the tray's own text always sits on the strong
|
||||||
|
// region while the ramp still reads as a gradient, not an edge.
|
||||||
|
GlurView(
|
||||||
|
radius: 14, offset: 0, interpolation: 0.6,
|
||||||
|
direction: edge == .top ? .up : .down)
|
||||||
|
// Full-bleed by LAYOUT, not `.ignoresSafeArea()`: safe-area expansion resolves a
|
||||||
|
// beat after insertion (outside any geometry group and outside this view's own
|
||||||
|
// transaction), which reads as a visible pop. 80 pt clears every inset on every
|
||||||
|
// device, and backgrounds never clip — the overhang simply draws.
|
||||||
|
.padding(edge == .top ? .top : .bottom, -80)
|
||||||
|
.padding(.horizontal, -80)
|
||||||
|
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
|
||||||
|
// geometry would ride the push's transaction and visibly grow into place. The
|
||||||
|
// layer's own fade/slide still carries the band; only its SHAPE is pinned.
|
||||||
|
.transaction { $0.animation = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One glyph + label cell in a hint bar.
|
/// One glyph + label cell in a hint bar.
|
||||||
struct GamepadHint: Identifiable {
|
struct GamepadHint: Identifiable {
|
||||||
let glyph: String
|
let glyph: String
|
||||||
let text: String
|
let text: String
|
||||||
|
/// What tapping/clicking this cell does — the same thing its button does. Optional because a
|
||||||
|
/// few legend cells NAME an input rather than an action ("↔ Adjust" is the stick itself;
|
||||||
|
/// there is no single thing a tap on it could mean), and those stay inert labels.
|
||||||
|
var action: (() -> Void)? = nil
|
||||||
var id: String { glyph + text }
|
var id: String { glyph + text }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,39 +338,75 @@ struct GamepadHint: Identifiable {
|
|||||||
/// the backdrop instead of dissolving into it.
|
/// the backdrop instead of dissolving into it.
|
||||||
struct GamepadHintBar: View {
|
struct GamepadHintBar: View {
|
||||||
@Environment(\.gamepadInk) private var ink
|
@Environment(\.gamepadInk) private var ink
|
||||||
|
/// Sized with the screen it pins to — a legend at phone scale on a 13" iPad is the same
|
||||||
|
/// mismatch the form rows had (see GamepadFormMetrics).
|
||||||
|
@Environment(\.gamepadMetrics) private var metrics
|
||||||
let hints: [GamepadHint]
|
let hints: [GamepadHint]
|
||||||
|
|
||||||
// 10-foot legend on tvOS, in-hand sizes elsewhere.
|
|
||||||
#if os(tvOS)
|
|
||||||
private static let glyphFont: CGFloat = 27
|
|
||||||
private static let textFont: CGFloat = 20
|
|
||||||
private static let pad: CGFloat = 18
|
|
||||||
#else
|
|
||||||
private static let glyphFont: CGFloat = 19
|
|
||||||
private static let textFont: CGFloat = 14
|
|
||||||
private static let pad: CGFloat = 13
|
|
||||||
#endif
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 18) {
|
HStack(spacing: 18) {
|
||||||
ForEach(hints) { hint in
|
ForEach(hints) { hint in
|
||||||
HStack(spacing: 7) {
|
cell(hint)
|
||||||
Image(systemName: hint.glyph)
|
|
||||||
.font(.system(size: Self.glyphFont))
|
|
||||||
.foregroundStyle(ink.fg)
|
|
||||||
Text(hint.text)
|
|
||||||
}
|
|
||||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
|
.font(.geist(metrics.hintTextFont, .semibold, relativeTo: .subheadline))
|
||||||
.foregroundStyle(ink.fg(0.85))
|
.foregroundStyle(ink.fg(0.85))
|
||||||
.padding(Self.pad)
|
.padding(metrics.hintPad)
|
||||||
.consoleGlass(Capsule())
|
.consoleGlass(Capsule())
|
||||||
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
|
// The hairline is DECORATION and sits on top of the cells, so it must never take a touch.
|
||||||
|
// Spelled out rather than left to defaults, because a swallowed touch in this bar is
|
||||||
|
// invisible — the legend simply stops doing anything.
|
||||||
|
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1).allowsHitTesting(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cell is a button where it has somewhere to go, and a plain label otherwise (see the type
|
||||||
|
/// comment for why tvOS is always the latter).
|
||||||
|
@ViewBuilder private func cell(_ hint: GamepadHint) -> some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
label(hint)
|
||||||
|
#else
|
||||||
|
if let action = hint.action {
|
||||||
|
Button(action: action) { label(hint) }
|
||||||
|
.buttonStyle(HintCellStyle())
|
||||||
|
.accessibilityLabel(hint.text)
|
||||||
|
} else {
|
||||||
|
label(hint)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func label(_ hint: GamepadHint) -> some View {
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Image(systemName: hint.glyph)
|
||||||
|
.font(.system(size: metrics.hintGlyphFont))
|
||||||
|
.foregroundStyle(ink.fg)
|
||||||
|
Text(hint.text)
|
||||||
|
}
|
||||||
|
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||||
|
// The tappable area covers the gap between glyph and label, not just their painted
|
||||||
|
// pixels — a legend cell is small enough already.
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
/// Press feedback for a legend cell. Deliberately quiet — the bar is chrome, and a cell that lit
|
||||||
|
/// up like a primary button would pull the eye off the content it describes.
|
||||||
|
///
|
||||||
|
/// `contentShape` sits BELOW the scale so the hit region stays the unscaled layout bounds: a press
|
||||||
|
/// animation that shrinks the artwork must never move the target out from under a resting finger,
|
||||||
|
/// or the touch-up lands outside and SwiftUI discards the tap.
|
||||||
|
private struct HintCellStyle: ButtonStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.opacity(configuration.isPressed ? 0.55 : 1)
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.94 : 1)
|
||||||
|
.animation(.smooth(duration: 0.14), value: configuration.isPressed)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind
|
/// The 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
|
/// 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
|
/// silk of colour whose control points wander on slow, out-of-phase sinusoids — finished with an
|
||||||
@@ -220,14 +480,20 @@ struct GamepadScreenBackground: View {
|
|||||||
colorField(at: t, palette: palette)
|
colorField(at: t, palette: palette)
|
||||||
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||||
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||||
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply…
|
// Calm = col·0.6 + ground·0.4. Over the OPAQUE ground beneath, `.opacity` already
|
||||||
|
// lerps toward it, so this layer alone IS the whole calm mix.
|
||||||
.opacity(1 - 0.4 * calmMix)
|
.opacity(1 - 0.4 * calmMix)
|
||||||
// …and a plusLighter wash of the palette's own ground IS the add. Chosen so the
|
// A further plusLighter wash of the ground, which lets a DARK palette's bright pools
|
||||||
// ground lands exactly where it was and the bright pools come down to meet it.
|
// come down to meet its ground rather than merely fading toward it.
|
||||||
// Mounted unconditionally — at opacity 0 a plusLighter layer contributes nothing,
|
//
|
||||||
// and an always-present layer is what lets the mix animate instead of popping.
|
// Suppressed on a pale palette (the factor goes to 0), because there it was destroying
|
||||||
|
// the setting: a pale ground is near-white, so ADDING 0.4 of it on top of a field
|
||||||
|
// already mixed 0.4 toward that same ground saturated the form screens to flat white —
|
||||||
|
// the field ask was "in bright mode the sub-screens are basically just white". Written
|
||||||
|
// as a factor rather than an `if` so the layer stays mounted and the calm chase keeps
|
||||||
|
// animating instead of popping when a screen is pushed.
|
||||||
Self.color(palette.ground)
|
Self.color(palette.ground)
|
||||||
.opacity(0.4 * calmMix)
|
.opacity(0.4 * calmMix * (palette.light ? 0 : 1))
|
||||||
.blendMode(.plusLighter)
|
.blendMode(.plusLighter)
|
||||||
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
|
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
|
||||||
// pooled light. Soft (extends past the frame) so the corners deepen rather than
|
// pooled light. Soft (extends past the frame) so the corners deepen rather than
|
||||||
@@ -363,59 +629,6 @@ private struct LegacyBlobField: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
|
|
||||||
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
|
|
||||||
/// render interleaved. Pure blur — a dark material faded out by a gradient mask, no dark tint — so
|
|
||||||
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
|
|
||||||
struct GamepadTrayScrim: View {
|
|
||||||
let edge: VerticalEdge
|
|
||||||
@Environment(\.gamepadInk) private var ink
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
|
|
||||||
let toContent: UnitPoint = edge == .top ? .bottom : .top
|
|
||||||
Rectangle()
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
// Force the frost to match the PALETTE, not the system appearance: the tray exists
|
|
||||||
// to keep the pinned title legible, so it has to frost dark under white ink and
|
|
||||||
// light under dark ink.
|
|
||||||
.environment(\.colorScheme, ink.isLight ? .light : .dark)
|
|
||||||
// Sink the material's grey luminance lift toward the palette's shade (black on a
|
|
||||||
// dark field — field ask: the frost read GREY over the aurora). Inside the mask, so
|
|
||||||
// the tint dissolves with the blur.
|
|
||||||
.overlay(ink.shade(0.35))
|
|
||||||
// Fade the whole blur out toward the content so it dissolves rather than ending on a
|
|
||||||
// line. The strong region sits deep (0.65) because the first stretch of the gradient
|
|
||||||
// now runs over the fixed 80 pt outer overhang below.
|
|
||||||
.mask {
|
|
||||||
LinearGradient(
|
|
||||||
stops: [
|
|
||||||
.init(color: .black, location: 0),
|
|
||||||
.init(color: .black.opacity(0.92), location: 0.65),
|
|
||||||
.init(color: .clear, location: 1),
|
|
||||||
],
|
|
||||||
startPoint: fromEdge, endPoint: toContent)
|
|
||||||
}
|
|
||||||
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
|
||||||
// text always sits on the strong part, rows blur out before they reach it. The bottom
|
|
||||||
// gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and
|
|
||||||
// the field verdict on the short reach was rows colliding visibly with the legend.
|
|
||||||
.padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72)
|
|
||||||
// Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a
|
|
||||||
// beat after insertion (outside any geometry group and outside this view's own
|
|
||||||
// transaction), which is exactly the pop the field kept seeing — vertically first,
|
|
||||||
// then, once the vertical runway became padding, on the X axis alone (the landscape
|
|
||||||
// side insets). 80 pt clears every inset on every device; backgrounds never clip,
|
|
||||||
// so the overhang simply draws.
|
|
||||||
.padding(edge == .top ? .top : .bottom, -80)
|
|
||||||
.padding(.horizontal, -80)
|
|
||||||
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
|
|
||||||
// geometry would ride the push's transaction and visibly grow into place. The
|
|
||||||
// layer's own fade/slide still carries the scrim; only its SHAPE is pinned.
|
|
||||||
.transaction { $0.animation = nil }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
|
/// 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
|
/// 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
|
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
|
||||||
|
|||||||
@@ -65,10 +65,24 @@ private struct HomeTile: Identifiable {
|
|||||||
|
|
||||||
struct GamepadHomeView: View {
|
struct GamepadHomeView: View {
|
||||||
@Environment(\.gamepadInk) private var ink
|
@Environment(\.gamepadInk) private var ink
|
||||||
|
/// Published by ContentView at the app ROOT, so this reads its own window's tier — this screen
|
||||||
|
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
|
||||||
|
@Environment(\.gamepadMetrics) private var metrics
|
||||||
|
/// The home-indicator strip's height, measured by DisplayBottomInsetProbe and published from
|
||||||
|
/// ContentView — an environment READ is safe in body; asking UIKit for it here is not (see
|
||||||
|
/// the probe's comment: a key-window walk mid-render severed this very view's updates).
|
||||||
|
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||||
@ObservedObject var store: HostStore
|
@ObservedObject var store: HostStore
|
||||||
@ObservedObject var model: SessionModel
|
@ObservedObject var model: SessionModel
|
||||||
@ObservedObject var discovery: HostDiscovery
|
@ObservedObject var discovery: HostDiscovery
|
||||||
@Binding var libraryTarget: StoredHost?
|
@Binding var libraryTarget: StoredHost?
|
||||||
|
/// The host awaiting a PIN ceremony, if any. Owned by ContentView (a connect attempt sets it,
|
||||||
|
/// as does the trust card's "Pair with PIN instead"), presented here as a shell screen —
|
||||||
|
/// PairSheet's `Form` is unreachable with a controller on iOS/macOS, which made pairing the
|
||||||
|
/// one thing a console-UI user simply could not do. See GamepadPairView.
|
||||||
|
@Binding var pairingTarget: StoredHost?
|
||||||
|
/// Pin the verified fingerprint and connect — ContentView's `handlePaired`.
|
||||||
|
let onPaired: (StoredHost, Data) -> Void
|
||||||
/// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's
|
/// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's
|
||||||
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
|
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
|
||||||
@ObservedObject var waker: HostWaker
|
@ObservedObject var waker: HostWaker
|
||||||
@@ -77,6 +91,11 @@ struct GamepadHomeView: View {
|
|||||||
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
|
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
|
||||||
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
|
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
|
||||||
let launchTitle: (StoredHost, String) -> Void
|
let launchTitle: (StoredHost, String) -> Void
|
||||||
|
/// A console prompt (GamepadPromptView) is up over the home — it polls the same controller, so
|
||||||
|
/// this screen must stand down for as long as it is. Same handoff contract as the connect
|
||||||
|
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
|
||||||
|
/// modal and a single A press reaches both.
|
||||||
|
var promptActive = false
|
||||||
|
|
||||||
/// The profile catalog — pinned host+profile combos render as their own tiles here, which is
|
/// The profile catalog — pinned host+profile combos render as their own tiles here, which is
|
||||||
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
|
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
|
||||||
@@ -213,19 +232,35 @@ struct GamepadHomeView: View {
|
|||||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||||
GamepadHintBar(hints: hints)
|
legend
|
||||||
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
|
||||||
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
|
|
||||||
.padding(.leading, compact ? 12 : 18)
|
|
||||||
.padding(.bottom, compact ? 12 : 18)
|
|
||||||
.padding(.top, compact ? 4 : 8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pinned controls legend, sitting the SAME distance from the leading and bottom edges of
|
||||||
|
/// the DISPLAY — see `gamepadLegendBottomPadding` for why the bottom number is not simply the
|
||||||
|
/// margin, and why measuring the inset (rather than trying to opt out of it) is what finally
|
||||||
|
/// worked.
|
||||||
|
private var legend: some View {
|
||||||
|
GamepadHintBar(hints: hints)
|
||||||
|
.padding(.leading, legendMargin)
|
||||||
|
.padding(
|
||||||
|
.bottom,
|
||||||
|
gamepadLegendBottomPadding(
|
||||||
|
legendMargin, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||||
|
.padding(.top, compact ? 4 : 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The legend pill's distance from the screen's leading and bottom edges.
|
||||||
|
private var legendMargin: CGFloat { compact ? 12 : 18 }
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
/// The screen the shell shows over the launcher — derived from the same triggers every
|
/// The screen the shell shows over the launcher — derived from the same triggers every
|
||||||
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
|
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
|
||||||
private var topScreen: GamepadScreen? {
|
private var topScreen: GamepadScreen? {
|
||||||
|
// Pairing leads: it is a ceremony blocking a connect the user already asked for, and it
|
||||||
|
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
|
||||||
|
// it has to win. Backing out of it reveals whatever it interrupted.
|
||||||
|
if let host = pairingTarget { return .pair(host) }
|
||||||
if showSettings { return .settings }
|
if showSettings { return .settings }
|
||||||
if showAddHost { return .addHost }
|
if showAddHost { return .addHost }
|
||||||
if let host = libraryTarget { return .library(host) }
|
if let host = libraryTarget { return .library(host) }
|
||||||
@@ -248,6 +283,12 @@ struct GamepadHomeView: View {
|
|||||||
onAdd: { store.add($0) },
|
onAdd: { store.add($0) },
|
||||||
close: { if !transitioning { showAddHost = false } },
|
close: { if !transitioning { showAddHost = false } },
|
||||||
controllerActive: active)
|
controllerActive: active)
|
||||||
|
case .pair(let host):
|
||||||
|
GamepadPairView(
|
||||||
|
host: host,
|
||||||
|
onPaired: { onPaired(host, $0) },
|
||||||
|
close: { if !transitioning { pairingTarget = nil } },
|
||||||
|
controllerActive: active)
|
||||||
case .library(let host):
|
case .library(let host):
|
||||||
GamepadLibraryScreen(
|
GamepadLibraryScreen(
|
||||||
store: store, host: host,
|
store: store, host: host,
|
||||||
@@ -294,11 +335,14 @@ struct GamepadHomeView: View {
|
|||||||
/// transition's input drop, during which NOBODY polls.
|
/// transition's input drop, during which NOBODY polls.
|
||||||
private var homeOwnsController: Bool {
|
private var homeOwnsController: Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
topScreen == nil && !transitioning
|
topScreen == nil && !transitioning && !promptActive
|
||||||
&& waker.waking == nil && model.phase != .connecting
|
&& waker.waking == nil && model.phase != .connecting
|
||||||
#else
|
#else
|
||||||
libraryTarget == nil && !showSettings && !showAddHost
|
// `pairingTarget` too: macOS presents the pair screen as a sheet and tvOS as a cover, and
|
||||||
&& waker.waking == nil && model.phase != .connecting
|
// either way the launcher underneath must stop consuming the pad — the pair screen's own
|
||||||
|
// list is polling the same controller.
|
||||||
|
libraryTarget == nil && pairingTarget == nil && !showSettings && !showAddHost
|
||||||
|
&& !promptActive && waker.waking == nil && model.phase != .connecting
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,13 +456,22 @@ struct GamepadHomeView: View {
|
|||||||
case .rescan: "Rescan"
|
case .rescan: "Rescan"
|
||||||
default: nil
|
default: nil
|
||||||
}
|
}
|
||||||
|
// Every cell's action re-resolves the selection when it FIRES rather than closing over the
|
||||||
|
// one this render saw: the legend is rebuilt on selection changes, but a tap landing in
|
||||||
|
// the same frame as a carousel move would otherwise activate the tile that was selected a
|
||||||
|
// moment ago — the one failure mode a launcher cannot afford.
|
||||||
var hints = [GamepadHint(
|
var hints = [GamepadHint(
|
||||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||||
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"),
|
||||||
|
action: { tiles.first { $0.id == selection }?.activate() })]
|
||||||
if libraryEnabled, selected?.hasLibrary == true {
|
if libraryEnabled, selected?.hasLibrary == true {
|
||||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
hints.append(.init(
|
||||||
|
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
|
||||||
|
action: { openLibraryForSelected() }))
|
||||||
}
|
}
|
||||||
hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings"))
|
hints.append(.init(
|
||||||
|
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
|
||||||
|
action: { showSettings = true }))
|
||||||
return hints
|
return hints
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,11 +626,21 @@ private struct GamepadHostTile: View {
|
|||||||
}
|
}
|
||||||
.padding(Self.pad)
|
.padding(Self.pad)
|
||||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||||
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
// Console tile — a brand wash marks a saved host as primary; discovered / Add-Host tiles
|
||||||
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
// stay neutral with a dashed edge. The surface clips to the shape itself.
|
||||||
|
//
|
||||||
|
// `forceMaterial`: these tiles are the one console surface that gets TRANSFORMED while it
|
||||||
|
// animates — `CardEntrance` swings each card in on a `rotation3DEffect` under an opacity
|
||||||
|
// ramp, and the carousel's `.scrollTransition` keeps scaling and rotating the neighbours
|
||||||
|
// forever after. Liquid Glass samples the backdrop through its own layer and cannot do
|
||||||
|
// that under a 3D transform, so it drew one way through the swing and snapped to another
|
||||||
|
// as the card landed — on glass it read as the tiles being swapped out for different ones
|
||||||
|
// at the end of their entrance. A material composites flat, so the card looks the same at
|
||||||
|
// every frame of the travel. (tvOS already takes this path for its own reasons.)
|
||||||
.consoleGlass(
|
.consoleGlass(
|
||||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
||||||
tint: tile.filled ? ink.accent(0.20) : nil)
|
tint: tile.filled ? ink.accent(0.20) : nil,
|
||||||
|
forceMaterial: true)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Hardware-keyboard navigation for the gamepad UI (iOS/iPadOS/macOS): arrows move, Return/Space
|
||||||
|
// activate, Esc backs out.
|
||||||
|
//
|
||||||
|
// Asked for by a field user on an iPad ("select games with keyboard arrows, enter to launch"). An
|
||||||
|
// iPad on a Magic Keyboard and a couch Mac are the same situation the console layout was built
|
||||||
|
// for — a screen driven from a distance with a fixed set of directional inputs — and the whole
|
||||||
|
// navigation model (a cursor, a confirm, a back) already exists here for the controller. A
|
||||||
|
// keyboard is just a third input onto it, alongside the pad poll and touch.
|
||||||
|
//
|
||||||
|
// tvOS is excluded: the focus engine already routes hardware-keyboard arrows into focus moves
|
||||||
|
// there, and these screens hand it navigation authority on purpose.
|
||||||
|
//
|
||||||
|
// The view must be FOCUSED to receive key presses, so this takes focus on appear. That is safe on
|
||||||
|
// exactly these screens because the gamepad UI has no system text fields to steal it from —
|
||||||
|
// GamepadKeyboard is a custom grid of keycaps, not a `TextField`.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Route arrows / Return / Esc into the same handlers the controller poll drives.
|
||||||
|
///
|
||||||
|
/// `active` mirrors the caller's `isActive` controller gate: a screen that has handed the pad
|
||||||
|
/// to something on top must not keep eating key presses either, or a covered launcher
|
||||||
|
/// navigates behind the screen in front of it.
|
||||||
|
func gamepadKeyNavigation(
|
||||||
|
active: Bool = true,
|
||||||
|
onMove: @escaping (GamepadMenuInput.Direction) -> Void,
|
||||||
|
onConfirm: @escaping () -> Void,
|
||||||
|
onBack: (() -> Void)? = nil
|
||||||
|
) -> some View {
|
||||||
|
modifier(GamepadKeyNav(active: active, onMove: onMove, onConfirm: onConfirm, onBack: onBack))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GamepadKeyNav: ViewModifier {
|
||||||
|
let active: Bool
|
||||||
|
let onMove: (GamepadMenuInput.Direction) -> Void
|
||||||
|
let onConfirm: () -> Void
|
||||||
|
let onBack: (() -> Void)?
|
||||||
|
|
||||||
|
@FocusState private var focused: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.focusable(active)
|
||||||
|
// No focus ring: these screens draw their own cursor (the centred card, the focused
|
||||||
|
// row), and a system ring around the whole scroll view on top of it reads as a bug.
|
||||||
|
.focusEffectDisabled()
|
||||||
|
.focused($focused)
|
||||||
|
// Claim focus on appear, and re-claim it whenever this screen becomes the active one
|
||||||
|
// again — a pushed screen popping off leaves the one underneath unfocused.
|
||||||
|
.onAppear { focused = active }
|
||||||
|
.onChange(of: active) { _, nowActive in
|
||||||
|
if nowActive { focused = true }
|
||||||
|
}
|
||||||
|
.onKeyPress(.upArrow) { handle { onMove(.up) } }
|
||||||
|
.onKeyPress(.downArrow) { handle { onMove(.down) } }
|
||||||
|
.onKeyPress(.leftArrow) { handle { onMove(.left) } }
|
||||||
|
.onKeyPress(.rightArrow) { handle { onMove(.right) } }
|
||||||
|
.onKeyPress(.return) { handle(onConfirm) }
|
||||||
|
.onKeyPress(.space) { handle(onConfirm) }
|
||||||
|
.onKeyPress(.escape) {
|
||||||
|
guard let onBack else { return .ignored }
|
||||||
|
return handle(onBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a handler only while this screen owns input, and report back whether the press was
|
||||||
|
/// consumed. `.ignored` matters: an unhandled Esc still has to reach the `.cancelAction`
|
||||||
|
/// shortcut that closes a macOS sheet (see GamepadAddHostView's hidden Cancel button).
|
||||||
|
private func handle(_ action: () -> Void) -> KeyPress.Result {
|
||||||
|
guard active else { return .ignored }
|
||||||
|
action()
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -38,7 +38,7 @@ struct GamepadLibraryScreen: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||||
.background { GamepadTrayScrim(edge: .top) }
|
.background { GamepadTrayBlur(edge: .top) }
|
||||||
}
|
}
|
||||||
// A hardware keyboard's Esc still closes, without chrome.
|
// A hardware keyboard's Esc still closes, without chrome.
|
||||||
.background {
|
.background {
|
||||||
|
|||||||
@@ -119,6 +119,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
.sensoryFeedback(.selection, trigger: adjustTick)
|
.sensoryFeedback(.selection, trigger: adjustTick)
|
||||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
// Hardware keyboard: up/down step the focus bar, left/right adjust the focused row's
|
||||||
|
// value (exactly what the stick does), Return activates, Esc backs out.
|
||||||
|
.gamepadKeyNavigation(
|
||||||
|
active: isActive,
|
||||||
|
onMove: { direction in
|
||||||
|
switch direction {
|
||||||
|
case .up: step(by: -1)
|
||||||
|
case .down: step(by: 1)
|
||||||
|
case .left: adjust(by: -1)
|
||||||
|
case .right: adjust(by: 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfirm: { activate() },
|
||||||
|
onBack: onBack)
|
||||||
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
reconcile()
|
reconcile()
|
||||||
wire()
|
wire()
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// The gamepad UI's answer to a system alert / confirmation dialog (iOS/iPadOS/macOS).
|
||||||
|
//
|
||||||
|
// `.alert` and `.confirmationDialog` are UIKit/AppKit surfaces. A game controller cannot move
|
||||||
|
// through their buttons or press one — so on iOS/macOS every prompt in the connect path was a dead
|
||||||
|
// end for a pad-only user, and they are not incidental prompts:
|
||||||
|
//
|
||||||
|
// - "Pairing required" (Request Access / Pair with PIN…) is the FIRST thing an unpaired host
|
||||||
|
// shows. Pairing was unreachable before it even got to the PIN.
|
||||||
|
// - "Connection failed" strands the console UI behind a modal only a finger can dismiss.
|
||||||
|
// - "Waiting for approval" owns the only Cancel for a connect that may never complete.
|
||||||
|
//
|
||||||
|
// tvOS keeps the system alerts: the focus engine drives them natively there, which is the whole
|
||||||
|
// reason this gap was tvOS-invisible.
|
||||||
|
//
|
||||||
|
// Deliberately NOT built on GamepadMenuList: that is a ScrollView (right for a settings screen of
|
||||||
|
// unknown length, wrong for two buttons in a card, where it would need an invented height and
|
||||||
|
// could clip). A prompt has two or three actions, so it owns a plain VStack and a cursor.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
|
||||||
|
/// One choice in a console prompt.
|
||||||
|
struct GamepadPromptAction: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
/// This is the action B (and Esc) performs, and the one the cursor opens on. Exactly one
|
||||||
|
/// action should carry it — `GamepadPrompt` falls back to the LAST action when none does,
|
||||||
|
/// which matches how a system alert treats its cancel role.
|
||||||
|
var isCancel = false
|
||||||
|
/// Drawn as the primary, accent-tinted row. At most one.
|
||||||
|
var isPrimary = false
|
||||||
|
let run: () -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A prompt to show over the console UI: what happened, and what can be done about it.
|
||||||
|
struct GamepadPrompt: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
let actions: [GamepadPromptAction]
|
||||||
|
/// A wait with no outcome yet (the delegated-approval hold) shows a spinner beside the title —
|
||||||
|
/// the prompt is the UI for something still in flight, not a report that it finished.
|
||||||
|
var busy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prompt, worn as the console's own modal: a dimmed field, a glass card, a focus list of
|
||||||
|
/// actions, and the same legend every other gamepad screen carries.
|
||||||
|
struct GamepadPromptView: View {
|
||||||
|
@Environment(\.gamepadInk) private var ink
|
||||||
|
@Environment(\.gamepadMetrics) private var metrics
|
||||||
|
let prompt: GamepadPrompt
|
||||||
|
|
||||||
|
@State private var cursor = 0
|
||||||
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
|
@State private var haptics = MenuHaptics(manager: .shared)
|
||||||
|
/// `.sensoryFeedback` counters — device ticks for confirm and for a refused move at an end.
|
||||||
|
@State private var activateTick = 0
|
||||||
|
@State private var boundaryTick = 0
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
|
|
||||||
|
private var compact: Bool { vSizeClass == .compact }
|
||||||
|
#else
|
||||||
|
private let compact = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Swallows touch to the launcher behind it, which is also gated out of the controller
|
||||||
|
// poll for as long as this is up (ContentView's `promptActive`).
|
||||||
|
Rectangle()
|
||||||
|
.fill(.black.opacity(0.55))
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {}
|
||||||
|
card
|
||||||
|
}
|
||||||
|
.sensoryFeedback(.selection, trigger: cursor)
|
||||||
|
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||||
|
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||||
|
// A prompt is exactly where a keyboard user gets stuck, so it takes arrows/Return/Esc too.
|
||||||
|
.gamepadKeyNavigation(
|
||||||
|
onMove: { direction in
|
||||||
|
switch direction {
|
||||||
|
case .up: step(by: -1)
|
||||||
|
case .down: step(by: 1)
|
||||||
|
case .left, .right: break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfirm: { activate() },
|
||||||
|
onBack: { back() })
|
||||||
|
.onAppear {
|
||||||
|
cursor = prompt.actions.firstIndex(where: \.isCancel) ?? max(prompt.actions.count - 1, 0)
|
||||||
|
wire()
|
||||||
|
input.start()
|
||||||
|
}
|
||||||
|
// The prompt's identity is stable across a message change (same `id`), so re-wire rather
|
||||||
|
// than rely on a remount: the stored closures captured the OLD actions array.
|
||||||
|
.onChange(of: prompt.actions.map(\.id)) { _, _ in
|
||||||
|
cursor = min(cursor, max(prompt.actions.count - 1, 0))
|
||||||
|
wire()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
input.stop()
|
||||||
|
haptics.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var card: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if prompt.busy {
|
||||||
|
ProgressView().controlSize(.small).tint(ink.fg(0.8))
|
||||||
|
}
|
||||||
|
Text(prompt.title)
|
||||||
|
.font(.geist(compact ? 19 : 22, .bold, relativeTo: .title3))
|
||||||
|
.foregroundStyle(ink.fg)
|
||||||
|
}
|
||||||
|
Text(prompt.message)
|
||||||
|
.font(.geist(metrics.detailFont, relativeTo: .callout))
|
||||||
|
.foregroundStyle(ink.fg(0.62))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ForEach(Array(prompt.actions.enumerated()), id: \.element.id) { idx, action in
|
||||||
|
actionRow(action, focused: idx == cursor)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { tap(idx) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
GamepadHintBar(hints: hints)
|
||||||
|
}
|
||||||
|
.padding(compact ? 20 : 26)
|
||||||
|
.frame(maxWidth: 460)
|
||||||
|
.consoleGlass(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(ink.fg(0.12), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func actionRow(_ action: GamepadPromptAction, focused: Bool) -> some View {
|
||||||
|
let m = metrics
|
||||||
|
return Text(action.title)
|
||||||
|
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||||
|
.foregroundStyle(action.isPrimary ? ink.accent : ink.fg)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, m.rowHPad)
|
||||||
|
.padding(.vertical, m.rowVPad)
|
||||||
|
.consoleGlass(
|
||||||
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||||
|
tint: focused ? ink.accent(0.30) : nil,
|
||||||
|
interactive: focused)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||||
|
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.scaleEffect(focused ? 1.0 : 0.98)
|
||||||
|
.animation(.smooth(duration: 0.18), value: focused)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hints: [GamepadHint] {
|
||||||
|
var hints: [GamepadHint] = [.init(
|
||||||
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
||||||
|
action: { activate() })]
|
||||||
|
// Only where B has somewhere to go: a one-action prompt ("OK") is dismissed by that
|
||||||
|
// action, and B does it too — naming it twice would just be noise.
|
||||||
|
if prompt.actions.count > 1, let cancel = prompt.actions.first(where: \.isCancel) {
|
||||||
|
hints.append(.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: cancel.title,
|
||||||
|
action: { back() }))
|
||||||
|
}
|
||||||
|
return hints
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Input
|
||||||
|
|
||||||
|
private func wire() {
|
||||||
|
input.onMove = { direction in
|
||||||
|
switch direction {
|
||||||
|
case .up: step(by: -1)
|
||||||
|
case .down: step(by: 1)
|
||||||
|
// A prompt's actions are a vertical list; left/right have nothing to mean here, and
|
||||||
|
// silently treating them as up/down would make a nudged stick pick a different button.
|
||||||
|
case .left, .right: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.onConfirm = { activate() }
|
||||||
|
input.onBack = { back() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func step(by delta: Int) {
|
||||||
|
let target = cursor + delta
|
||||||
|
guard target >= 0, target < prompt.actions.count else {
|
||||||
|
boundaryTick &+= 1
|
||||||
|
haptics.boundary()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cursor = target
|
||||||
|
haptics.move()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activate() {
|
||||||
|
guard cursor >= 0, cursor < prompt.actions.count else { return }
|
||||||
|
activateTick &+= 1
|
||||||
|
haptics.confirm()
|
||||||
|
prompt.actions[cursor].run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B: the cancel action, else the last one — the same fallback a system alert applies when
|
||||||
|
/// nothing carries the cancel role, so B always has a way out rather than doing nothing.
|
||||||
|
private func back() {
|
||||||
|
guard let action = prompt.actions.first(where: \.isCancel) ?? prompt.actions.last
|
||||||
|
else { return }
|
||||||
|
activateTick &+= 1
|
||||||
|
haptics.confirm()
|
||||||
|
action.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Touch fallback matching the rest of the gamepad UI: a tap focuses AND activates.
|
||||||
|
private func tap(_ idx: Int) {
|
||||||
|
guard idx >= 0, idx < prompt.actions.count else { return }
|
||||||
|
cursor = idx
|
||||||
|
activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -21,12 +21,14 @@ import SwiftUI
|
|||||||
enum GamepadScreen: Identifiable {
|
enum GamepadScreen: Identifiable {
|
||||||
case settings
|
case settings
|
||||||
case addHost
|
case addHost
|
||||||
|
case pair(StoredHost)
|
||||||
case library(StoredHost)
|
case library(StoredHost)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .settings: return "settings"
|
case .settings: return "settings"
|
||||||
case .addHost: return "addHost"
|
case .addHost: return "addHost"
|
||||||
|
case .pair(let host): return "pair-\(host.id.uuidString)"
|
||||||
case .library(let host): return "library-\(host.id.uuidString)"
|
case .library(let host): return "library-\(host.id.uuidString)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +37,7 @@ enum GamepadScreen: Identifiable {
|
|||||||
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
||||||
var isForm: Bool {
|
var isForm: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .settings, .addHost: return true
|
case .settings, .addHost, .pair: return true
|
||||||
case .library: return false
|
case .library: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ struct LibraryCoverflowView: View {
|
|||||||
) -> some View {
|
) -> some View {
|
||||||
PosterImage(
|
PosterImage(
|
||||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||||
onLoaded: { artSettled += 1 })
|
icon: game.iconToken, onLoaded: { artSettled += 1 })
|
||||||
.frame(width: width, height: height)
|
.frame(width: width, height: height)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
@@ -204,13 +204,19 @@ struct LibraryCoverflowView: View {
|
|||||||
|
|
||||||
private var hints: [GamepadHint] {
|
private var hints: [GamepadHint] {
|
||||||
var hints: [GamepadHint] = []
|
var hints: [GamepadHint] = []
|
||||||
if onLaunch != nil {
|
if let onLaunch {
|
||||||
// You *open* a launcher and *launch* a game — the hint follows the focused entry.
|
// You *open* a launcher and *launch* a game — the hint follows the focused entry.
|
||||||
let opens = games.first { $0.id == selection }?.isLauncher == true
|
let opens = games.first { $0.id == selection }?.isLauncher == true
|
||||||
hints.append(
|
hints.append(.init(
|
||||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch",
|
||||||
|
// Reads `selection` when it fires, not when the legend was built (see the
|
||||||
|
// launcher's twin) — and does nothing with no title centred, which is exactly
|
||||||
|
// what A does.
|
||||||
|
action: { if let id = selection { onLaunch(id) } }))
|
||||||
}
|
}
|
||||||
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
|
hints.append(.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close",
|
||||||
|
action: { onDismiss?() }))
|
||||||
return hints
|
return hints
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ struct LibraryView: View {
|
|||||||
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
|
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
|
||||||
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
|
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
|
||||||
@State private var artLoader: LibraryArtLoader?
|
@State private var artLoader: LibraryArtLoader?
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
|
||||||
|
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
|
||||||
|
/// they didn't ask for.
|
||||||
|
@State private var keyCursor: String?
|
||||||
|
@State private var gridWidth: CGFloat = 0
|
||||||
|
#endif
|
||||||
#if os(iOS) || os(macOS) || os(tvOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
|
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
|
||||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||||
@@ -120,34 +127,103 @@ struct LibraryView: View {
|
|||||||
let launchers = games.filter(\.isLauncher)
|
let launchers = games.filter(\.isLauncher)
|
||||||
let titles = games.filter { !$0.isLauncher }
|
let titles = games.filter { !$0.isLauncher }
|
||||||
let both = !launchers.isEmpty && !titles.isEmpty
|
let both = !launchers.isEmpty && !titles.isEmpty
|
||||||
return ScrollView {
|
return ScrollViewReader { proxy in
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
ScrollView {
|
||||||
if !launchers.isEmpty {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
if both { sectionHeader("Launchers") }
|
if !launchers.isEmpty {
|
||||||
tiles(launchers)
|
if both { sectionHeader("Launchers") }
|
||||||
|
tiles(launchers)
|
||||||
|
}
|
||||||
|
if !titles.isEmpty {
|
||||||
|
if both { sectionHeader("Games") }
|
||||||
|
tiles(titles)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !titles.isEmpty {
|
.padding()
|
||||||
if both { sectionHeader("Games") }
|
#if os(iOS) || os(macOS)
|
||||||
tiles(titles)
|
// The grid's own width, reported without affecting layout — a GeometryReader
|
||||||
|
// SIBLING inside a ScrollView would claim the whole viewport. It's what tells the
|
||||||
|
// keyboard cursor how many columns `.adaptive` actually produced, so it is only
|
||||||
|
// measured where that cursor exists.
|
||||||
|
.background {
|
||||||
|
GeometryReader { geo in
|
||||||
|
Color.clear
|
||||||
|
.onAppear { gridWidth = geo.size.width }
|
||||||
|
.onChange(of: geo.size.width) { _, w in gridWidth = w }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.padding()
|
#if os(iOS) || os(macOS)
|
||||||
|
// Hardware keyboard: arrows pick a title, Return launches it — a field ask from an
|
||||||
|
// iPad user on a Magic Keyboard. The gamepad UI's coverflow has had this via the
|
||||||
|
// controller all along; this is the same thing for the plain grid, which is what an
|
||||||
|
// iPad with a keyboard and NO pad actually sees.
|
||||||
|
.gamepadKeyNavigation(
|
||||||
|
active: onLaunch != nil,
|
||||||
|
onMove: { direction in
|
||||||
|
guard let next = gridNav(launchers: launchers, titles: titles)
|
||||||
|
.move(from: keyCursor, direction) else { return }
|
||||||
|
keyCursor = next
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(next, anchor: .center) }
|
||||||
|
},
|
||||||
|
onConfirm: {
|
||||||
|
guard let onLaunch, let id = keyCursor else { return }
|
||||||
|
onLaunch(id)
|
||||||
|
})
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
/// The keyboard cursor's model over the two grid sections. Rebuilt per press from the live
|
||||||
|
/// sections so it can never point into a stale list.
|
||||||
|
private func gridNav(launchers: [GameEntry], titles: [GameEntry]) -> LibraryGridNav {
|
||||||
|
LibraryGridNav(
|
||||||
|
sections: [launchers, titles].filter { !$0.isEmpty }.map { $0.map(\.id) },
|
||||||
|
columns: columnCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many columns `.adaptive(minimum:spacing:)` fits into the measured width — the same
|
||||||
|
/// arithmetic the layout does, so up/down move exactly one visual row rather than a guess.
|
||||||
|
/// Falls back to one column before the first measurement lands.
|
||||||
|
private var columnCount: Int {
|
||||||
|
let minimum: CGFloat = 130 // matches `columns` below on iOS/macOS
|
||||||
|
let spacing: CGFloat = 18
|
||||||
|
// The VStack's `.padding()` is inside the measured width, so take it back off.
|
||||||
|
let usable = gridWidth - 32
|
||||||
|
guard usable > 0 else { return 1 }
|
||||||
|
return max(1, Int((usable + spacing) / (minimum + spacing)))
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func tiles(_ entries: [GameEntry]) -> some View {
|
private func tiles(_ entries: [GameEntry]) -> some View {
|
||||||
LazyVGrid(columns: columns, spacing: 18) {
|
LazyVGrid(columns: columns, spacing: 18) {
|
||||||
ForEach(entries) { game in
|
ForEach(entries) { game in
|
||||||
if let onLaunch {
|
if let onLaunch {
|
||||||
Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) }
|
Button { onLaunch(game.id) } label: {
|
||||||
.buttonStyle(.plain)
|
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.id(game.id)
|
||||||
} else {
|
} else {
|
||||||
GameCard(game: game, artLoader: artLoader)
|
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
|
||||||
|
.id(game.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the keyboard cursor is on this tile (always false where there is no keyboard
|
||||||
|
/// navigation to have moved it).
|
||||||
|
private func isKeyCursor(_ game: GameEntry) -> Bool {
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
keyCursor == game.id
|
||||||
|
#else
|
||||||
|
false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func sectionHeader(_ text: String) -> some View {
|
private func sectionHeader(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
.font(.geist(12, .semibold, relativeTo: .caption))
|
||||||
@@ -264,13 +340,24 @@ private struct LibraryBackCatcher: View {
|
|||||||
private struct GameCard: View {
|
private struct GameCard: View {
|
||||||
let game: GameEntry
|
let game: GameEntry
|
||||||
let artLoader: LibraryArtLoader?
|
let artLoader: LibraryArtLoader?
|
||||||
|
/// The hardware-keyboard cursor is on this tile — drawn as an accent ring, since the plain
|
||||||
|
/// grid has no other way to say "Return launches THIS one".
|
||||||
|
var selected = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
|
PosterImage(
|
||||||
|
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||||
|
icon: game.iconToken)
|
||||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.overlay {
|
||||||
|
if selected {
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.strokeBorder(.tint, lineWidth: 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ struct PosterImage: View {
|
|||||||
let candidates: [URL]
|
let candidates: [URL]
|
||||||
let title: String
|
let title: String
|
||||||
let loader: LibraryArtLoader?
|
let loader: LibraryArtLoader?
|
||||||
|
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
|
||||||
|
/// no cover art by design, so for those the mark IS the poster — see `placeholder`.
|
||||||
|
var icon: String?
|
||||||
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
||||||
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
||||||
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
||||||
@@ -121,11 +124,26 @@ struct PosterImage: View {
|
|||||||
private var placeholder: some View {
|
private var placeholder: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Rectangle().fill(.quaternary)
|
Rectangle().fill(.quaternary)
|
||||||
Text(title)
|
// A launcher's brand mark, drawn at poster size and tinted like the text it replaces.
|
||||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
// `scaledToFit` inside a fraction of the card keeps a non-square master (the Steam mark
|
||||||
.multilineTextAlignment(.center)
|
// is 496×512, Playnite's 1024×1024) in its own aspect ratio rather than stretched.
|
||||||
.foregroundStyle(.secondary)
|
// Falling back to the title is the pre-icon design, so an unshipped mark loses nothing.
|
||||||
.padding(8)
|
if let mark = launcherIconImage(for: icon) {
|
||||||
|
GeometryReader { geo in
|
||||||
|
mark
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: geo.size.width * 0.44, height: geo.size.height * 0.44)
|
||||||
|
.frame(width: geo.size.width, height: geo.size.height)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(title)
|
||||||
|
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6).
|
// Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6).
|
||||||
// Deliberately thin: every action already has an internal entry point — the deep-link router
|
// Deliberately thin: every action already has an internal entry point — the deep-link router
|
||||||
// (connect / connect-and-launch / connect-with-a-profile), the in-process end-session hook, and
|
// (connect / connect-and-launch / connect-with-a-profile, and the `browse` route into a host's
|
||||||
// the existing Wake-on-LAN path — so these intents only wrap them.
|
// library), the in-process end-session hook, and the existing Wake-on-LAN path — so these
|
||||||
|
// intents only wrap them.
|
||||||
//
|
//
|
||||||
// Connect and Wake compile on macOS and tvOS too: AppIntents is genuinely available there
|
// Connect and Wake compile on macOS and tvOS too: AppIntents is genuinely available there
|
||||||
// (macOS 13+ / tvOS 16+), and "Stream Desktop with Work" from Spotlight on a Mac is part of the
|
// (macOS 13+ / tvOS 16+), and "Stream Desktop with Work" from Spotlight on a Mac is part of the
|
||||||
@@ -51,6 +52,28 @@ struct ConnectToHostIntent: AppIntent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Jump straight into a host's game library — no session. Foregrounds the app and routes the
|
||||||
|
/// `browse` route through the same `.onOpenURL` path a widget tap uses, which drives the one
|
||||||
|
/// `libraryTarget` every surface shares — so the shortcut lands in whichever library presentation
|
||||||
|
/// the current mode owns: the gamepad console's library screen when the gamepad UI is active, the
|
||||||
|
/// touch/desktop library otherwise. A session starts only when a title is picked there.
|
||||||
|
struct OpenLibraryIntent: AppIntent {
|
||||||
|
static let title: LocalizedStringResource = "Open Game Library"
|
||||||
|
static let description = IntentDescription(
|
||||||
|
"Open a host's game library in Punktfunk, without starting a stream.")
|
||||||
|
static let openAppWhenRun = true
|
||||||
|
|
||||||
|
@Parameter(title: "Host") var host: HostEntity
|
||||||
|
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
let url = DeepLink.browse(host: host.id).url
|
||||||
|
await MainActor.run {
|
||||||
|
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
|
||||||
|
}
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Wake a sleeping host (magic packet). No `openAppWhenRun` — usable in automations ("when I get
|
/// Wake a sleeping host (magic packet). No `openAppWhenRun` — usable in automations ("when I get
|
||||||
/// home, wake the tower") without foregrounding the app.
|
/// home, wake the tower") without foregrounding the app.
|
||||||
struct WakeHostIntent: AppIntent {
|
struct WakeHostIntent: AppIntent {
|
||||||
@@ -97,6 +120,13 @@ struct PunktfunkShortcuts: AppShortcutsProvider {
|
|||||||
"Stream \(\.$host) with \(.applicationName)",
|
"Stream \(\.$host) with \(.applicationName)",
|
||||||
],
|
],
|
||||||
shortTitle: "Connect", systemImageName: "play.tv.fill")
|
shortTitle: "Connect", systemImageName: "play.tv.fill")
|
||||||
|
AppShortcut(
|
||||||
|
intent: OpenLibraryIntent(),
|
||||||
|
phrases: [
|
||||||
|
"Open \(\.$host) library in \(.applicationName)",
|
||||||
|
"Show \(\.$host) games in \(.applicationName)",
|
||||||
|
],
|
||||||
|
shortTitle: "Game Library", systemImageName: "square.grid.2x2.fill")
|
||||||
AppShortcut(
|
AppShortcut(
|
||||||
intent: WakeHostIntent(),
|
intent: WakeHostIntent(),
|
||||||
phrases: [
|
phrases: [
|
||||||
|
|||||||
@@ -43,10 +43,27 @@ enum ScreenshotMode {
|
|||||||
/// readiness ping for the capture script.
|
/// readiness ping for the capture script.
|
||||||
struct ScreenshotHostView: View {
|
struct ScreenshotHostView: View {
|
||||||
let scene: ShotScene
|
let scene: ShotScene
|
||||||
|
#if os(iOS)
|
||||||
|
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||||
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// The gamepad UI's form-metric tier, published here for the same reason ContentView does it:
|
||||||
|
/// this harness mounts those screens DIRECTLY, with no ContentView in the tree, so without it
|
||||||
|
/// an iPad capture renders every gamepad screen at iPhone scale — a capture that doesn't look
|
||||||
|
/// like the app.
|
||||||
|
private var gamepadMetrics: GamepadFormMetrics {
|
||||||
|
#if os(iOS)
|
||||||
|
.forWindow(h: hSizeClass, v: vSizeClass)
|
||||||
|
#else
|
||||||
|
.platformDefault
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
scene.make()
|
scene.make()
|
||||||
.environment(\.colorScheme, scene.colorScheme)
|
.environment(\.colorScheme, scene.colorScheme)
|
||||||
|
.environment(\.gamepadMetrics, gamepadMetrics)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
|
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
|
||||||
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
|
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
|
||||||
|
|||||||
@@ -242,7 +242,8 @@ private struct ShotGamepadHome: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
GamepadHomeView(
|
GamepadHomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
libraryTarget: .constant(nil), waker: waker,
|
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||||
|
onPaired: { _, _ in }, waker: waker,
|
||||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,7 +301,8 @@ private struct ShotConnect: View {
|
|||||||
if gamepadUI {
|
if gamepadUI {
|
||||||
GamepadHomeView(
|
GamepadHomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
libraryTarget: .constant(nil), waker: waker,
|
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||||
|
onPaired: { _, _ in }, waker: waker,
|
||||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||||
} else {
|
} else {
|
||||||
ShotHome()
|
ShotHome()
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ struct AcknowledgementsView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
Text("Swift packages")
|
||||||
|
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||||
|
Text("Punktfunk uses Glur (progressive backdrop blur), "
|
||||||
|
+ "© 2023 João Gabriel, under the MIT License.")
|
||||||
|
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
Text("Third-party software")
|
Text("Third-party software")
|
||||||
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -66,22 +66,20 @@ struct GamepadOptionBand: View {
|
|||||||
rotation: drumPosition,
|
rotation: drumPosition,
|
||||||
target: drumPosition,
|
target: drumPosition,
|
||||||
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
|
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
|
||||||
radius: width * 0.72)
|
radius: width * 0.72,
|
||||||
|
width: width)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: width)
|
.frame(width: width)
|
||||||
.clipped()
|
.clipped()
|
||||||
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut.
|
// NO `.mask` here. The soft edges used to be a gradient mask over the whole band, and a
|
||||||
.mask {
|
// mask RASTERISES what it covers — which flattens `rotation3DEffect`'s perspective, so the
|
||||||
LinearGradient(
|
// drum was being composited as a flat sideways slide rather than a turning cylinder. That
|
||||||
stops: [
|
// is the "3D effect isn't what it should be" the field kept seeing: the geometry was
|
||||||
.init(color: .clear, location: 0),
|
// always right, and the mask was throwing the projection away every frame.
|
||||||
.init(color: .black, location: 0.12),
|
//
|
||||||
.init(color: .black, location: 0.88),
|
// The same soft edge is folded into each option's own opacity instead (see `Drum.option`),
|
||||||
.init(color: .clear, location: 1),
|
// which costs nothing and leaves the projection intact.
|
||||||
],
|
|
||||||
startPoint: .leading, endPoint: .trailing)
|
|
||||||
}
|
|
||||||
.onChange(of: selection) { old, new in step(from: old, to: new) }
|
.onChange(of: selection) { old, new in step(from: old, to: new) }
|
||||||
// The options list itself can mutate under the drum (a custom resolution appears, a
|
// The options list itself can mutate under the drum (a custom resolution appears, a
|
||||||
// controller connects, the buffer options re-derive from a new refresh rate) — re-seat
|
// controller connects, the buffer options re-derive from a new refresh rate) — re-seat
|
||||||
@@ -131,6 +129,9 @@ private struct Drum: View, Animatable {
|
|||||||
let target: Double
|
let target: Double
|
||||||
/// Drum radius in points (from the band width — see the caller).
|
/// Drum radius in points (from the band width — see the caller).
|
||||||
let radius: Double
|
let radius: Double
|
||||||
|
/// The band's own width — the stage the options turn on, and what the edge fade is measured
|
||||||
|
/// against now that the container no longer carries a mask.
|
||||||
|
let width: Double
|
||||||
|
|
||||||
var animatableData: Double {
|
var animatableData: Double {
|
||||||
get { rotation }
|
get { rotation }
|
||||||
@@ -140,6 +141,17 @@ private struct Drum: View, Animatable {
|
|||||||
/// Angular pitch between adjacent options on the drum.
|
/// Angular pitch between adjacent options on the drum.
|
||||||
private static let stepAngle = 34.0 * .pi / 180.0
|
private static let stepAngle = 34.0 * .pi / 180.0
|
||||||
|
|
||||||
|
// Neighbours exist only while the drum is MOVING, and that is not a compromise — it is the
|
||||||
|
// documented field fix this file was written around. Showing them at rest was tried (to make a
|
||||||
|
// settled row look more like a cylinder) and immediately reproduced the original defect: on the
|
||||||
|
// simulator, "This device · 2752 × 2064" rendered with "280 ×" sitting on top of it, and
|
||||||
|
// "Automatic" with "10 Mbps" through it. A long value and its neighbour occupy the same
|
||||||
|
// pixels, and no opacity low enough to fix that is high enough to be worth drawing.
|
||||||
|
//
|
||||||
|
// The cylinder is meant to be READ WHILE IT TURNS. What was actually broken is fixed above:
|
||||||
|
// the band used to mask itself, and the mask rasterised the drum and threw its perspective
|
||||||
|
// away every frame, so the turn never looked like a turn.
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let flight = min(1, abs(rotation - target) * 3)
|
let flight = min(1, abs(rotation - target) * 3)
|
||||||
let content = ZStack {
|
let content = ZStack {
|
||||||
@@ -147,14 +159,18 @@ private struct Drum: View, Animatable {
|
|||||||
// Plain signed distance — the band is linear, so option i has ONE home and the
|
// Plain signed distance — the band is linear, so option i has ONE home and the
|
||||||
// ends are the ends (nothing waits beyond the last option).
|
// ends are the ends (nothing waits beyond the last option).
|
||||||
let d = Double(i) - rotation
|
let d = Double(i) - rotation
|
||||||
|
// Only the facing option at rest; its neighbours join it for the travel (see the
|
||||||
|
// note on `restingNeighbour`'s removal above).
|
||||||
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
|
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
|
||||||
option(i, distance: d, gate: flight)
|
option(i, distance: d, gate: flight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Flatten the transform stack while travelling — the 10-foot GPU already made these
|
// Flatten the transform stack — the 10-foot GPU already made these rows drop Liquid
|
||||||
// rows drop Liquid Glass, and five projected texts per step is the same class of cost.
|
// Glass, and several projected texts per step is the same class of cost. It costs the
|
||||||
|
// projection (a rasterised layer has no perspective), which is the trade tvOS already
|
||||||
|
// makes elsewhere on this screen.
|
||||||
content.drawingGroup()
|
content.drawingGroup()
|
||||||
#else
|
#else
|
||||||
content
|
content
|
||||||
@@ -164,17 +180,30 @@ private struct Drum: View, Animatable {
|
|||||||
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
|
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
|
||||||
let angle = d * Self.stepAngle
|
let angle = d * Self.stepAngle
|
||||||
let depth = cos(angle)
|
let depth = cos(angle)
|
||||||
|
let x = radius * sin(angle)
|
||||||
// The facing option never gates: a resting row still shows its value.
|
// The facing option never gates: a resting row still shows its value.
|
||||||
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
|
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) * edgeFade(x)
|
||||||
Text(options[i])
|
Text(options[i])
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.fixedSize() // never let a turning label re-wrap to the band's width mid-flight
|
||||||
.scaleEffect(0.70 + 0.30 * depth)
|
.scaleEffect(0.70 + 0.30 * depth)
|
||||||
// Foreshorten the label as it turns away — this is what sells the cylinder.
|
// Foreshorten the label as it turns away — this is what sells the cylinder.
|
||||||
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4)
|
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.55)
|
||||||
.offset(x: radius * sin(angle))
|
.offset(x: x)
|
||||||
.opacity(alpha)
|
.opacity(alpha)
|
||||||
.zIndex(depth)
|
.zIndex(depth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The soft edge, per option, replacing the container mask that used to flatten the
|
||||||
|
/// projection: full strength through the middle of the band, dissolving to nothing by the
|
||||||
|
/// time an option reaches its rim, so the drum never ends on a cut.
|
||||||
|
private func edgeFade(_ x: Double) -> Double {
|
||||||
|
let halfWidth = width / 2
|
||||||
|
guard halfWidth > 0 else { return 1 }
|
||||||
|
let fadeStart = halfWidth * 0.55
|
||||||
|
guard abs(x) > fadeStart else { return 1 }
|
||||||
|
return max(0, min(1, (halfWidth - abs(x)) / (halfWidth - fadeStart)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
|
|||||||
|
|
||||||
struct GamepadSettingsView: View {
|
struct GamepadSettingsView: View {
|
||||||
@Environment(\.gamepadInk) private var ink
|
@Environment(\.gamepadInk) private var ink
|
||||||
|
@Environment(\.gamepadMetrics) private var metrics
|
||||||
|
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||||
@@ -142,7 +144,7 @@ struct GamepadSettingsView: View {
|
|||||||
isActive: controllerActive
|
isActive: controllerActive
|
||||||
) { row, focused in
|
) { row, focused in
|
||||||
rowView(row, focused: focused)
|
rowView(row, focused: focused)
|
||||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
.frame(maxWidth: metrics.rowMaxWidth)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -161,12 +163,12 @@ struct GamepadSettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||||
.background { GamepadTrayScrim(edge: .top) }
|
.background { GamepadTrayBlur(edge: .top) }
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(focusedDetail)
|
Text(focusedDetail)
|
||||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||||
.foregroundStyle(ink.fg(0.55))
|
.foregroundStyle(ink.fg(0.55))
|
||||||
.lineLimit(2, reservesSpace: true)
|
.lineLimit(2, reservesSpace: true)
|
||||||
.animation(.smooth(duration: 0.2), value: focusID)
|
.animation(.smooth(duration: 0.2), value: focusID)
|
||||||
@@ -175,10 +177,13 @@ struct GamepadSettingsView: View {
|
|||||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||||
.padding(.leading, compact ? 12 : 18)
|
.padding(.leading, compact ? 12 : 18)
|
||||||
.padding(.trailing, 22)
|
.padding(.trailing, 22)
|
||||||
.padding(.bottom, compact ? 12 : 18)
|
.padding(
|
||||||
|
.bottom,
|
||||||
|
gamepadLegendBottomPadding(
|
||||||
|
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||||
.padding(.top, compact ? 6 : 10)
|
.padding(.top, compact ? 6 : 10)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background { GamepadTrayScrim(edge: .bottom) }
|
.background { GamepadTrayBlur(edge: .bottom) }
|
||||||
}
|
}
|
||||||
// The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real
|
// 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
|
// colour and luminance to lens without the launcher's contrast, and the palette setting
|
||||||
@@ -254,10 +259,18 @@ struct GamepadSettingsView: View {
|
|||||||
private func pill(_ t: GpSettingsTab) -> some View {
|
private func pill(_ t: GpSettingsTab) -> some View {
|
||||||
let selected = t == tab
|
let selected = t == tab
|
||||||
return Text(t.rawValue)
|
return Text(t.rawValue)
|
||||||
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
|
.font(.geist(compact ? 12 : metrics.tabFont, .semibold, relativeTo: .footnote))
|
||||||
.foregroundStyle(selected ? ink.fg : ink.fg(0.55))
|
// `onAccent`, not `fg` — the selected pill is FILLED with the palette accent, and
|
||||||
.padding(.horizontal, 13)
|
// `onAccent` is the colour picked (by the accent's own luminance) to read on top of
|
||||||
.padding(.vertical, 7)
|
// it; its doc calls out "a filled pill's label" for exactly this surface. Using the
|
||||||
|
// foreground meant white-on-white wherever a palette's accent is pale: Graphite's is
|
||||||
|
// a light grey (luma ≈ 0.80), so its selected tab was unreadable.
|
||||||
|
.foregroundStyle(selected ? ink.onAccent : ink.fg(0.55))
|
||||||
|
// Proportional to the row metrics rather than fixed, so the strip grows with the
|
||||||
|
// fields under it — a tab bar at phone scale above iPad-scale rows was half the
|
||||||
|
// "does not adapt to larger screens" complaint.
|
||||||
|
.padding(.horizontal, metrics.rowHPad * 0.8)
|
||||||
|
.padding(.vertical, metrics.rowVPad * 0.55)
|
||||||
.background {
|
.background {
|
||||||
// One shared capsule that MOVES between pills, rather than one per pill fading
|
// One shared capsule that MOVES between pills, rather than one per pill fading
|
||||||
// in and out — the highlight travels the way the press did. A Liquid Glass
|
// in and out — the highlight travels the way the press did. A Liquid Glass
|
||||||
@@ -328,26 +341,39 @@ struct GamepadSettingsView: View {
|
|||||||
// shoulders exist at all (see `showsSectionHint`).
|
// shoulders exist at all (see `showsSectionHint`).
|
||||||
let sections: [GamepadHint] = showsSectionHint
|
let sections: [GamepadHint] = showsSectionHint
|
||||||
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||||
text: "Section")]
|
text: "Section", action: { step(tabBy: 1) })]
|
||||||
: []
|
: []
|
||||||
// A dimmed row takes neither, so offering them would be the same lie the row itself
|
// 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.
|
// 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 {
|
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
|
||||||
return sections
|
return sections
|
||||||
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
+ [.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||||
|
action: { back() })]
|
||||||
}
|
}
|
||||||
return sections + [
|
return sections + [
|
||||||
|
// The stick itself, not an action — nothing to tap (see GamepadHint.action).
|
||||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
.init(
|
||||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change",
|
||||||
|
action: { if let focusID { activate(id: focusID) } }),
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||||
|
action: { back() }),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
guard !store.hosts.isEmpty else {
|
guard !store.hosts.isEmpty else {
|
||||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
|
return [.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||||
|
action: { back() })]
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
|
.init(
|
||||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin",
|
||||||
|
action: { if let focusID { activate(id: focusID) } }),
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||||
|
action: { back() }),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +391,7 @@ struct GamepadSettingsView: View {
|
|||||||
// MARK: - Row rendering
|
// MARK: - Row rendering
|
||||||
|
|
||||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||||
let m = GamepadFormMetrics.self
|
let m = metrics
|
||||||
// No section header: the tab strip names the section now, and repeating it above the
|
// 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.
|
// first row of every tab was just a second label saying the same word.
|
||||||
return VStack(alignment: .leading, spacing: 6) {
|
return VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -443,9 +469,9 @@ struct GamepadSettingsView: View {
|
|||||||
/// narrows the stage.
|
/// narrows the stage.
|
||||||
private var bandWidth: CGFloat {
|
private var bandWidth: CGFloat {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
|
hSizeClass == .compact && vSizeClass == .regular ? 170 : metrics.bandWidth
|
||||||
#else
|
#else
|
||||||
GamepadFormMetrics.bandWidth
|
metrics.bandWidth
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,15 +200,16 @@ final class HostStore: ObservableObject {
|
|||||||
if let data = try? JSONEncoder().encode(hosts) {
|
if let data = try? JSONEncoder().encode(hosts) {
|
||||||
defaults.set(data, forKey: Self.key)
|
defaults.set(data, forKey: Self.key)
|
||||||
}
|
}
|
||||||
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
|
reloadHostsWidget() // the widgets read this store; any change refreshes their timelines
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/
|
/// Ask WidgetKit to rebuild the launcher widgets' timelines after any store change (add/remove/
|
||||||
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
|
/// pin/last-connected). iOS-only and a no-op where WidgetKit is absent; both widgets use
|
||||||
/// `.never`-refresh entries and relies on this push.
|
/// `.never`-refresh entries and rely on this push.
|
||||||
private func reloadHostsWidget() {
|
private func reloadHostsWidget() {
|
||||||
#if canImport(WidgetKit) && os(iOS)
|
#if canImport(WidgetKit) && os(iOS)
|
||||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
|
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
|
||||||
|
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkLibrary")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|||||||
let shape: S
|
let shape: S
|
||||||
var tint: Color?
|
var tint: Color?
|
||||||
var interactive = false
|
var interactive = false
|
||||||
|
/// Take the MATERIAL path even where real Liquid Glass is available. For surfaces that get
|
||||||
|
/// transformed while they animate: glass samples the backdrop through its own layer, and under
|
||||||
|
/// a `rotation3DEffect` / `opacity` it cannot, so it renders one way mid-animation and snaps to
|
||||||
|
/// another the instant the transform ends — on glass that reads as the tile being SWAPPED for a
|
||||||
|
/// different one as it lands. A material is a flat composite and looks identical throughout.
|
||||||
|
var forceMaterial = false
|
||||||
/// The console surface follows the background palette: a PALE field needs the material to
|
/// The console surface follows the background palette: a PALE field needs the material to
|
||||||
/// frost light and the glass to read as white, or the dark ink on top of it disappears.
|
/// frost light and the glass to read as white, or the dark ink on top of it disappears.
|
||||||
/// Defaults to the dark ink, so every non-gamepad caller is unchanged.
|
/// Defaults to the dark ink, so every non-gamepad caller is unchanged.
|
||||||
@@ -117,8 +123,18 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|||||||
}
|
}
|
||||||
.environment(\.colorScheme, scheme)
|
.environment(\.colorScheme, scheme)
|
||||||
#else
|
#else
|
||||||
if #available(iOS 26, macOS 26, *) {
|
if #available(iOS 26, macOS 26, *), !forceMaterial {
|
||||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
content
|
||||||
|
// The caller's tint rides HERE, not in `Glass.tint`, so it can ANIMATE. A Glass
|
||||||
|
// value is opaque to SwiftUI's animation system: changing its tint swaps one
|
||||||
|
// effect for another, which is why a focused row's accent used to appear (and,
|
||||||
|
// worse, disappear a beat late) as a hard jump while the row's scale animated
|
||||||
|
// smoothly beside it. A plain fill interpolates, so `.animation(value: focused)`
|
||||||
|
// at the call site now covers the whole row. Sits between the glass and the
|
||||||
|
// content: `.background` is behind the label, `glassEffect` behind both.
|
||||||
|
.background { shape.fill(tint ?? .clear) }
|
||||||
|
.glassEffect(glass, in: shape)
|
||||||
|
.environment(\.colorScheme, scheme)
|
||||||
} else {
|
} else {
|
||||||
content
|
content
|
||||||
.background {
|
.background {
|
||||||
@@ -137,13 +153,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@available(iOS 26, macOS 26, *)
|
@available(iOS 26, macOS 26, *)
|
||||||
private var glass: Glass {
|
private var glass: Glass {
|
||||||
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share
|
// The glass carries the PALETTE wash only — the caller's focus tint is an animatable fill
|
||||||
// it: mixed 60 % toward the caller's (the focused row must still read accented on
|
// above it now (see `body`).
|
||||||
// every palette) over the palette base. If device QA finds the mixed focus wash too
|
//
|
||||||
// weak, the escape hatch is `tint ?? wash` — today's focused look, bit for bit.
|
// A pale palette gets `.clear` glass, not `.regular`. Its `ink.glass` is literal white, so
|
||||||
let wash = ink.glass(ink.isLight ? 0.60 : 0.45)
|
// over `.regular` — which is already a bright, high-body material — even a light white
|
||||||
var g: Glass = .regular.tint(
|
// wash lands as a flat white slab: the refraction and the blurred field behind stop
|
||||||
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash)
|
// reading entirely, which is the "opaque fully white bg" on every row, pill and legend.
|
||||||
|
// Lowering the tint alone did NOT fix it, because the opacity was coming from the glass
|
||||||
|
// BODY rather than from the tint. `.clear` is the variant meant for exactly this — a
|
||||||
|
// surface over content that must stay visible through it — and a small white wash on top
|
||||||
|
// of it is enough to keep the dark ink legible without closing the surface up.
|
||||||
|
let wash = ink.glass(ink.isLight ? 0.18 : 0.45)
|
||||||
|
// Spelled out rather than `.clear`/`.regular`: a ternary between two leading-dot members
|
||||||
|
// gives the compiler no base type to infer from.
|
||||||
|
var g: Glass = (ink.isLight ? Glass.clear : Glass.regular).tint(wash)
|
||||||
if interactive { g = g.interactive() }
|
if interactive { g = g.interactive() }
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
@@ -154,8 +178,13 @@ extension View {
|
|||||||
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
|
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
|
||||||
/// pre-26 — both washed with the palette's own glass colour, both frosting to the palette's
|
/// pre-26 — both washed with the palette's own glass colour, both frosting to the palette's
|
||||||
/// scheme. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
/// scheme. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
||||||
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
|
///
|
||||||
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
|
/// `forceMaterial` opts a TRANSFORMED surface out of live glass; see the property.
|
||||||
|
func consoleGlass<S: Shape>(
|
||||||
|
_ shape: S, tint: Color? = nil, interactive: Bool = false, forceMaterial: Bool = false
|
||||||
|
) -> some View {
|
||||||
|
modifier(ConsoleGlass(
|
||||||
|
shape: shape, tint: tint, interactive: interactive, forceMaterial: forceMaterial))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// The gamepad-driven PIN pairing screen (iOS/iPadOS/macOS) — the controller counterpart of
|
||||||
|
// PairSheet, and the reason a console-UI user can pair at all.
|
||||||
|
//
|
||||||
|
// PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives those natively, but
|
||||||
|
// on iOS/macOS a controller cannot reach a text field, type into it, or press the button
|
||||||
|
// underneath — so for anyone in the console UI, pairing (the ONE thing standing between a fresh
|
||||||
|
// install and a first stream) ended at "now touch the screen". This screen is the same ceremony
|
||||||
|
// wearing the gamepad UI's own vocabulary: the vertical focus list from the settings/add-host
|
||||||
|
// screens, A on a field to open GamepadKeyboard in a bottom tray, B to peel one layer.
|
||||||
|
//
|
||||||
|
// Structure deliberately mirrors GamepadAddHostView field for field — the two screens are the same
|
||||||
|
// interaction (a short form, typed with a pad, committed by an action row) and a user who has
|
||||||
|
// added a host should recognise this immediately. The ceremony itself is shared with PairSheet
|
||||||
|
// (`PairCeremony`), so the two presentations can never disagree about what a wrong PIN means.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
|
||||||
|
struct GamepadPairView: View {
|
||||||
|
@Environment(\.gamepadInk) private var ink
|
||||||
|
@Environment(\.gamepadMetrics) private var metrics
|
||||||
|
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||||
|
let host: StoredHost
|
||||||
|
/// Called with the verified host fingerprint after a successful ceremony — the caller pins it
|
||||||
|
/// and connects (ContentView's `handlePaired`).
|
||||||
|
let onPaired: (Data) -> Void
|
||||||
|
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet) falls back to the
|
||||||
|
/// environment dismiss.
|
||||||
|
var close: (() -> Void)?
|
||||||
|
/// Whether this screen owns the controller — false while the shell is mid-transition or the
|
||||||
|
/// connect takeover is up (see GamepadAddHostView's twin).
|
||||||
|
var controllerActive = true
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits.
|
||||||
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
|
|
||||||
|
private var compact: Bool { vSizeClass == .compact }
|
||||||
|
#else
|
||||||
|
private let compact = false // no size classes on macOS; the sheet is sized to fit the tray
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@StateObject private var ceremony = PairCeremony()
|
||||||
|
@State private var pin = ""
|
||||||
|
#if os(macOS)
|
||||||
|
@State private var clientName = Host.current().localizedName ?? "Mac"
|
||||||
|
#else
|
||||||
|
@State private var clientName = UIDevice.current.name
|
||||||
|
#endif
|
||||||
|
@State private var focusID: String?
|
||||||
|
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
||||||
|
@State private var editing: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GamepadMenuList(
|
||||||
|
items: rows,
|
||||||
|
focusID: $focusID,
|
||||||
|
onActivate: { activate(id: $0.id) },
|
||||||
|
onBack: { performClose() },
|
||||||
|
// A ceremony in flight also takes the list out of the loop: `pair()` blocks on a
|
||||||
|
// background thread and its result rewrites this screen, so letting B peel a layer
|
||||||
|
// or A fire a second ceremony underneath it would race the completion.
|
||||||
|
isActive: controllerActive && editing == nil && !ceremony.busy
|
||||||
|
) { row, focused in
|
||||||
|
rowView(row, focused: focused)
|
||||||
|
.frame(maxWidth: metrics.rowMaxWidth)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
header
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
|
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background { GamepadTrayBlur(edge: .top) }
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
|
bottomTray
|
||||||
|
// Equal distance from the left and bottom edges for the legend pill (see
|
||||||
|
// GamepadHomeView).
|
||||||
|
.padding(.horizontal, compact ? 12 : 18)
|
||||||
|
.padding(
|
||||||
|
.bottom,
|
||||||
|
gamepadLegendBottomPadding(
|
||||||
|
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||||
|
.padding(.top, compact ? 6 : 10)
|
||||||
|
.background { GamepadTrayBlur(edge: .bottom) }
|
||||||
|
}
|
||||||
|
// Hosted in the shell, the field is the shell's own (see GamepadAddHostView's twin).
|
||||||
|
.background {
|
||||||
|
if !hostedInShell { GamepadFormBackground() }
|
||||||
|
}
|
||||||
|
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||||
|
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||||
|
.gamepadPaletteInk()
|
||||||
|
// A PIN is short; cap it so the row can't grow absurd on a stuck key.
|
||||||
|
.onChange(of: pin) { _, value in
|
||||||
|
if value.count > Self.maxPINLength { pin = String(value.prefix(Self.maxPINLength)) }
|
||||||
|
}
|
||||||
|
// Any dismissal path abandons an in-flight ceremony — a late success must not pin and
|
||||||
|
// connect to a host the user backed out of.
|
||||||
|
.onDisappear { ceremony.abandon() }
|
||||||
|
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||||
|
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||||
|
.background {
|
||||||
|
Button("Cancel") { performClose() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.frame(width: 0, height: 0)
|
||||||
|
.opacity(0)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generous next to the host's 4 digits: the PIN length is the HOST's business (a future one
|
||||||
|
/// may well be longer), so this is a runaway guard, not a validator. Rejecting a correct PIN
|
||||||
|
/// locally would be a far worse failure than sending a wrong one, which the host just refuses.
|
||||||
|
private static let maxPINLength = 12
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||||
|
// Leading, like every gamepad heading — and no close chrome (B is the exit).
|
||||||
|
Text("Pair with \(host.displayName)")
|
||||||
|
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||||
|
.foregroundStyle(ink.fg)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
if !compact {
|
||||||
|
Text("The PIN is shown in the host's web console (port 47992 → Pairing). "
|
||||||
|
+ "Pairing verifies both sides at once — no fingerprint comparison needed.")
|
||||||
|
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||||
|
.foregroundStyle(ink.fg(0.55))
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keyboard tray while editing, the status line + controls legend otherwise.
|
||||||
|
@ViewBuilder private var bottomTray: some View {
|
||||||
|
if let editing {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
GamepadKeyboard(
|
||||||
|
text: editingBinding(editing),
|
||||||
|
allowed: allowedCharacters(editing),
|
||||||
|
onDone: { closeKeyboard() })
|
||||||
|
// Fresh keyboard per field (see GamepadAddHostView) — the tray's input wiring
|
||||||
|
// captured the previous binding on appear.
|
||||||
|
.id(editing)
|
||||||
|
GamepadHintBar(hints: [
|
||||||
|
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
||||||
|
action: { backspace(editing) }),
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||||
|
action: { closeKeyboard() }),
|
||||||
|
])
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
statusLine
|
||||||
|
GamepadHintBar(hints: [
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
||||||
|
action: { if let focusID { activate(id: focusID) } }),
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
|
||||||
|
action: { performClose() }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the ceremony is doing, in the slot the settings screen gives its detail line. Reserves
|
||||||
|
/// its space so the legend never jumps when a failure arrives.
|
||||||
|
@ViewBuilder private var statusLine: some View {
|
||||||
|
Group {
|
||||||
|
if ceremony.busy {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView().controlSize(.small).tint(ink.fg(0.7))
|
||||||
|
Text("Pairing with \(host.displayName)…").foregroundStyle(ink.fg(0.7))
|
||||||
|
}
|
||||||
|
} else if let error = ceremony.errorText {
|
||||||
|
Text(error).foregroundStyle(.red)
|
||||||
|
} else {
|
||||||
|
// Placeholder keeps the reserved height honest under `lineLimit(2)`.
|
||||||
|
Text(" ").foregroundStyle(.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||||
|
.lineLimit(2, reservesSpace: true)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: metrics.rowMaxWidth, alignment: .leading)
|
||||||
|
.animation(.smooth(duration: 0.2), value: ceremony.errorText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
|
||||||
|
/// the environment dismiss under a macOS sheet.
|
||||||
|
private func performClose() {
|
||||||
|
ceremony.abandon()
|
||||||
|
if let close { close() } else { dismiss() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rows
|
||||||
|
|
||||||
|
private struct Row: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let label: String
|
||||||
|
var value = ""
|
||||||
|
var placeholder = ""
|
||||||
|
var isAction = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rows: [Row] {
|
||||||
|
[
|
||||||
|
Row(id: "pin", label: "PIN", value: pin, placeholder: "Shown in the web console"),
|
||||||
|
Row(
|
||||||
|
id: "name", label: "Device name", value: clientName,
|
||||||
|
placeholder: "How the host lists this device"),
|
||||||
|
Row(id: "pair", label: "Pair & Connect", isAction: true),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||||
|
let m = metrics
|
||||||
|
return HStack(spacing: 14) {
|
||||||
|
if row.isAction {
|
||||||
|
Label("Pair & Connect", systemImage: "lock.shield")
|
||||||
|
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||||
|
.foregroundStyle(canPair ? ink.accent : ink.fg(0.35))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Text(row.label)
|
||||||
|
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||||
|
.foregroundStyle(ink.fg)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Text(row.value.isEmpty ? row.placeholder : row.value)
|
||||||
|
.font(.geistFixed(m.valueFont, .medium))
|
||||||
|
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.head) // keep the end of a long name visible while typing
|
||||||
|
if editing == row.id {
|
||||||
|
// The live-edit caret: this row is what the keyboard tray is typing into.
|
||||||
|
Rectangle()
|
||||||
|
.fill(ink.accent)
|
||||||
|
.frame(width: 2, height: m.labelFont + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, m.rowHPad)
|
||||||
|
.padding(.vertical, m.rowVPad)
|
||||||
|
.consoleGlass(
|
||||||
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||||
|
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
|
||||||
|
interactive: focused)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||||
|
.strokeBorder(
|
||||||
|
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
|
||||||
|
lineWidth: 1)
|
||||||
|
}
|
||||||
|
.scaleEffect(focused ? 1.0 : 0.98)
|
||||||
|
.animation(.smooth(duration: 0.18), value: focused)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func activate(id: String) {
|
||||||
|
guard !ceremony.busy else { return }
|
||||||
|
switch id {
|
||||||
|
case "pair":
|
||||||
|
guard canPair else {
|
||||||
|
// Not pairable yet — jump straight to what's missing instead of a dead press,
|
||||||
|
// matching the add-host screen's Add row.
|
||||||
|
focusID = "pin"
|
||||||
|
openKeyboard("pin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ceremony.run(host: host.address, port: host.port, pin: pin, clientName: clientName) {
|
||||||
|
fingerprint in
|
||||||
|
onPaired(fingerprint)
|
||||||
|
// NOT `performClose()`: that abandons the ceremony, and this IS the ceremony's
|
||||||
|
// success. Closing is all that's left to do.
|
||||||
|
if let close { close() } else { dismiss() }
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
openKeyboard(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canPair: Bool {
|
||||||
|
!pin.trimmingCharacters(in: .whitespaces).isEmpty && !ceremony.busy
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openKeyboard(_ id: String) {
|
||||||
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeKeyboard() {
|
||||||
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editingBinding(_ id: String) -> Binding<String> {
|
||||||
|
id == "pin" ? $pin : $clientName
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The legend's Delete cell — see GamepadAddHostView's twin for why this edits the binding
|
||||||
|
/// rather than reaching into the keyboard.
|
||||||
|
private func backspace(_ id: String) {
|
||||||
|
let binding = editingBinding(id)
|
||||||
|
guard !binding.wrappedValue.isEmpty else { return }
|
||||||
|
binding.wrappedValue.removeLast()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the keyboard may type per field: a PIN is digits; a device name is free-form.
|
||||||
|
private func allowedCharacters(_ id: String) -> CharacterSet? {
|
||||||
|
id == "pin" ? CharacterSet(charactersIn: "0123456789") : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// The SPAKE2 PIN ceremony itself, with no opinion about how it's presented. Two screens run it:
|
||||||
|
// `PairSheet` (the touch/desktop Form, and tvOS's focus-engine layout) and `GamepadPairView` (the
|
||||||
|
// controller-driven console screen). The ceremony is the part that must not diverge between them —
|
||||||
|
// it decides what counts as a wrong PIN, what a rejection means, and which failures are worth
|
||||||
|
// telling the user apart — so it lives here once rather than being copied into the second caller.
|
||||||
|
//
|
||||||
|
// Threading: `pair()` and the identity load both BLOCK, so they run off the main actor; every
|
||||||
|
// published mutation lands back on it.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PairCeremony: ObservableObject {
|
||||||
|
/// A ceremony is in flight — callers disable their commit action and show a spinner.
|
||||||
|
@Published private(set) var busy = false
|
||||||
|
/// The last failure, in user-facing terms; cleared when a new attempt starts.
|
||||||
|
@Published var errorText: String?
|
||||||
|
|
||||||
|
/// Dismissing the presenting screen must abandon an in-flight ceremony: the blocking `pair()`
|
||||||
|
/// call can't be interrupted, so its completion checks this token and self-discards — a late
|
||||||
|
/// success must NOT pin and auto-connect to a host the user cancelled out of. A fresh token
|
||||||
|
/// per attempt, so abandoning one attempt can't silence the next.
|
||||||
|
private var token = Token()
|
||||||
|
|
||||||
|
private final class Token: @unchecked Sendable {
|
||||||
|
var cancelled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the ceremony. `onPaired` receives the host's now-VERIFIED fingerprint — the caller pins
|
||||||
|
/// it and connects; no manual fingerprint comparison is needed, because the host proved itself
|
||||||
|
/// with the same PIN.
|
||||||
|
func run(
|
||||||
|
host address: String, port: UInt16, pin rawPIN: String, clientName rawName: String,
|
||||||
|
onPaired: @escaping (Data) -> Void
|
||||||
|
) {
|
||||||
|
busy = true
|
||||||
|
errorText = nil
|
||||||
|
let pin = rawPIN.trimmingCharacters(in: .whitespaces)
|
||||||
|
let name = rawName.trimmingCharacters(in: .whitespaces)
|
||||||
|
token = Token()
|
||||||
|
let token = token
|
||||||
|
Task.detached(priority: .userInitiated) {
|
||||||
|
// Identity load + the ceremony both block — keep them off the main actor.
|
||||||
|
// loadForPairing is the strict variant: the host durably trusts this
|
||||||
|
// identity, so it must have made it into the Keychain.
|
||||||
|
let result = Result {
|
||||||
|
let identity = try ClientIdentityStore.shared.loadForPairing()
|
||||||
|
return try PunktfunkKit.pair(
|
||||||
|
host: address, port: port, identity: identity,
|
||||||
|
pin: pin, name: name.isEmpty ? "Mac" : name)
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
guard !token.cancelled else { return } // screen dismissed mid-ceremony
|
||||||
|
self.busy = false
|
||||||
|
switch result {
|
||||||
|
case .success(let fingerprint):
|
||||||
|
onPaired(fingerprint)
|
||||||
|
case .failure(PunktfunkClientError.wrongPIN):
|
||||||
|
self.errorText = "Wrong PIN — check the host's web console (port 47992) "
|
||||||
|
+ "and try again."
|
||||||
|
case .failure(PunktfunkClientError.rejected(let rejection)):
|
||||||
|
// The host answered and said why (not armed / rate-limited / armed for
|
||||||
|
// another device) — show that instead of the guessing-game fallback.
|
||||||
|
self.errorText = rejection.userMessage
|
||||||
|
case .failure(is ClientIdentityStore.IdentityError):
|
||||||
|
self.errorText = "Can't store this Mac's identity in the Keychain, so the "
|
||||||
|
+ "pairing would not survive a relaunch. Unlock the login "
|
||||||
|
+ "keychain and try again."
|
||||||
|
case .failure:
|
||||||
|
self.errorText = "Pairing failed — the host didn't answer. Is it running, "
|
||||||
|
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
|
||||||
|
+ "isolation)?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The presenting screen went away — discard whatever is still in flight. Called from every
|
||||||
|
/// dismissal path (an explicit Cancel, a swipe, B on a controller), which is why it is safe to
|
||||||
|
/// call when nothing is running.
|
||||||
|
func abandon() {
|
||||||
|
token.cancelled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,19 +5,15 @@
|
|||||||
// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED
|
// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED
|
||||||
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this
|
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this
|
||||||
// client's identity in return.
|
// client's identity in return.
|
||||||
|
//
|
||||||
|
// This is the TOUCH/desktop presentation (and tvOS's, where the focus engine drives the same
|
||||||
|
// fields). A controller can't reach a `Form`'s text fields on iOS/macOS, so the console UI
|
||||||
|
// presents `GamepadPairView` instead — same ceremony, via the shared `PairCeremony`.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Dismissing the sheet must abandon an in-flight ceremony: the blocking pair() call
|
|
||||||
/// can't be interrupted, so its completion checks this flag and self-discards — a late
|
|
||||||
/// success must NOT pin and auto-connect to a host the user cancelled out of. Only
|
|
||||||
/// touched on the main actor.
|
|
||||||
private final class CeremonyToken: @unchecked Sendable {
|
|
||||||
var cancelled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PairSheet: View {
|
struct PairSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
let host: StoredHost
|
let host: StoredHost
|
||||||
@@ -30,9 +26,10 @@ struct PairSheet: View {
|
|||||||
#else
|
#else
|
||||||
@State private var clientName = UIDevice.current.name
|
@State private var clientName = UIDevice.current.name
|
||||||
#endif
|
#endif
|
||||||
@State private var busy = false
|
@StateObject private var ceremony = PairCeremony()
|
||||||
@State private var errorText: String?
|
|
||||||
@State private var token = CeremonyToken()
|
private var busy: Bool { ceremony.busy }
|
||||||
|
private var errorText: String? { ceremony.errorText }
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
private enum EditField: String, Identifiable {
|
private enum EditField: String, Identifiable {
|
||||||
case pin, clientName
|
case pin, clientName
|
||||||
@@ -64,7 +61,7 @@ struct PairSheet: View {
|
|||||||
}
|
}
|
||||||
HStack(spacing: 32) {
|
HStack(spacing: 32) {
|
||||||
Button("Cancel", role: .cancel) {
|
Button("Cancel", role: .cancel) {
|
||||||
token.cancelled = true
|
ceremony.abandon()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
if busy {
|
if busy {
|
||||||
@@ -78,7 +75,7 @@ struct PairSheet: View {
|
|||||||
.frame(maxWidth: 1000)
|
.frame(maxWidth: 1000)
|
||||||
.padding(60)
|
.padding(60)
|
||||||
.navigationTitle("Pair with \(host.displayName)")
|
.navigationTitle("Pair with \(host.displayName)")
|
||||||
.onDisappear { token.cancelled = true }
|
.onDisappear { ceremony.abandon() }
|
||||||
.fullScreenCover(item: $editing) { field in
|
.fullScreenCover(item: $editing) { field in
|
||||||
switch field {
|
switch field {
|
||||||
case .pin:
|
case .pin:
|
||||||
@@ -142,7 +139,7 @@ struct PairSheet: View {
|
|||||||
#endif
|
#endif
|
||||||
HStack {
|
HStack {
|
||||||
Button("Cancel", role: .cancel) {
|
Button("Cancel", role: .cancel) {
|
||||||
token.cancelled = true
|
ceremony.abandon()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -180,7 +177,7 @@ struct PairSheet: View {
|
|||||||
.presentationDragIndicator(busy ? .hidden : .visible)
|
.presentationDragIndicator(busy ? .hidden : .visible)
|
||||||
#endif
|
#endif
|
||||||
.interactiveDismissDisabled(busy)
|
.interactiveDismissDisabled(busy)
|
||||||
.onDisappear { token.cancelled = true } // any other dismissal path
|
.onDisappear { ceremony.abandon() } // any other dismissal path
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,47 +192,11 @@ struct PairSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func runCeremony() {
|
private func runCeremony() {
|
||||||
busy = true
|
ceremony.run(
|
||||||
errorText = nil
|
host: host.address, port: host.port, pin: pin, clientName: clientName
|
||||||
let pin = pin.trimmingCharacters(in: .whitespaces)
|
) { fingerprint in
|
||||||
let name = clientName.trimmingCharacters(in: .whitespaces)
|
onPaired(fingerprint)
|
||||||
let address = host.address
|
dismiss()
|
||||||
let port = host.port
|
|
||||||
let token = token
|
|
||||||
Task.detached(priority: .userInitiated) {
|
|
||||||
// Identity load + the ceremony both block — keep them off the main actor.
|
|
||||||
// loadForPairing is the strict variant: the host durably trusts this
|
|
||||||
// identity, so it must have made it into the Keychain.
|
|
||||||
let result = Result {
|
|
||||||
let identity = try ClientIdentityStore.shared.loadForPairing()
|
|
||||||
return try PunktfunkKit.pair(
|
|
||||||
host: address, port: port, identity: identity,
|
|
||||||
pin: pin, name: name.isEmpty ? "Mac" : name)
|
|
||||||
}
|
|
||||||
await MainActor.run {
|
|
||||||
guard !token.cancelled else { return } // sheet dismissed mid-ceremony
|
|
||||||
busy = false
|
|
||||||
switch result {
|
|
||||||
case .success(let fingerprint):
|
|
||||||
onPaired(fingerprint)
|
|
||||||
dismiss()
|
|
||||||
case .failure(PunktfunkClientError.wrongPIN):
|
|
||||||
errorText = "Wrong PIN — check the host's web console (port 47992) "
|
|
||||||
+ "and try again."
|
|
||||||
case .failure(PunktfunkClientError.rejected(let rejection)):
|
|
||||||
// The host answered and said why (not armed / rate-limited / armed for
|
|
||||||
// another device) — show that instead of the guessing-game fallback.
|
|
||||||
errorText = rejection.userMessage
|
|
||||||
case .failure(is ClientIdentityStore.IdentityError):
|
|
||||||
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
|
||||||
+ "pairing would not survive a relaunch. Unlock the login "
|
|
||||||
+ "keychain and try again."
|
|
||||||
case .failure:
|
|
||||||
errorText = "Pairing failed — the host didn't answer. Is it running, "
|
|
||||||
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
|
|
||||||
+ "isolation)?"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
// Trust-on-first-use prompt: shown over the live-but-blurred stream when connecting to an
|
// Trust-on-first-use prompt: shown over the live-but-blurred stream when connecting to an
|
||||||
// unpinned host. The user compares the fingerprint with the one the host logged at startup,
|
// unpinned host. The user compares the fingerprint with the one the host logged at startup,
|
||||||
// or drops this and runs the PIN pairing ceremony instead.
|
// or drops this and runs the PIN pairing ceremony instead.
|
||||||
|
//
|
||||||
|
// Controller-drivable on iOS/macOS (A trust, B cancel, X pair instead). It had no controller
|
||||||
|
// wiring at all, which made it a dead end for a pad-only user at the worst possible moment: the
|
||||||
|
// card appears mid-connect with capture disabled (ContentView blurs the stream and stops
|
||||||
|
// forwarding), so the pad in their hands genuinely did nothing and the only way past was to reach
|
||||||
|
// for the screen. tvOS needs none of this — the focus engine drives the buttons natively.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
@@ -13,6 +19,12 @@ struct TrustCardView: View {
|
|||||||
let onTrust: () -> Void
|
let onTrust: () -> Void
|
||||||
let onPairInstead: () -> Void
|
let onPairInstead: () -> Void
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
/// Observed so the legend appears the moment a pad wakes up mid-prompt — and so it stays
|
||||||
|
/// absent for the mouse/touch users this card is otherwise for.
|
||||||
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
Image(systemName: "lock.shield")
|
Image(systemName: "lock.shield")
|
||||||
@@ -60,12 +72,35 @@ struct TrustCardView: View {
|
|||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
#endif
|
#endif
|
||||||
.font(.geist(16, relativeTo: .callout))
|
.font(.geist(16, relativeTo: .callout))
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
// Only with a pad attached: controller glyphs in front of a trackpad user would be
|
||||||
|
// naming buttons they don't have.
|
||||||
|
if gamepads.active != nil {
|
||||||
|
GamepadHintBar(hints: [
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Trust",
|
||||||
|
action: onTrust),
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Pair with PIN",
|
||||||
|
action: onPairInstead),
|
||||||
|
.init(
|
||||||
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
|
||||||
|
action: onCancel),
|
||||||
|
])
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.padding(28)
|
.padding(28)
|
||||||
.frame(maxWidth: 440)
|
.frame(maxWidth: 440)
|
||||||
// Floating trust card over the blurred stream — Liquid Glass on 26+, .regularMaterial
|
// Floating trust card over the blurred stream — Liquid Glass on 26+, .regularMaterial
|
||||||
// fallback below. The inner fingerprint box stays .quaternary (content, not glass).
|
// fallback below. The inner fingerprint box stays .quaternary (content, not glass).
|
||||||
.glassBackground(RoundedRectangle(cornerRadius: 18))
|
.glassBackground(RoundedRectangle(cornerRadius: 18))
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
.background {
|
||||||
|
TrustControllerInput(onTrust: onTrust, onCancel: onCancel, onPairInstead: onPairInstead)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 64 hex chars → four groups per line, two lines — easy to eyeball against the log.
|
/// 64 hex chars → four groups per line, two lines — easy to eyeball against the log.
|
||||||
@@ -80,6 +115,35 @@ struct TrustCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
/// Controller binding for the trust prompt: A trusts, B cancels, X runs the PIN ceremony instead.
|
||||||
|
/// The same zero-size-backing-view shape as `ConnectOverlay`'s `ConnectControllerInput` — mounted
|
||||||
|
/// for exactly as long as the card is up, and `GamepadMenuInput`'s snapshot-on-start swallows
|
||||||
|
/// whatever button was still held when it appeared (the A press that started the connect is
|
||||||
|
/// usually still down).
|
||||||
|
///
|
||||||
|
/// Nothing else is polling the pad here: capture is off for the duration of the prompt, and the
|
||||||
|
/// home screens are unmounted behind the session view.
|
||||||
|
private struct TrustControllerInput: View {
|
||||||
|
let onTrust: () -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
let onPairInstead: () -> Void
|
||||||
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 0, height: 0)
|
||||||
|
.onAppear {
|
||||||
|
input.onConfirm = onTrust
|
||||||
|
input.onBack = onCancel
|
||||||
|
input.onTertiary = onPairInstead
|
||||||
|
input.start()
|
||||||
|
}
|
||||||
|
.onDisappear { input.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private extension Array {
|
private extension Array {
|
||||||
func chunks(of size: Int) -> [[Element]] {
|
func chunks(of size: Int) -> [[Element]] {
|
||||||
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
|
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// "The audio output moved under us" — the one signal `SessionAudio` needs to survive a device
|
||||||
|
// change, and the one piece of it that can be tested without a stream.
|
||||||
|
//
|
||||||
|
// Split out of SessionAudio deliberately. An end-to-end test of the recovery needs a live session,
|
||||||
|
// which needs a host, and punktfunk-host does not build on macOS — so the wiring that matters most
|
||||||
|
// (is the observer actually installed? does the identity check let the notification through?) would
|
||||||
|
// otherwise ship unverified, and a silent failure in it costs the session ALL of its audio. On its
|
||||||
|
// own this can be pointed at the real hardware from a unit test: see AudioDeviceWatcherTests.
|
||||||
|
//
|
||||||
|
// What it does NOT own: anything with session semantics. The iOS route-change steer and the
|
||||||
|
// media-services-reset re-activation stay in SessionAudio, next to the AVAudioSession they act on.
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import os
|
||||||
|
#if os(macOS)
|
||||||
|
import CoreAudio
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
|
||||||
|
|
||||||
|
final class AudioDeviceWatcher {
|
||||||
|
/// Why the owner is being told. Only for the log line — every reason leads to the same
|
||||||
|
/// question, "is playback still on the device it should be on".
|
||||||
|
enum Reason: String {
|
||||||
|
/// An engine stopped itself because its IO hardware changed underneath it.
|
||||||
|
case engineConfiguration = "the audio hardware configuration changed"
|
||||||
|
/// The system's default output device moved (macOS).
|
||||||
|
case defaultOutputDevice = "the default output device changed"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this configuration change belong to an engine the session still owns? A retired engine
|
||||||
|
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
|
||||||
|
/// ours to restart.
|
||||||
|
private let isOurs: (AnyObject?) -> Bool
|
||||||
|
/// Delivered on the main queue.
|
||||||
|
private let onChange: (Reason) -> Void
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var configObserver: NSObjectProtocol?
|
||||||
|
#if os(macOS)
|
||||||
|
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
|
||||||
|
self.isOurs = isOurs
|
||||||
|
self.onChange = onChange
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { stop() }
|
||||||
|
|
||||||
|
/// Idempotent.
|
||||||
|
func start() {
|
||||||
|
lock.lock()
|
||||||
|
let already = configObserver != nil
|
||||||
|
lock.unlock()
|
||||||
|
guard !already else { return }
|
||||||
|
|
||||||
|
let token = NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVAudioEngineConfigurationChange, object: nil, queue: nil
|
||||||
|
) { [weak self] note in
|
||||||
|
// Posted from whatever thread the IO unit noticed on. The engine is the notification's
|
||||||
|
// object; it is only ever compared by identity, never resurrected.
|
||||||
|
let posted = note.object as AnyObject?
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self, self.isOurs(posted) else { return }
|
||||||
|
self.onChange(.engineConfiguration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lock.lock()
|
||||||
|
configObserver = token
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
// The engine notification is the direct signal, but it is delivered BY an engine — useless
|
||||||
|
// in the two places it is needed most: after a rebuild that could not start (no engine left
|
||||||
|
// to notify anyone) and on an engine topology whose notification behaviour is unverified
|
||||||
|
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
|
||||||
|
// here can even initialize). The HAL is told either way.
|
||||||
|
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
|
||||||
|
self?.onChange(.defaultOutputDevice) // on the main queue — registered against it below
|
||||||
|
}
|
||||||
|
var address = Self.defaultOutputAddress()
|
||||||
|
let status = AudioObjectAddPropertyListenerBlock(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block)
|
||||||
|
guard status == noErr else {
|
||||||
|
log.warning("""
|
||||||
|
could not watch the default output device (\(status)) — an output device change \
|
||||||
|
mid-stream may need a reconnect
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lock.lock()
|
||||||
|
defaultOutputListener = block
|
||||||
|
lock.unlock()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idempotent, and safe from any thread. After it returns, no further `onChange` is delivered
|
||||||
|
/// except one already in flight on the main queue — which the owner's own stopped-flag catches.
|
||||||
|
func stop() {
|
||||||
|
lock.lock()
|
||||||
|
let token = configObserver
|
||||||
|
configObserver = nil
|
||||||
|
#if os(macOS)
|
||||||
|
let listener = defaultOutputListener
|
||||||
|
defaultOutputListener = nil
|
||||||
|
#endif
|
||||||
|
lock.unlock()
|
||||||
|
if let token { NotificationCenter.default.removeObserver(token) }
|
||||||
|
#if os(macOS)
|
||||||
|
guard let listener else { return }
|
||||||
|
var address = Self.defaultOutputAddress()
|
||||||
|
AudioObjectRemovePropertyListenerBlock(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, listener)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// Freshly built per call rather than held in a mutable static: the HAL takes the address
|
||||||
|
/// `inout` and copies it, so there is nothing to share and a shared one would only be a
|
||||||
|
/// mutable global.
|
||||||
|
private static func defaultOutputAddress() -> AudioObjectPropertyAddress {
|
||||||
|
AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -43,8 +43,21 @@ public enum AudioDevices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func defaultInputDevice() -> AudioDeviceID? {
|
private static func defaultInputDevice() -> AudioDeviceID? {
|
||||||
|
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device the system is currently playing to — what an engine with no pinned speaker UID
|
||||||
|
/// follows, and so what `SessionAudio` compares its live output device against when the
|
||||||
|
/// default moves (AirPods in or out, a headset unplugged).
|
||||||
|
static func defaultOutputDevice() -> AudioDeviceID? {
|
||||||
|
systemDevice(kAudioHardwarePropertyDefaultOutputDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func systemDevice(
|
||||||
|
_ selector: AudioObjectPropertySelector
|
||||||
|
) -> AudioDeviceID? {
|
||||||
var address = AudioObjectPropertyAddress(
|
var address = AudioObjectPropertyAddress(
|
||||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
mSelector: selector,
|
||||||
mScope: kAudioObjectPropertyScopeGlobal,
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
mElement: kAudioObjectPropertyElementMain)
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
var dev = AudioDeviceID(0)
|
var dev = AudioDeviceID(0)
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
//
|
//
|
||||||
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
|
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
|
||||||
// concrete device and follows default-device changes).
|
// concrete device and follows default-device changes).
|
||||||
|
//
|
||||||
|
// Surviving the hardware. An AVAudioEngine does NOT follow the audio hardware: when the output
|
||||||
|
// device changes underneath a running engine, the engine stops itself and stays stopped. The
|
||||||
|
// session therefore watches for that and rebuilds its engines — see "Device changes" below.
|
||||||
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import os
|
import os
|
||||||
@@ -79,14 +83,53 @@ public final class SessionAudio {
|
|||||||
/// session's activate.
|
/// session's activate.
|
||||||
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if !os(macOS)
|
||||||
/// Live only for a `.playAndRecord` session: the token for the route-change observer that
|
/// Token for the route-change observer: it revives an engine the route change stopped, and on
|
||||||
/// keeps the BUILT-IN output on the speaker rather than the earpiece (see
|
/// iOS re-applies the earpiece steer (see `installRouteObserver`). Guarded by `stateLock`.
|
||||||
/// `steerBuiltInOutputToSpeaker`). A `.playback` session already prefers the speaker and
|
|
||||||
/// never needs steering, so the mic-off path installs nothing. Guarded by `stateLock`.
|
|
||||||
private var routeObserver: NSObjectProtocol?
|
private var routeObserver: NSObjectProtocol?
|
||||||
|
/// Token for the media-services-reset observer — the audio server restarting takes the
|
||||||
|
/// session's configuration and every engine with it. Guarded by `stateLock`.
|
||||||
|
private var mediaResetObserver: NSObjectProtocol?
|
||||||
|
/// Token for the interruption observer — a phone call or a non-mixable app stops the engines,
|
||||||
|
/// and ending the interruption restarts nothing by itself (see
|
||||||
|
/// `installInterruptionObserver`). Guarded by `stateLock`.
|
||||||
|
private var interruptionObserver: NSObjectProtocol?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Device changes (see `installDeviceChangeRecovery`)
|
||||||
|
|
||||||
|
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
|
||||||
|
/// started with. Main-thread confined, like the start paths that read it.
|
||||||
|
private var startConfig: StartConfig?
|
||||||
|
private struct StartConfig {
|
||||||
|
let speakerUID: String
|
||||||
|
let micUID: String
|
||||||
|
let micChannel: Int
|
||||||
|
let micEnabled: Bool
|
||||||
|
let echoCancel: Bool
|
||||||
|
}
|
||||||
|
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
|
||||||
|
private var deviceWatcher: AudioDeviceWatcher?
|
||||||
|
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
|
||||||
|
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
|
||||||
|
/// Main-thread confined.
|
||||||
|
private var enginesAttempted = false
|
||||||
|
/// A rebuild is already on the main queue — one device switch produces a burst of triggers
|
||||||
|
/// and they must collapse into one restart. Main-thread confined.
|
||||||
|
private var rebuildQueued = false
|
||||||
|
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
|
||||||
|
/// the session. Main-thread confined.
|
||||||
|
private var lastRebuildAt: TimeInterval = 0
|
||||||
|
/// Let the burst of triggers from one switch land before rebuilding.
|
||||||
|
private static let rebuildDebounce: TimeInterval = 0.15
|
||||||
|
/// Floor between two rebuilds.
|
||||||
|
private static let rebuildFloor: TimeInterval = 0.5
|
||||||
|
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
|
||||||
|
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
|
||||||
|
/// so this ladder — and, on macOS, the HAL listener — is all that stands between a mistimed
|
||||||
|
/// switch and a silent session.
|
||||||
|
private static let rebuildAttempts = 3
|
||||||
|
|
||||||
public init(connection: PunktfunkConnection) {
|
public init(connection: PunktfunkConnection) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
}
|
}
|
||||||
@@ -96,10 +139,14 @@ public final class SessionAudio {
|
|||||||
/// Engine teardown still belongs to stop().
|
/// Engine teardown still belongs to stop().
|
||||||
deinit {
|
deinit {
|
||||||
flag.stop()
|
flag.stop()
|
||||||
#if os(iOS)
|
// The observers only hold self weakly, so we can be deinited with them still registered;
|
||||||
// The observer only holds self weakly, so we can be deinited with it still registered;
|
// drop them here too rather than leaking them when an owner skips stop().
|
||||||
// drop the token here too rather than leaking it when an owner skips stop().
|
deviceWatcher?.stop()
|
||||||
|
#if !os(macOS)
|
||||||
if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
|
if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
|
||||||
|
if let mediaResetObserver {
|
||||||
|
NotificationCenter.default.removeObserver(mediaResetObserver)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +167,12 @@ public final class SessionAudio {
|
|||||||
videoLatency: LatencyMeter? = nil
|
videoLatency: LatencyMeter? = nil
|
||||||
) {
|
) {
|
||||||
self.videoLatency = videoLatency
|
self.videoLatency = videoLatency
|
||||||
|
// Before any engine exists: the recovery watches the hardware, not the engines, and the
|
||||||
|
// config it rebuilds from has to be recorded whether or not this start succeeds.
|
||||||
|
startConfig = StartConfig(
|
||||||
|
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||||
|
micEnabled: micEnabled, echoCancel: echoCancel)
|
||||||
|
installDeviceChangeRecovery(micEnabled: micEnabled)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||||
startEngines(
|
startEngines(
|
||||||
@@ -170,9 +223,17 @@ public final class SessionAudio {
|
|||||||
// make a headset's MIC usable, but it buys that by dragging the whole route onto
|
// make a headset's MIC usable, but it buys that by dragging the whole route onto
|
||||||
// HFP/SCO and collapsing game audio to narrowband. High-quality A2DP output plus
|
// HFP/SCO and collapsing game audio to narrowband. High-quality A2DP output plus
|
||||||
// the built-in mic is the better trade for a game-streaming client.
|
// the built-in mic is the better trade for a game-streaming client.
|
||||||
|
// `.mixWithOthers`, both branches: without it this session is EXCLUSIVE — merely
|
||||||
|
// activating it paused the user's Music at connect, and Music's RESUME took the
|
||||||
|
// session right back, which read as "stream audio stops when I resume Music"
|
||||||
|
// (field report; the interruption observer below is the other half of that fix).
|
||||||
|
// A game stream mixing over someone's playlist is the behavior a console has,
|
||||||
|
// and what this client's peers do. The trade is real but right: a mixable
|
||||||
|
// session is nobody's Now Playing app, so the lock screen shows the music, not
|
||||||
|
// the stream — which is exactly how it should read.
|
||||||
try session.setCategory(
|
try session.setCategory(
|
||||||
.playAndRecord, mode: .default,
|
.playAndRecord, mode: .default,
|
||||||
options: [.allowBluetoothA2DP])
|
options: [.allowBluetoothA2DP, .mixWithOthers])
|
||||||
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
||||||
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
||||||
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
||||||
@@ -180,19 +241,19 @@ public final class SessionAudio {
|
|||||||
try? session.setPreferredIOBufferDuration(0.005)
|
try? session.setPreferredIOBufferDuration(0.005)
|
||||||
try? session.setPreferredSampleRate(48_000)
|
try? session.setPreferredSampleRate(48_000)
|
||||||
} else {
|
} else {
|
||||||
try session.setCategory(.playback, mode: .default)
|
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||||
}
|
}
|
||||||
#else // tvOS — no app-accessible mic
|
#else // tvOS — no app-accessible mic
|
||||||
try session.setCategory(.playback, mode: .default)
|
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||||
#endif
|
#endif
|
||||||
try session.setActive(true)
|
try session.setActive(true)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
|
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
|
||||||
// output override — so the mic-off (`.playback`) path deliberately does neither.
|
// output override — so the mic-off (`.playback`) path deliberately does neither.
|
||||||
if micEnabled {
|
// (The route OBSERVER that re-applies this per route is installed by
|
||||||
steerBuiltInOutputToSpeaker(session)
|
// `installDeviceChangeRecovery`, for every session — a `.playback` session steers
|
||||||
installRouteObserver()
|
// nothing but still has engines a route change can stop.)
|
||||||
}
|
if micEnabled { steerBuiltInOutputToSpeaker(session) }
|
||||||
#endif
|
#endif
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||||
@@ -220,11 +281,20 @@ public final class SessionAudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
/// Routes change under a live session: a headset connects mid-stream, or disconnects and hands
|
/// Routes change under a live session: a headset connects mid-stream, or disconnects and hands
|
||||||
/// the stream back to the built-in output. iOS drops an output override whenever the route
|
/// the stream back to the built-in output. Two things follow from that.
|
||||||
/// changes — which is what lets a newly-connected headset win — so the earpiece steer is a
|
///
|
||||||
/// property of the CURRENT route and has to be re-applied per route. Without this, dropping
|
/// iOS drops an output override whenever the route changes — which is what lets a newly-
|
||||||
/// Bluetooth mid-stream would land the game on the earpiece.
|
/// connected headset win — so the earpiece steer is a property of the CURRENT route and has to
|
||||||
|
/// be re-applied per route. Without it, dropping Bluetooth mid-stream lands the game on the
|
||||||
|
/// earpiece.
|
||||||
|
///
|
||||||
|
/// And on every platform a route change can take the engines down with it (see
|
||||||
|
/// `installDeviceChangeRecovery`), which is why this is installed for `.playback` sessions and
|
||||||
|
/// on tvOS too, where there is no earpiece to steer away from.
|
||||||
private func installRouteObserver() {
|
private func installRouteObserver() {
|
||||||
let observer = NotificationCenter.default.addObserver(
|
let observer = NotificationCenter.default.addObserver(
|
||||||
forName: AVAudioSession.routeChangeNotification,
|
forName: AVAudioSession.routeChangeNotification,
|
||||||
@@ -235,7 +305,10 @@ public final class SessionAudio {
|
|||||||
// other call into it.
|
// other call into it.
|
||||||
SessionAudio.sessionQueue.async {
|
SessionAudio.sessionQueue.async {
|
||||||
guard let self, !self.flag.isStopped else { return }
|
guard let self, !self.flag.isStopped else { return }
|
||||||
|
#if os(iOS)
|
||||||
self.steerBuiltInOutputToSpeaker(AVAudioSession.sharedInstance())
|
self.steerBuiltInOutputToSpeaker(AVAudioSession.sharedInstance())
|
||||||
|
#endif
|
||||||
|
DispatchQueue.main.async { self.reviveStoppedEngines("the audio route changed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stateLock.lock()
|
stateLock.lock()
|
||||||
@@ -252,6 +325,7 @@ public final class SessionAudio {
|
|||||||
private func startEngines(
|
private func startEngines(
|
||||||
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
|
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
|
||||||
) {
|
) {
|
||||||
|
enginesAttempted = true // even if every path below fails — see `reviveStoppedEngines`
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// No app-accessible microphone input on tvOS — playback only.
|
// No app-accessible microphone input on tvOS — playback only.
|
||||||
startPlayback(speakerUID: speakerUID)
|
startPlayback(speakerUID: speakerUID)
|
||||||
@@ -325,35 +399,34 @@ public final class SessionAudio {
|
|||||||
public func stop() {
|
public func stop() {
|
||||||
flag.stop() // before taking the engines — see stateLock's comment
|
flag.stop() // before taking the engines — see stateLock's comment
|
||||||
stateLock.lock()
|
stateLock.lock()
|
||||||
let capture = captureEngine
|
|
||||||
captureEngine = nil
|
|
||||||
let playback = playbackEngine
|
|
||||||
playbackEngine = nil
|
|
||||||
let combined = combinedEngine
|
|
||||||
combinedEngine = nil
|
|
||||||
let wasDraining = drainStarted
|
let wasDraining = drainStarted
|
||||||
drainStarted = false
|
drainStarted = false
|
||||||
#if os(iOS)
|
let watcher = deviceWatcher
|
||||||
|
deviceWatcher = nil
|
||||||
|
#if !os(macOS)
|
||||||
let route = routeObserver
|
let route = routeObserver
|
||||||
routeObserver = nil
|
routeObserver = nil
|
||||||
|
let mediaReset = mediaResetObserver
|
||||||
|
mediaResetObserver = nil
|
||||||
|
let interruption = interruptionObserver
|
||||||
|
interruptionObserver = nil
|
||||||
#endif
|
#endif
|
||||||
stateLock.unlock()
|
stateLock.unlock()
|
||||||
#if os(iOS)
|
// Every watcher goes before the engines do: a device change landing during teardown must
|
||||||
// Before the deactivate below, so a route change during teardown can't re-steer a session
|
// not schedule a rebuild of a session we are in the middle of releasing. (`flag` already
|
||||||
// we are in the middle of releasing.
|
// guards that, but not arming the trigger is better than catching it.) On iOS this is
|
||||||
if let route { NotificationCenter.default.removeObserver(route) }
|
// also ahead of the deactivate below, so a route change cannot re-steer a dying session.
|
||||||
#endif
|
watcher?.stop()
|
||||||
if let capture {
|
|
||||||
capture.inputNode.removeTap(onBus: 0)
|
|
||||||
capture.stop()
|
|
||||||
}
|
|
||||||
playback?.stop()
|
|
||||||
if let combined {
|
|
||||||
combined.inputNode.removeTap(onBus: 0)
|
|
||||||
combined.stop()
|
|
||||||
}
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
|
if let route { NotificationCenter.default.removeObserver(route) }
|
||||||
|
if let mediaReset { NotificationCenter.default.removeObserver(mediaReset) }
|
||||||
|
if let interruption { NotificationCenter.default.removeObserver(interruption) }
|
||||||
|
#endif
|
||||||
|
tearDownEngines()
|
||||||
|
#if !os(macOS)
|
||||||
|
// Release the session. (A mixable session interrupts nobody, so the resume cue below is
|
||||||
|
// now a courtesy for the edge where an OLD non-mixable install interrupted something —
|
||||||
|
// harmless either way, and deactivating promptly is still what orders a reconnect.) Like
|
||||||
// activation, setActive is synchronous/blocking — run it on the shared serial session queue
|
// activation, setActive is synchronous/blocking — run it on the shared serial session queue
|
||||||
// (off the main thread). Enqueued HERE — engines already stopped, and BEFORE the drain wait
|
// (off the main thread). Enqueued HERE — engines already stopped, and BEFORE the drain wait
|
||||||
// below — so across a reconnect it lands ahead of the next session's activate on the shared
|
// below — so across a reconnect it lands ahead of the next session's activate on the shared
|
||||||
@@ -372,6 +445,267 @@ public final class SessionAudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop and release every engine we own, leaving the ring, the drain thread, the observers and
|
||||||
|
/// the audio session alone — the teardown half shared by `stop()` and a rebuild. Safe from any
|
||||||
|
/// thread; the engines are taken under the lock before any of them is touched.
|
||||||
|
private func tearDownEngines() {
|
||||||
|
stateLock.lock()
|
||||||
|
let capture = captureEngine
|
||||||
|
captureEngine = nil
|
||||||
|
let playback = playbackEngine
|
||||||
|
playbackEngine = nil
|
||||||
|
let combined = combinedEngine
|
||||||
|
combinedEngine = nil
|
||||||
|
stateLock.unlock()
|
||||||
|
if let capture {
|
||||||
|
capture.inputNode.removeTap(onBus: 0)
|
||||||
|
capture.stop()
|
||||||
|
}
|
||||||
|
playback?.stop()
|
||||||
|
if let combined {
|
||||||
|
combined.inputNode.removeTap(onBus: 0)
|
||||||
|
combined.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device changes
|
||||||
|
|
||||||
|
/// An AVAudioEngine does not follow the audio hardware. When the output device changes under a
|
||||||
|
/// running engine — AirPods taken out of an ear, a headset unplugged, the default switched in
|
||||||
|
/// System Settings — the engine's IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and
|
||||||
|
/// it posts `AVAudioEngineConfigurationChange`. It stays stopped until somebody starts it
|
||||||
|
/// again. Nothing here ever did, so from that moment the session rendered silence: no audio on
|
||||||
|
/// the speakers the stream had just moved to, and none in the AirPods when they went back in
|
||||||
|
/// (that is a second stop, not a recovery), until the whole stream was restarted. Measured on
|
||||||
|
/// this exact topology: render callbacks go from ~94/s to zero the instant the default output
|
||||||
|
/// device changes, and both restarting the same engine and building a fresh one resume them.
|
||||||
|
///
|
||||||
|
/// Three triggers feed one rebuild, because no single one of them covers the ground:
|
||||||
|
///
|
||||||
|
/// - the engine notification, everywhere — the direct signal, but only an engine that still
|
||||||
|
/// EXISTS can post it, so it cannot report a rebuild that failed to start;
|
||||||
|
/// - the HAL default-output-device listener, macOS — independent of any engine and of the
|
||||||
|
/// engine's topology. It is what makes the recovery work for the voice-processing engine
|
||||||
|
/// (mic + echo cancellation, the DEFAULT macOS configuration) without having to assume that
|
||||||
|
/// a VPIO engine posts the notification the plain one demonstrably does;
|
||||||
|
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
|
||||||
|
/// not the device is what moves.
|
||||||
|
///
|
||||||
|
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
|
||||||
|
/// Main thread.
|
||||||
|
private func installDeviceChangeRecovery(micEnabled: Bool) {
|
||||||
|
stateLock.lock()
|
||||||
|
let already = deviceWatcher != nil
|
||||||
|
stateLock.unlock()
|
||||||
|
guard !already else { return } // a second start() on one SessionAudio: keep the first set
|
||||||
|
|
||||||
|
let watcher = AudioDeviceWatcher(
|
||||||
|
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
|
||||||
|
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
|
||||||
|
stateLock.lock()
|
||||||
|
deviceWatcher = watcher
|
||||||
|
stateLock.unlock()
|
||||||
|
watcher.start()
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
installRouteObserver()
|
||||||
|
installMediaResetObserver(micEnabled: micEnabled)
|
||||||
|
installInterruptionObserver(micEnabled: micEnabled)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `posted` one of the engines this session currently owns? A retired engine posts one last
|
||||||
|
/// configuration change as it is torn down, and another AVAudioEngine in the process is none of
|
||||||
|
/// our business — identity only, the object is never resurrected.
|
||||||
|
private func ownsEngine(_ posted: AnyObject?) -> Bool {
|
||||||
|
stateLock.lock()
|
||||||
|
defer { stateLock.unlock() }
|
||||||
|
return posted === playbackEngine || posted === captureEngine || posted === combinedEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hardware moved (main queue, from `AudioDeviceWatcher`). Both reasons ask the same
|
||||||
|
/// question — is playback still where it should be — but they answer it differently: an engine
|
||||||
|
/// that told us it stopped is definitive, while the default device moving might not concern us
|
||||||
|
/// at all.
|
||||||
|
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
|
||||||
|
guard !flag.isStopped else { return }
|
||||||
|
switch reason {
|
||||||
|
case .engineConfiguration:
|
||||||
|
scheduleEngineRebuild(reason: reason.rawValue)
|
||||||
|
case .defaultOutputDevice:
|
||||||
|
#if os(macOS)
|
||||||
|
defaultOutputChanged()
|
||||||
|
#else
|
||||||
|
break // the watcher only raises this one on macOS
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart the engines if — and only if — playback is down. The conservative trigger: it is
|
||||||
|
/// what a route change (iOS/tvOS) and the macOS backstop get to do, since a HEALTHY engine
|
||||||
|
/// that followed the change on its own must not be interrupted for it.
|
||||||
|
///
|
||||||
|
/// Gated on a start having been ATTEMPTED rather than on an engine existing, which is the
|
||||||
|
/// difference between recovering a session whose very first `startPlayback` failed — no
|
||||||
|
/// output device at the moment it connected — and leaving it silent for good. On iOS the same
|
||||||
|
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
|
||||||
|
private func reviveStoppedEngines(_ reason: String) {
|
||||||
|
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
|
||||||
|
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the render side actually running? Both engines can carry it (`combinedEngine` when the
|
||||||
|
/// voice processor is engaged, `playbackEngine` otherwise). Taken out from under `stateLock`
|
||||||
|
/// before asking AVAudioEngine anything — the lock guards our handles, not the framework.
|
||||||
|
private var playbackIsLive: Bool {
|
||||||
|
stateLock.lock()
|
||||||
|
let playback = playbackEngine
|
||||||
|
let combined = combinedEngine
|
||||||
|
stateLock.unlock()
|
||||||
|
return (playback?.isRunning ?? false) || (combined?.isRunning ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coalesce: one device switch produces a burst — the old device leaving, the default moving,
|
||||||
|
/// the new device settling, and each engine we own posting its own change — and one rebuild
|
||||||
|
/// serves all of it. The floor between rebuilds keeps a device that renegotiates in a loop
|
||||||
|
/// from spinning the session. Main thread.
|
||||||
|
private func scheduleEngineRebuild(reason: String) {
|
||||||
|
guard !rebuildQueued else { return }
|
||||||
|
rebuildQueued = true
|
||||||
|
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
|
||||||
|
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
|
||||||
|
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
|
self?.rebuildEngines(attempt: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put back the topology this session was started with, on whatever hardware is there now.
|
||||||
|
///
|
||||||
|
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
|
||||||
|
/// follow too: `installMicTap` reads the input's live format, and the voice processor
|
||||||
|
/// renegotiates its own. The RING is deliberately not touched — it is the one thing carried
|
||||||
|
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
|
||||||
|
/// keeps decoding right through the switch and its overflow policy has already dropped
|
||||||
|
/// everything that went stale while the engine was down.
|
||||||
|
private func rebuildEngines(attempt: Int) {
|
||||||
|
rebuildQueued = false
|
||||||
|
guard !flag.isStopped, let config = startConfig else { return }
|
||||||
|
lastRebuildAt = ProcessInfo.processInfo.systemUptime
|
||||||
|
tearDownEngines()
|
||||||
|
startEngines(
|
||||||
|
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
|
||||||
|
micEnabled: config.micEnabled, echoCancel: config.echoCancel)
|
||||||
|
|
||||||
|
// Did playback actually come back? A device caught mid-transition can refuse to start, and
|
||||||
|
// a rebuild that fails leaves no engine to post the next notification — so this is the one
|
||||||
|
// path that must not just give up. (`startEngines` has logged the reason already.)
|
||||||
|
if playbackIsLive {
|
||||||
|
log.info("audio engines restarted on the current device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard attempt < Self.rebuildAttempts else {
|
||||||
|
#if os(macOS)
|
||||||
|
log.error("""
|
||||||
|
audio did not come back after the device change — the default-output watcher will \
|
||||||
|
try again when a device appears
|
||||||
|
""")
|
||||||
|
#else
|
||||||
|
log.error("audio did not come back after the route change")
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rebuildQueued = true // holds off a trigger that would only race this ladder
|
||||||
|
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
|
self?.rebuildEngines(attempt: attempt + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// The system's output device moved. Rebuild only when it actually concerns this session: the
|
||||||
|
/// engine is gone or stopped, or it is playing to a device that is no longer the one we should
|
||||||
|
/// be on. Somebody changing the default while we are pinned to a named speaker is none of our
|
||||||
|
/// business, and rebuilding for it would cost an audible gap for nothing. Main queue (the
|
||||||
|
/// listener block is registered against it).
|
||||||
|
private func defaultOutputChanged() {
|
||||||
|
guard !flag.isStopped, let config = startConfig else { return }
|
||||||
|
stateLock.lock()
|
||||||
|
let engine = combinedEngine ?? playbackEngine
|
||||||
|
stateLock.unlock()
|
||||||
|
guard let engine, engine.isRunning, let unit = engine.outputNode.audioUnit,
|
||||||
|
let playingOn = Self.currentDevice(of: unit)
|
||||||
|
else {
|
||||||
|
// Nothing is playing. If an engine was expected at all, this is the backstop firing.
|
||||||
|
reviveStoppedEngines("the default output device moved")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Empty UID = follow the system default; a pinned UID only moves if that device itself
|
||||||
|
// came or went, which `deviceID(forUID:)` reports by resolving to a different ID or none.
|
||||||
|
let shouldBeOn = config.speakerUID.isEmpty
|
||||||
|
? AudioDevices.defaultOutputDevice()
|
||||||
|
: AudioDevices.deviceID(forUID: config.speakerUID)
|
||||||
|
guard let shouldBeOn, shouldBeOn != playingOn else { return }
|
||||||
|
scheduleEngineRebuild(reason: "the output device changed under the session")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
/// The audio server can die and restart. It takes the session's configuration and every engine
|
||||||
|
/// with it, and the documented recovery is to build all of it again — the same rebuild a route
|
||||||
|
/// change uses, with the session activation back in front of it.
|
||||||
|
private func installMediaResetObserver(micEnabled: Bool) {
|
||||||
|
let observer = NotificationCenter.default.addObserver(
|
||||||
|
forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: nil
|
||||||
|
) { [weak self] _ in
|
||||||
|
SessionAudio.sessionQueue.async {
|
||||||
|
guard let self, !self.flag.isStopped else { return }
|
||||||
|
self.activateAudioSession(micEnabled: micEnabled)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.scheduleEngineRebuild(reason: "the audio services were reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stateLock.lock()
|
||||||
|
let stale = mediaResetObserver
|
||||||
|
mediaResetObserver = observer
|
||||||
|
stateLock.unlock()
|
||||||
|
if let stale { NotificationCenter.default.removeObserver(stale) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interruptions still happen to a mixable session — a phone call, Siri, an app that claims
|
||||||
|
/// a NON-mixable session of its own. iOS stops the engines, and when the interruption ends it
|
||||||
|
/// restarts NOTHING by itself; before this observer the stream just stayed silent (under the
|
||||||
|
/// old exclusive category, Music itself was such an interrupter, which is how "resume Music,
|
||||||
|
/// lose the stream" was ever possible). Reactivate and revive on `.ended` — unconditionally,
|
||||||
|
/// not only when iOS hints `.shouldResume`: a live stream is the one case where the user's
|
||||||
|
/// intent to keep hearing it is not in doubt, and `reviveStoppedEngines` already declines
|
||||||
|
/// when playback never went down.
|
||||||
|
private func installInterruptionObserver(micEnabled: Bool) {
|
||||||
|
let observer = NotificationCenter.default.addObserver(
|
||||||
|
forName: AVAudioSession.interruptionNotification,
|
||||||
|
object: AVAudioSession.sharedInstance(), queue: nil
|
||||||
|
) { [weak self] note in
|
||||||
|
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
AVAudioSession.InterruptionType(rawValue: raw) == .ended else { return }
|
||||||
|
SessionAudio.sessionQueue.async {
|
||||||
|
guard let self, !self.flag.isStopped else { return }
|
||||||
|
// The full activation, not a bare `setActive`: an interruption can drop the
|
||||||
|
// category configuration too, and on iOS the earpiece steer is per-route.
|
||||||
|
self.activateAudioSession(micEnabled: micEnabled)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.reviveStoppedEngines("an audio interruption ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stateLock.lock()
|
||||||
|
let stale = interruptionObserver
|
||||||
|
interruptionObserver = observer
|
||||||
|
stateLock.unlock()
|
||||||
|
if let stale { NotificationCenter.default.removeObserver(stale) }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
|
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
|
||||||
/// mechanism: the owner composes its reasons — the user's in-stream mute and the background
|
/// mechanism: the owner composes its reasons — the user's in-stream mute and the background
|
||||||
/// keep-alive's privacy mute — into one effective state and passes that here, so neither can
|
/// keep-alive's privacy mute — into one effective state and passes that here, so neither can
|
||||||
@@ -437,6 +771,21 @@ public final class SessionAudio {
|
|||||||
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
|
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// Whether playback is rendering, and the device it is rendering to. The device-change
|
||||||
|
/// recovery has exactly one observable signature from outside — "running again, on the device
|
||||||
|
/// the system just moved to" — and nothing else here could tell the two halves apart: a
|
||||||
|
/// stopped engine can still name the old device, and a retargeted one can still be stopped.
|
||||||
|
/// Used by `AudioDeviceSwitchTests`.
|
||||||
|
var playbackState: (running: Bool, device: AudioDeviceID?) {
|
||||||
|
stateLock.lock()
|
||||||
|
let engine = combinedEngine ?? playbackEngine
|
||||||
|
stateLock.unlock()
|
||||||
|
guard let engine else { return (false, nil) }
|
||||||
|
return (engine.isRunning, engine.outputNode.audioUnit.flatMap(Self.currentDevice(of:)))
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Playback (host → speaker)
|
// MARK: - Playback (host → speaker)
|
||||||
|
|
||||||
/// The playback jitter ring + the source node draining it — shared by the plain playback
|
/// The playback jitter ring + the source node draining it — shared by the plain playback
|
||||||
|
|||||||
@@ -47,12 +47,28 @@ public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
|
|||||||
/// optional String: the host owns the vocabulary, and an unknown future value must never fail
|
/// 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).
|
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
|
||||||
public var role: String?
|
public var role: String?
|
||||||
|
/// The token for this entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||||
|
/// `nil` on every older host and on every ordinary title. See `launcherIconImage`.
|
||||||
|
public var icon: String?
|
||||||
|
|
||||||
public var isCustom: Bool { store == "custom" }
|
public var isCustom: Bool { store == "custom" }
|
||||||
|
|
||||||
/// Whether this entry opens a launcher rather than a game.
|
/// Whether this entry opens a launcher rather than a game.
|
||||||
public var isLauncher: Bool { role == "launcher" }
|
public var isLauncher: Bool { role == "launcher" }
|
||||||
|
|
||||||
|
/// The brand-icon token, re-validated rather than taken on trust.
|
||||||
|
///
|
||||||
|
/// The host checks the shape on the way in, so this can only fire for a host older than that
|
||||||
|
/// check or one that isn't ours. The value reaches `Image(named:)`, and "the peer promised" is
|
||||||
|
/// not the standard a name lookup deserves.
|
||||||
|
public var iconToken: String? {
|
||||||
|
guard let t = icon, !t.isEmpty, t.count <= 32,
|
||||||
|
let first = t.first, first.isASCII, first.isLowercase,
|
||||||
|
t.allSatisfy({ $0.isASCII && ($0.isLowercase || $0.isNumber || $0 == "-") })
|
||||||
|
else { return nil }
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
/// Display name for the store badge — the same table the Rust clients use
|
/// 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
|
/// (`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.
|
/// every non-custom entry, which a Lutris or GOG title made a lie.
|
||||||
|
|||||||
@@ -1002,22 +1002,34 @@ public final class PunktfunkConnection {
|
|||||||
/// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine — the
|
/// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine — the
|
||||||
/// uniform replacement for per-platform rumble policy. The engine owns every decision
|
/// uniform replacement for per-platform rumble policy. The engine owns every decision
|
||||||
/// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros),
|
/// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros),
|
||||||
/// so apply commands verbatim: `(0, 0)` = stop now, non-zero = run at this level.
|
/// so apply commands verbatim: all-zero = stop now, non-zero = run at this level.
|
||||||
/// `backstopMs` is a safety-net duration for duration-parameterized platform APIs — the
|
/// `backstopMs` is a safety-net duration for duration-parameterized platform APIs — the
|
||||||
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
|
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
|
||||||
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
||||||
|
///
|
||||||
|
/// A command carries FOUR motor levels: the two handles plus the two Xbox impulse-trigger
|
||||||
|
/// motors (`leftTrigger`/`rightTrigger`, same 0...0xFFFF scale), which arrive on the 0xCA
|
||||||
|
/// plane's v3 tail. This calls the core's `_cmd2` entry point — `_cmd` is the frozen
|
||||||
|
/// two-handle form kept for out-of-tree embedders, and there is no reason for this client to
|
||||||
|
/// stay on it: a pad that reports no `GCHapticsLocality.leftTrigger`/`.rightTrigger` simply
|
||||||
|
/// has no engine for those levels and they go nowhere, which is the normal case.
|
||||||
public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws
|
public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws
|
||||||
-> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)?
|
-> (
|
||||||
|
pad: UInt16, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16,
|
||||||
|
backstopMs: UInt32
|
||||||
|
)?
|
||||||
{
|
{
|
||||||
feedbackLock.lock()
|
feedbackLock.lock()
|
||||||
defer { feedbackLock.unlock() }
|
defer { feedbackLock.unlock() }
|
||||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||||
|
|
||||||
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0
|
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0
|
||||||
let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs)
|
var lt: UInt16 = 0, rt: UInt16 = 0
|
||||||
|
let rc = punktfunk_connection_next_rumble_cmd2(
|
||||||
|
h, &pad, &low, &high, <, &rt, &backstop, timeoutMs)
|
||||||
switch rc {
|
switch rc {
|
||||||
case statusOK:
|
case statusOK:
|
||||||
return (pad, low, high, backstop)
|
return (pad, low, high, lt, rt, backstop)
|
||||||
case statusNoFrame:
|
case statusNoFrame:
|
||||||
return nil
|
return nil
|
||||||
case statusClosed:
|
case statusClosed:
|
||||||
|
|||||||
@@ -172,7 +172,8 @@ public final class GamepadFeedback {
|
|||||||
while rumbleBurst < 64, !flag.isStopped,
|
while rumbleBurst < 64, !flag.isStopped,
|
||||||
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
|
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
|
||||||
self?.routeRumble(
|
self?.routeRumble(
|
||||||
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high)
|
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high,
|
||||||
|
leftTrigger: c.leftTrigger, rightTrigger: c.rightTrigger)
|
||||||
rumbleBurst += 1
|
rumbleBurst += 1
|
||||||
}
|
}
|
||||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||||
@@ -225,12 +226,21 @@ public final class GamepadFeedback {
|
|||||||
|
|
||||||
/// Route one engine command to its pad's renderer (drain thread). A command for a pad with no
|
/// Route one engine command to its pad's renderer (drain thread). A command for a pad with no
|
||||||
/// live renderer — one that just left the forwarded set — is dropped.
|
/// live renderer — one that just left the forwarded set — is dropped.
|
||||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16) {
|
private func routeRumble(
|
||||||
|
pad: UInt8, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16
|
||||||
|
) {
|
||||||
let renderer = withRouting { rumbleByPad[pad] }
|
let renderer = withRouting { rumbleByPad[pad] }
|
||||||
renderer?.apply(low: low, high: high)
|
renderer?.apply(low: low, high: high, leftTrigger: leftTrigger, rightTrigger: rightTrigger)
|
||||||
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
||||||
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
||||||
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
||||||
|
//
|
||||||
|
// HANDLES ONLY, deliberately. A phone body is one actuator with no trigger analogue, so
|
||||||
|
// the trigger levels would have to be folded to arrive at all — and folding continuous
|
||||||
|
// impulse-trigger content (a racing title's engine RPM / tyre slip) onto the one motor
|
||||||
|
// this mirror has would buzz the phone flat-out for the whole race at a level the game
|
||||||
|
// never requested. Dropping them matches the core engine's policy for every pad without
|
||||||
|
// trigger motors.
|
||||||
if pad == 0 { deviceRumble?.apply(low: low, high: high) }
|
if pad == 0 { deviceRumble?.apply(low: low, high: high) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Button glyphs for the gamepad UI's legends, for a controller that ISN'T currently attached.
|
||||||
|
//
|
||||||
|
// While a pad is connected the truth is GameController's own `sfSymbolsName` on the live element —
|
||||||
|
// nothing here competes with that. The problem this file solves is the other half of the time: the
|
||||||
|
// instant `GamepadManager.active` goes nil (the pad slept, its battery died, it was unplugged, or
|
||||||
|
// `gamepadUIMode == "always"` put the console UI up with no pad at all) there is no element left to
|
||||||
|
// ask, and every legend fell back to the generic letter glyphs — which read as an Xbox pad. A
|
||||||
|
// DualSense user watched their ✕/◯ legends turn into A/B the moment the controller dozed off.
|
||||||
|
//
|
||||||
|
// So: `GamepadManager` remembers the KIND of the last controller that was actually attached
|
||||||
|
// (`DefaultsKey.lastGamepadKind`, never cleared on disconnect) and the legends resolve through this
|
||||||
|
// table instead. Deliberately NOT a user-facing setting — a "glyph style" picker is one more row in
|
||||||
|
// a settings screen to answer a question the app can answer itself, and the remembered pad is right
|
||||||
|
// essentially always: people own the controller they last plugged in.
|
||||||
|
//
|
||||||
|
// Positional, not nominal. `GCExtendedGamepad`'s buttonA/B/X/Y are POSITIONS (A = bottom, B =
|
||||||
|
// right, X = left, Y = top), so each family maps its own labels onto those positions — which is why
|
||||||
|
// the Nintendo column looks transposed: a Switch pad's bottom button is B and its right one is A.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import GameController
|
||||||
|
|
||||||
|
/// A face/shoulder button by POSITION, which is what `GCExtendedGamepad` exposes and what a legend
|
||||||
|
/// actually means ("press the bottom button"). The label drawn for it is the family's business.
|
||||||
|
public enum GamepadButtonRole: Sendable {
|
||||||
|
/// Bottom face button — Xbox A, PlayStation ✕, Nintendo B.
|
||||||
|
case a
|
||||||
|
/// Right face button — Xbox B, PlayStation ◯, Nintendo A.
|
||||||
|
case b
|
||||||
|
/// Left face button — Xbox X, PlayStation □, Nintendo Y.
|
||||||
|
case x
|
||||||
|
/// Top face button — Xbox Y, PlayStation △, Nintendo X.
|
||||||
|
case y
|
||||||
|
case leftShoulder
|
||||||
|
case rightShoulder
|
||||||
|
|
||||||
|
/// The role a `GCExtendedGamepad` key path names, so a caller that already spells its buttons
|
||||||
|
/// as key paths (every legend in the gamepad UI does — it reads `sfSymbolsName` off the live
|
||||||
|
/// element through one) can reach this table without restating itself. nil for any other
|
||||||
|
/// button: the legends only ever name these six, and a role invented for, say, the menu button
|
||||||
|
/// would have no honest glyph on half the families.
|
||||||
|
///
|
||||||
|
/// Compared with `==` rather than matched with `switch`: key paths are reference-typed and
|
||||||
|
/// their pattern-matching goes through the generic `Equatable` `~=`, which is easy to send to
|
||||||
|
/// an unintended overload. This spelling has exactly one meaning.
|
||||||
|
public init?(keyPath: KeyPath<GCExtendedGamepad, GCControllerButtonInput>) {
|
||||||
|
if keyPath == \GCExtendedGamepad.buttonA { self = .a }
|
||||||
|
else if keyPath == \GCExtendedGamepad.buttonB { self = .b }
|
||||||
|
else if keyPath == \GCExtendedGamepad.buttonX { self = .x }
|
||||||
|
else if keyPath == \GCExtendedGamepad.buttonY { self = .y }
|
||||||
|
else if keyPath == \GCExtendedGamepad.leftShoulder { self = .leftShoulder }
|
||||||
|
else if keyPath == \GCExtendedGamepad.rightShoulder { self = .rightShoulder }
|
||||||
|
else { return nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum GamepadGlyphs {
|
||||||
|
/// The SF Symbol a `role` wears on a `kind` of pad. Every name here is asserted to resolve on
|
||||||
|
/// the running OS by `GamepadGlyphTests` — a symbol name that doesn't exist renders as NOTHING
|
||||||
|
/// (SwiftUI draws an empty image rather than failing), so a typo would silently blank a legend
|
||||||
|
/// on real hardware and never show up in a build.
|
||||||
|
public static func symbol(_ role: GamepadButtonRole, for kind: PunktfunkConnection.GamepadType)
|
||||||
|
-> String {
|
||||||
|
switch role {
|
||||||
|
case .leftShoulder: return "l1.rectangle.roundedbottom"
|
||||||
|
case .rightShoulder: return "r1.rectangle.roundedbottom"
|
||||||
|
case .a, .b, .x, .y: return faceSymbol(role, for: kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func faceSymbol(
|
||||||
|
_ role: GamepadButtonRole, for kind: PunktfunkConnection.GamepadType
|
||||||
|
) -> String {
|
||||||
|
switch kind {
|
||||||
|
// PlayStation shapes. ✕ is the BOTTOM button, so it belongs to role `.a` — the mapping
|
||||||
|
// people mean when they say "the PlayStation glyphs".
|
||||||
|
case .dualSense, .dualSenseEdge, .dualShock4:
|
||||||
|
switch role {
|
||||||
|
case .a: return "xmark.circle"
|
||||||
|
case .b: return "circle.circle"
|
||||||
|
case .x: return "square.circle"
|
||||||
|
case .y: return "triangle.circle"
|
||||||
|
default: return "circle.circle"
|
||||||
|
}
|
||||||
|
// Nintendo's labels sit transposed on the same positions (bottom = B, right = A,
|
||||||
|
// left = Y, top = X) — printing Xbox letters on a Switch pad would name the wrong
|
||||||
|
// physical button, which is worse than a generic glyph.
|
||||||
|
case .switchPro:
|
||||||
|
switch role {
|
||||||
|
case .a: return "b.circle"
|
||||||
|
case .b: return "a.circle"
|
||||||
|
case .x: return "y.circle"
|
||||||
|
case .y: return "x.circle"
|
||||||
|
default: return "a.circle"
|
||||||
|
}
|
||||||
|
// Xbox, the Steam pads (Deck included — its ABXY is the Xbox layout), and `.auto`, which
|
||||||
|
// is what a client with no remembered pad has. Xbox letters double as the neutral default
|
||||||
|
// because they ARE the positional names in `GCExtendedGamepad`.
|
||||||
|
case .auto, .xbox360, .xboxOne, .steamController, .steamDeck, .steamController2:
|
||||||
|
switch role {
|
||||||
|
case .a: return "a.circle"
|
||||||
|
case .b: return "b.circle"
|
||||||
|
case .x: return "x.circle"
|
||||||
|
case .y: return "y.circle"
|
||||||
|
default: return "a.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,17 @@ public final class GamepadManager: ObservableObject {
|
|||||||
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
|
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
|
||||||
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
|
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
|
||||||
|
|
||||||
|
/// The kind of the last controller that was actually attached — persisted under
|
||||||
|
/// `DefaultsKey.lastGamepadKind` and deliberately NEVER cleared on disconnect. The gamepad
|
||||||
|
/// UI's legends read it (through `GamepadGlyphs`) whenever `active` is nil, so a DualSense
|
||||||
|
/// user's ✕/◯ hints don't turn into A/B the moment the pad sleeps, and so the legends are
|
||||||
|
/// right at all under `gamepadUIMode == "always"`, which puts the console UI up with no pad
|
||||||
|
/// attached by design. `.auto` = nothing has ever been seen on this device (⇒ neutral glyphs).
|
||||||
|
///
|
||||||
|
/// @Published so the legends re-render when a pad of a different family arrives; the screens
|
||||||
|
/// already observe this object for `active`.
|
||||||
|
@Published public private(set) var lastKnownKind: PunktfunkConnection.GamepadType
|
||||||
|
|
||||||
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
|
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
|
||||||
/// reselects immediately, so a Settings Picker can bind straight to this.
|
/// reselects immediately, so a Settings Picker can bind straight to this.
|
||||||
@Published public var preferredID: String {
|
@Published public var preferredID: String {
|
||||||
@@ -97,12 +108,19 @@ public final class GamepadManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let preferredKey = DefaultsKey.gamepadID
|
private static let preferredKey = DefaultsKey.gamepadID
|
||||||
|
private static let lastKindKey = DefaultsKey.lastGamepadKind
|
||||||
/// Connect order (identity-keyed) — drives both twin de-dup suffixes and auto-pick.
|
/// Connect order (identity-keyed) — drives both twin de-dup suffixes and auto-pick.
|
||||||
private var connectOrder: [ObjectIdentifier] = []
|
private var connectOrder: [ObjectIdentifier] = []
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
preferredID = UserDefaults.standard.string(forKey: Self.preferredKey) ?? ""
|
preferredID = UserDefaults.standard.string(forKey: Self.preferredKey) ?? ""
|
||||||
|
// Stored as an Int (what UserDefaults round-trips losslessly) and validated back into a
|
||||||
|
// real case: a value written by a NEWER client — a pad family this build has no case for
|
||||||
|
// — must fall back to the neutral glyphs, not trap on an invalid raw value.
|
||||||
|
lastKnownKind = (UserDefaults.standard.object(forKey: Self.lastKindKey) as? Int)
|
||||||
|
.flatMap { UInt32(exactly: $0) }
|
||||||
|
.flatMap(PunktfunkConnection.GamepadType.init(rawValue:)) ?? .auto
|
||||||
observers.append(NotificationCenter.default.addObserver(
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
forName: .GCControllerDidConnect, object: nil, queue: .main
|
forName: .GCControllerDidConnect, object: nil, queue: .main
|
||||||
) { [weak self] n in
|
) { [weak self] n in
|
||||||
@@ -212,6 +230,13 @@ public final class GamepadManager: ObservableObject {
|
|||||||
// (list is in connect order). A stale pin falls back to automatic.
|
// (list is in connect order). A stale pin falls back to automatic.
|
||||||
let pinned = candidates.last { $0.id == preferredID }
|
let pinned = candidates.last { $0.id == preferredID }
|
||||||
active = pinned ?? candidates.last
|
active = pinned ?? candidates.last
|
||||||
|
// Remember the family for the legends (see `lastKnownKind`). Only ever WRITTEN, never
|
||||||
|
// cleared: `active` going nil is precisely the moment the memory has to survive, and a
|
||||||
|
// pad whose `kind` is genuinely unknown never becomes active in the first place.
|
||||||
|
if let active, active.kind != lastKnownKind {
|
||||||
|
lastKnownKind = active.kind
|
||||||
|
UserDefaults.standard.set(Int(active.kind.rawValue), forKey: Self.lastKindKey)
|
||||||
|
}
|
||||||
// Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad
|
// Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad
|
||||||
// (explicit single-player); Automatic forwards every extended controller in connect order
|
// (explicit single-player); Automatic forwards every extended controller in connect order
|
||||||
// (oldest→newest), so a game's player numbers are stable across hot-plug churn.
|
// (oldest→newest), so a game's player numbers are stable across hot-plug churn.
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Where the arrow keys go in the library's plain poster grid (LibraryView's touch layout on
|
||||||
|
// iOS/iPadOS/macOS) — the model behind "select games with keyboard arrows, enter to launch".
|
||||||
|
//
|
||||||
|
// The grid is up to TWO sections (launcher entries above titles), each rendered as its own
|
||||||
|
// `LazyVGrid`. A single flat index across both would step by the wrong amount at the boundary
|
||||||
|
// whenever the first section's last row is partial — up from the second section's first row would
|
||||||
|
// land in the middle of the first section rather than on the row above. So moves happen WITHIN a
|
||||||
|
// section, with an explicit hand-off at its edges that preserves the column.
|
||||||
|
//
|
||||||
|
// Lives in PunktfunkKit rather than beside the view because this is arithmetic with edge cases —
|
||||||
|
// partial rows, section hand-offs, empty sections — and PunktfunkKit is the target the tests can
|
||||||
|
// reach (the app is an executable target). Pure values in, pure value out: no SwiftUI.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct LibraryGridNav {
|
||||||
|
/// Game ids per RENDERED section, in display order. Callers drop empty sections before
|
||||||
|
/// constructing this, so `sections` never contains one.
|
||||||
|
public let sections: [[String]]
|
||||||
|
/// How many columns the grid actually laid out — the caller derives it from the measured
|
||||||
|
/// width using `.adaptive`'s own fitting rule, so a vertical move is exactly one visual row.
|
||||||
|
public let columns: Int
|
||||||
|
|
||||||
|
public init(sections: [[String]], columns: Int) {
|
||||||
|
self.sections = sections
|
||||||
|
// A zero or negative count would divide by zero below; one column is the degenerate grid.
|
||||||
|
self.columns = max(1, columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The id `direction` leads to from `current`, or nil when there is nowhere to go (so the
|
||||||
|
/// caller leaves the cursor where it is). A nil `current` — nothing selected yet — lands on
|
||||||
|
/// the very first tile, so the first arrow press always produces a visible cursor rather than
|
||||||
|
/// appearing to do nothing.
|
||||||
|
public func move(from current: String?, _ direction: GamepadMenuInput.Direction) -> String? {
|
||||||
|
guard !sections.isEmpty else { return nil }
|
||||||
|
guard let (s, i) = locate(current) else { return sections[0].first }
|
||||||
|
switch direction {
|
||||||
|
case .left:
|
||||||
|
if i > 0 { return sections[s][i - 1] }
|
||||||
|
return s > 0 ? sections[s - 1].last : nil
|
||||||
|
case .right:
|
||||||
|
if i + 1 < sections[s].count { return sections[s][i + 1] }
|
||||||
|
return s + 1 < sections.count ? sections[s + 1].first : nil
|
||||||
|
case .up:
|
||||||
|
if i >= columns { return sections[s][i - columns] }
|
||||||
|
// Off the top of this section: the section above, same column, its LAST row —
|
||||||
|
// clamped, because that row may be partial.
|
||||||
|
guard s > 0 else { return nil }
|
||||||
|
let above = sections[s - 1]
|
||||||
|
let lastRowStart = ((above.count - 1) / columns) * columns
|
||||||
|
return above[min(lastRowStart + (i % columns), above.count - 1)]
|
||||||
|
case .down:
|
||||||
|
if i + columns < sections[s].count { return sections[s][i + columns] }
|
||||||
|
// Off the bottom: the section below, same column, its first row.
|
||||||
|
if s + 1 < sections.count {
|
||||||
|
let below = sections[s + 1]
|
||||||
|
return below[min(i % columns, below.count - 1)]
|
||||||
|
}
|
||||||
|
// Nothing below. A press from a full row above the last (partial) one still settles
|
||||||
|
// on the final tile rather than refusing — the row IS down from here, just short.
|
||||||
|
let lastRowStart = ((sections[s].count - 1) / columns) * columns
|
||||||
|
return i < lastRowStart ? sections[s].last : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (section, index within it) for an id, or nil when it isn't in the grid any more.
|
||||||
|
private func locate(_ id: String?) -> (Int, Int)? {
|
||||||
|
guard let id else { return nil }
|
||||||
|
for (s, section) in sections.enumerated() {
|
||||||
|
if let i = section.firstIndex(of: id) { return (s, i) }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,9 @@ enum RumbleTuning {
|
|||||||
/// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
|
/// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
|
||||||
/// distinct frequencies mirroring the real hardware they emulate — low/left ≈ the heavy
|
/// distinct frequencies mirroring the real hardware they emulate — low/left ≈ the heavy
|
||||||
/// low-frequency rotor, high/right ≈ the light buzzer; a single combined actuator keeps the
|
/// low-frequency rotor, high/right ≈ the light buzzer; a single combined actuator keeps the
|
||||||
/// proven mid value.
|
/// proven mid value. The impulse-trigger motors are small and light — the same character as
|
||||||
|
/// the high/right buzzer — so they reuse `sharpnessHigh` rather than introduce a number
|
||||||
|
/// nobody has measured on real trigger hardware.
|
||||||
static let sharpnessLow: Float = 0.3
|
static let sharpnessLow: Float = 0.3
|
||||||
static let sharpnessHigh: Float = 0.7
|
static let sharpnessHigh: Float = 0.7
|
||||||
static let sharpnessCombined: Float = 0.5
|
static let sharpnessCombined: Float = 0.5
|
||||||
@@ -140,9 +142,21 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
private var controller: GCController?
|
private var controller: GCController?
|
||||||
private var low: Motor?
|
private var low: Motor?
|
||||||
private var high: Motor?
|
private var high: Motor?
|
||||||
/// Wire-truth target (raw wire units) — the engine command's level, applied verbatim; the
|
/// The two Xbox impulse-trigger motors, when the pad offers
|
||||||
/// core policy engine owns when it ends (explicit zero commands), so no deadline lives here.
|
/// `GCHapticsLocality.leftTrigger`/`.rightTrigger`. **Nil is the normal case** — every pad but
|
||||||
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
/// an Xbox One/Series/Elite has no such actuator, and the tree has already observed Xbox pads
|
||||||
|
/// on Apple exposing no haptics engine at all — so their absence is never logged and never
|
||||||
|
/// counts as a setup failure. Independent of the handle split: a pad may offer trigger
|
||||||
|
/// localities with or without split handles, and losing one does not implicate the other.
|
||||||
|
private var leftTrigger: Motor?
|
||||||
|
private var rightTrigger: Motor?
|
||||||
|
/// Wire-truth target (raw wire units) — the engine command's four levels, applied verbatim;
|
||||||
|
/// the core policy engine owns when it ends (explicit zero commands), so no deadline lives
|
||||||
|
/// here. The trigger levels are only ever non-zero against a Windows HID Xbox host pad; every
|
||||||
|
/// other backend on every OS lacks the channel entirely (XInput's `XINPUT_VIBRATION` and
|
||||||
|
/// evdev's `FF_RUMBLE` each carry exactly two magnitudes).
|
||||||
|
private var target: (low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16) =
|
||||||
|
(0, 0, 0, 0)
|
||||||
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
||||||
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
|
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
|
||||||
/// so an idle controller costs no timer wakeups and no radio traffic.
|
/// so an idle controller costs no timer wakeups and no radio traffic.
|
||||||
@@ -216,22 +230,28 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the wire-truth target. Called with every 0xCA state the host sends — level changes AND
|
/// Set the wire-truth target: one policy-engine command's four motor levels, applied verbatim.
|
||||||
/// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
|
/// Called with every 0xCA state the host sends — level changes AND renewals — and the core
|
||||||
/// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
|
/// engine owns when a level ends (it emits explicit zero commands), so nothing here decides.
|
||||||
/// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an
|
///
|
||||||
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
|
/// `leftTrigger`/`rightTrigger` are the Xbox impulse-trigger motors. They default to zero so
|
||||||
/// lapses mid-effect.
|
/// handle-only callers (the debug test panel, the tuning tests) read unchanged, which is also
|
||||||
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
/// the wire's own rule: on a level-triggered plane an absent level is off, never "keep what
|
||||||
|
/// you had".
|
||||||
|
func apply(
|
||||||
|
low lowAmp: UInt16, high highAmp: UInt16, leftTrigger ltAmp: UInt16 = 0,
|
||||||
|
rightTrigger rtAmp: UInt16 = 0
|
||||||
|
) {
|
||||||
queue.async {
|
queue.async {
|
||||||
let active = lowAmp != 0 || highAmp != 0
|
let next = (lowAmp, highAmp, ltAmp, rtAmp)
|
||||||
|
let active = next != (0, 0, 0, 0)
|
||||||
if active != self.wasActive {
|
if active != self.wasActive {
|
||||||
self.wasActive = active
|
self.wasActive = active
|
||||||
log.debug(
|
log.debug(
|
||||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public) lt=\(ltAmp, privacy: .public) rt=\(rtAmp, privacy: .public)")
|
||||||
}
|
}
|
||||||
guard (lowAmp, highAmp) != self.target else { return }
|
guard next != self.target else { return }
|
||||||
self.target = (lowAmp, highAmp)
|
self.target = next
|
||||||
self.render()
|
self.render()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +261,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
queue.sync {
|
queue.sync {
|
||||||
self.ticker?.cancel()
|
self.ticker?.cancel()
|
||||||
self.ticker = nil
|
self.ticker = nil
|
||||||
self.target = (0, 0)
|
self.target = (0, 0, 0, 0)
|
||||||
self.wasActive = false
|
self.wasActive = false
|
||||||
self.teardown()
|
self.teardown()
|
||||||
self.closeHID()
|
self.closeHID()
|
||||||
@@ -256,7 +276,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
defer { updateTicker() }
|
defer { updateTicker() }
|
||||||
if renderHID() { return }
|
if renderHID() { return }
|
||||||
guard !broken else { return }
|
guard !broken else { return }
|
||||||
let audible = target.low != 0 || target.high != 0
|
let audible = target != (0, 0, 0, 0)
|
||||||
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
|
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
|
||||||
setup()
|
setup()
|
||||||
}
|
}
|
||||||
@@ -274,6 +294,18 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
|
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
|
||||||
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
|
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
|
||||||
}
|
}
|
||||||
|
// Impulse triggers: rendered ONLY where the hardware has the actuators, never folded into
|
||||||
|
// the handles. `reconcile` on a nil slot is a no-op returning true, so a pad without them
|
||||||
|
// silently drops the levels — which is the correct degrade and the common case.
|
||||||
|
//
|
||||||
|
// Their outcome is deliberately kept OUT of `ok`: a trigger engine erroring must not tear
|
||||||
|
// down the handle engines (which are what the pad's rumble mostly is) nor flip
|
||||||
|
// `preferCombined`, which is a statement about the handle split and nothing else. Nothing
|
||||||
|
// is orphaned by that — a failed reconcile leaves the slot's Motor in place, so the next
|
||||||
|
// tick simply retries it, and an engine that is genuinely dead fires its
|
||||||
|
// stopped/reset handler, which tears down all four slots for a lazy rebuild.
|
||||||
|
_ = reconcile(&leftTrigger, to: RumbleTuning.amplitude(target.leftTrigger))
|
||||||
|
_ = reconcile(&rightTrigger, to: RumbleTuning.amplitude(target.rightTrigger))
|
||||||
if !ok {
|
if !ok {
|
||||||
let wasSplit = high != nil
|
let wasSplit = high != nil
|
||||||
teardown()
|
teardown()
|
||||||
@@ -410,9 +442,11 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
/// The ticker runs only while something needs tending — any nonzero target (watchdog,
|
/// The ticker runs only while something needs tending — any nonzero target (watchdog,
|
||||||
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
|
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
|
||||||
private func updateTicker() {
|
private func updateTicker() {
|
||||||
let needed = target != (0, 0)
|
let needed = target != (0, 0, 0, 0)
|
||||||
|| low?.current != nil || low?.retiring != nil
|
|| low?.current != nil || low?.retiring != nil
|
||||||
|| high?.current != nil || high?.retiring != nil
|
|| high?.current != nil || high?.retiring != nil
|
||||||
|
|| leftTrigger?.current != nil || leftTrigger?.retiring != nil
|
||||||
|
|| rightTrigger?.current != nil || rightTrigger?.retiring != nil
|
||||||
if needed, ticker == nil {
|
if needed, ticker == nil {
|
||||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||||
t.schedule(
|
t.schedule(
|
||||||
@@ -477,6 +511,26 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
preferCombined = true
|
preferCombined = true
|
||||||
log.info("rumble: split-handle engines failing — will retry with one combined engine")
|
log.info("rumble: split-handle engines failing — will retry with one combined engine")
|
||||||
}
|
}
|
||||||
|
// Return before the trigger engines: the retry path re-enters setup() on the same
|
||||||
|
// `low == nil, high == nil` condition, so building them here would leak a fresh pair
|
||||||
|
// on every attempt (teardown() only runs on the failure paths above, and this is not
|
||||||
|
// one of them).
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Impulse-trigger motors, built last and best-effort. Independent of the handle split —
|
||||||
|
// the localities are separate and a pad can offer either, both or neither — and NOT part
|
||||||
|
// of the failure test above: nil here is the ordinary state of every pad that is not an
|
||||||
|
// Xbox One/Series/Elite, so it must not read as "engine setup failed", back off the handle
|
||||||
|
// engines, or produce a log line on a path that runs per controller attach.
|
||||||
|
//
|
||||||
|
// Whether a given pad + OS pair actually reports these localities is UNVERIFIED on glass.
|
||||||
|
// The degrade needs no code: `createEngine(withLocality:)` returns nil, the slots stay nil,
|
||||||
|
// and `reconcile` no-ops on them.
|
||||||
|
if localities.contains(.leftTrigger) {
|
||||||
|
leftTrigger = makeMotor(haptics, .leftTrigger, sharpness: RumbleTuning.sharpnessHigh)
|
||||||
|
}
|
||||||
|
if localities.contains(.rightTrigger) {
|
||||||
|
rightTrigger = makeMotor(haptics, .rightTrigger, sharpness: RumbleTuning.sharpnessHigh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +617,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func teardown() {
|
private func teardown() {
|
||||||
for m in [low, high].compactMap({ $0 }) {
|
for m in [low, high, leftTrigger, rightTrigger].compactMap({ $0 }) {
|
||||||
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
|
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
|
||||||
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||||
m.engine.stoppedHandler = { _ in }
|
m.engine.stoppedHandler = { _ in }
|
||||||
@@ -577,6 +631,8 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
low = nil
|
low = nil
|
||||||
high = nil
|
high = nil
|
||||||
|
leftTrigger = nil
|
||||||
|
rightTrigger = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func seconds(since t: DispatchTime) -> TimeInterval {
|
private func seconds(since t: DispatchTime) -> TimeInterval {
|
||||||
@@ -624,6 +680,16 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
||||||
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
||||||
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
||||||
|
///
|
||||||
|
/// **The impulse-trigger levels are deliberately dropped here, and there is no mapping to
|
||||||
|
/// invent.** A DualSense has *adaptive* triggers — force resistance on a trigger you press,
|
||||||
|
/// driven by the separate 0xCD `HidOutput.Trigger` plane — and no trigger *motors*. The two
|
||||||
|
/// features are unrelated hardware that only share a word: an Xbox Series pad has trigger
|
||||||
|
/// motors and no adaptive triggers, a DualSense has the reverse. Routing wire trigger rumble
|
||||||
|
/// into either the DS5 rumble bytes (which are the two handles) or the adaptive-trigger
|
||||||
|
/// parameter block would fabricate feedback the game never asked for. This path returning
|
||||||
|
/// `true` also means a macOS DualSense never reaches the CoreHaptics trigger localities above,
|
||||||
|
/// which is correct for the same reason.
|
||||||
private func renderHID() -> Bool {
|
private func renderHID() -> Bool {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
guard let hid = dualSenseHID else { return false }
|
guard let hid = dualSenseHID else { return false }
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// The launcher tiles' brand marks: template vector imagesets in Resources/LauncherIcons.xcassets
|
||||||
|
// (generated from the repo's assets/launcher-icons masters by scripts/gen-launcher-icons.sh —
|
||||||
|
// per-mark provenance and licensing in that directory's README), resolved from a library entry's
|
||||||
|
// `icon` token. Template rendering means they tint with `foregroundStyle` like an SF Symbol.
|
||||||
|
//
|
||||||
|
// The sibling of OsIcon.swift, which does the equivalent job for the host cards' OS marks. SF
|
||||||
|
// Symbols ships no third-party brand glyphs, so a curated registry is the only route.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The icon tokens this client ships art for. A token outside this set draws nothing and the tile
|
||||||
|
/// falls back to naming its launcher — which is what every launcher tile looked like before icons
|
||||||
|
/// existed, so an unknown mark degrades to the old design rather than to a hole.
|
||||||
|
///
|
||||||
|
/// Checked against rather than interpolated: `Image(named:)` is a name lookup, and the set is the
|
||||||
|
/// only thing that decides which names it can ever see.
|
||||||
|
private let launcherIconTokensShipped: Set<String> = [
|
||||||
|
"steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The brand mark for a library entry's `icon` token, or nil — no view at all — when the entry
|
||||||
|
/// carries no token or names one this client ships no art for.
|
||||||
|
public func launcherIconImage(for token: String?) -> Image? {
|
||||||
|
guard let token, launcherIconTokensShipped.contains(token) else { return nil }
|
||||||
|
return Image("launcher-\(token)", bundle: .module)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "epic.pdf", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "gog.pdf", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "heroic.pdf", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "lutris.pdf", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "playnite.pdf", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "steam.pdf", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "xbox.pdf", "idiom" : "universal" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,6 +191,13 @@ public struct DeepLink: Equatable, Sendable {
|
|||||||
profile: (profile?.isEmpty ?? true) ? nil : profile)
|
profile: (profile?.isEmpty ?? true) ? nil : profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A library link for a saved host — the shape the library widget and the Open Library intent
|
||||||
|
/// emit. Opens the host's game library without starting a session; a session begins only when
|
||||||
|
/// the user picks a title there, through the normal connect path.
|
||||||
|
public static func browse(host: UUID) -> DeepLink {
|
||||||
|
DeepLink(route: .browse, hostRef: host.uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
/// The self-emitted form for a saved host: id first (address-independent), with the address
|
/// The self-emitted form for a saved host: id first (address-independent), with the address
|
||||||
/// and pin alongside so the link degrades to a confirmation sheet instead of a dead click when
|
/// and pin alongside so the link degrades to a confirmation sheet instead of a dead click when
|
||||||
/// the record is gone ("Copy link", and any shortcut written from a card).
|
/// the record is gone ("Copy link", and any shortcut written from a card).
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ public enum DefaultsKey {
|
|||||||
public static let compositor = "punktfunk.compositor"
|
public static let compositor = "punktfunk.compositor"
|
||||||
public static let gamepadType = "punktfunk.gamepadType"
|
public static let gamepadType = "punktfunk.gamepadType"
|
||||||
public static let gamepadID = "punktfunk.gamepadID"
|
public static let gamepadID = "punktfunk.gamepadID"
|
||||||
|
/// The `PunktfunkConnection.GamepadType` raw value of the last controller that was actually
|
||||||
|
/// attached — written by `GamepadManager` whenever one becomes active, never cleared on
|
||||||
|
/// disconnect. It exists so the gamepad UI's button legends keep speaking the pad the user
|
||||||
|
/// owns: the live controller's own `sfSymbolsName` is authoritative while it's connected, but
|
||||||
|
/// the moment it sleeps or disconnects there is nothing left to ask, and the legends used to
|
||||||
|
/// snap back to generic letter glyphs (i.e. Xbox) under a DualSense user's hands. Also what
|
||||||
|
/// makes the legends right at all under `gamepadUIMode == "always"`, where the console UI is
|
||||||
|
/// up with no pad attached by design. See `GamepadGlyphs`.
|
||||||
|
public static let lastGamepadKind = "punktfunk.lastGamepadKind"
|
||||||
/// Forward this device's controllers to the host at all (default true). Off is for a
|
/// Forward this device's controllers to the host at all (default true). Off is for a
|
||||||
/// couch whose controller reaches the host another way — USB passthrough such as
|
/// couch whose controller reaches the host another way — USB passthrough such as
|
||||||
/// VirtualHere, or a pad plugged into the host — where forwarding as well would give the
|
/// VirtualHere, or a pad plugged into the host — where forwarding as well would give the
|
||||||
|
|||||||
@@ -79,7 +79,13 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
|
|||||||
// too: the calm mix on the form screens lifts toward nothing. What is left is a
|
// too: the calm mix on the form screens lifts toward nothing. What is left is a
|
||||||
// faint indigo→violet ember in the bright corner. The accent stays the brand violet
|
// faint indigo→violet ember in the bright corner. The accent stays the brand violet
|
||||||
// — focus has to be findable on black.
|
// — focus has to be findable on black.
|
||||||
id: "oled", name: "OLED",
|
//
|
||||||
|
// Named for the look, not the panel technology: black with a thin violet corona is an
|
||||||
|
// eclipse, and it belongs beside Nebula and Abyss rather than reading as a spec sheet.
|
||||||
|
// ⚠ The ID stays "oled" — it is the stored `ui_palette` value AND the cross-client key
|
||||||
|
// (pf-console-ui's library.rs, the Android GamepadPalette.kt), so renaming it would
|
||||||
|
// orphan every saved choice and desync the three clients. Only the label moved.
|
||||||
|
id: "oled", name: "Eclipse",
|
||||||
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
|
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
|
||||||
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
|
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
|
||||||
SIMD3(0.120, 0.024, 0.130)],
|
SIMD3(0.120, 0.024, 0.130)],
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// The device-switch regression, end to end against a real session.
|
||||||
|
//
|
||||||
|
// An AVAudioEngine does not follow the audio hardware: when the output device changes under a
|
||||||
|
// running engine it STOPS ITSELF and stays stopped. Nothing restarted it, so a stream whose
|
||||||
|
// output moved mid-session — AirPods taken out of an ear, a headset unplugged, the default
|
||||||
|
// changed in System Settings — played silence from that moment on: nothing on the speakers the
|
||||||
|
// system had just moved to, and nothing in the AirPods when they went back in, since that is a
|
||||||
|
// second stop rather than a recovery. Only restarting the whole stream brought audio back.
|
||||||
|
//
|
||||||
|
// This drives the real `SessionAudio` against the loopback host and moves the system's default
|
||||||
|
// output device out from under it, twice — out and back, the exact shape of the field report.
|
||||||
|
// Playback-only (mic off): it is the render side that died, and a mic would drag the microphone
|
||||||
|
// permission and the voice processor into a test that is about neither.
|
||||||
|
//
|
||||||
|
// Driven by clients/apple/test-loopback.sh, like its LoopbackIntegrationTests siblings.
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import AVFoundation
|
||||||
|
import CoreAudio
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class AudioDeviceSwitchTests: XCTestCase {
|
||||||
|
/// Set the system default output device. Test-local on purpose: nothing in the app ever
|
||||||
|
/// changes the user's device, it only follows it.
|
||||||
|
private func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var dev = id
|
||||||
|
return AudioObjectSetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
|
||||||
|
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pump the MAIN runloop until playback is running on `device`, or the deadline passes. The
|
||||||
|
/// recovery lands on the main queue (a debounced hop, then possibly a retry ladder), so a
|
||||||
|
/// sleeping test would block the very thing it is waiting for.
|
||||||
|
private func waitForPlayback(
|
||||||
|
_ audio: SessionAudio, on device: AudioDeviceID, timeout: TimeInterval
|
||||||
|
) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||||
|
let state = audio.playbackState
|
||||||
|
if state.running, state.device == device { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPlaybackFollowsAnOutputDeviceChange() throws {
|
||||||
|
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
|
||||||
|
let port = UInt16(portStr)
|
||||||
|
else {
|
||||||
|
throw XCTSkip("needs a running punktfunk1-host — use clients/apple/test-loopback.sh")
|
||||||
|
}
|
||||||
|
guard let original = AudioDevices.defaultOutputDevice() else {
|
||||||
|
throw XCTSkip("no default output device")
|
||||||
|
}
|
||||||
|
let others = AudioDevices.outputs()
|
||||||
|
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
|
||||||
|
.filter { $0 != original }
|
||||||
|
guard let target = others.first else {
|
||||||
|
throw XCTSkip("needs a second output device to switch to")
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = try PunktfunkConnection(
|
||||||
|
host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60,
|
||||||
|
bitrateKbps: 50_000)
|
||||||
|
let audio = SessionAudio(connection: conn)
|
||||||
|
// "" speaker UID = follow the system default, which is what the report was running and
|
||||||
|
// the only configuration a default-device change is supposed to move.
|
||||||
|
audio.start(
|
||||||
|
speakerUID: "", micUID: "", micChannel: 0, micEnabled: false, echoCancel: false)
|
||||||
|
defer {
|
||||||
|
audio.stop()
|
||||||
|
_ = setDefaultOutput(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForPlayback(audio, on: original, timeout: 5),
|
||||||
|
"playback never started on the current default output device")
|
||||||
|
|
||||||
|
// Out: the device the stream was playing to goes away underneath it.
|
||||||
|
XCTAssertEqual(setDefaultOutput(target), noErr)
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForPlayback(audio, on: target, timeout: 10),
|
||||||
|
"playback did not come back after the output device changed — this is the field "
|
||||||
|
+ "report: no sound on the device the system moved to, until the stream is "
|
||||||
|
+ "restarted")
|
||||||
|
|
||||||
|
// And back: the second half of the report, where putting the AirPods back in produced a
|
||||||
|
// second stop rather than a recovery.
|
||||||
|
XCTAssertEqual(setDefaultOutput(original), noErr)
|
||||||
|
XCTAssertTrue(
|
||||||
|
waitForPlayback(audio, on: original, timeout: 10),
|
||||||
|
"playback did not come back after the output device changed back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// The trigger half of surviving a device change: does the session actually get TOLD?
|
||||||
|
//
|
||||||
|
// An AVAudioEngine stops itself when its output hardware changes and never restarts on its own, so
|
||||||
|
// everything downstream of these notifications is dead code if the notification never arrives. The
|
||||||
|
// rebuild itself needs a live session to exercise (and so a host, which does not build on macOS),
|
||||||
|
// but the wiring does not — and the wiring is where a silent failure costs a session all of its
|
||||||
|
// audio, which is exactly the shape of the bug this watcher exists to fix.
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import XCTest
|
||||||
|
#if os(macOS)
|
||||||
|
import CoreAudio
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class AudioDeviceWatcherTests: XCTestCase {
|
||||||
|
/// The callbacks land on the main queue, so a test that slept would block the thing it waits
|
||||||
|
/// for. Pumps until `predicate` holds or the deadline passes.
|
||||||
|
private func pump(until predicate: () -> Bool, timeout: TimeInterval = 2) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if predicate() { return true }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
|
||||||
|
}
|
||||||
|
return predicate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity gate is the one line that could swallow every notification silently: get it
|
||||||
|
/// wrong and the recovery compiles, installs, runs — and never fires.
|
||||||
|
func testAConfigurationChangeFromOurEngineReachesTheOwner() {
|
||||||
|
let engine = AVAudioEngine()
|
||||||
|
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||||
|
let watcher = AudioDeviceWatcher(
|
||||||
|
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
|
||||||
|
watcher.start()
|
||||||
|
defer { watcher.stop() }
|
||||||
|
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .AVAudioEngineConfigurationChange, object: engine)
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
pump(until: { reasons.contains(.engineConfiguration) }),
|
||||||
|
"the session was never told its engine's configuration changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A retired engine posts one last change as it is torn down, and other AVAudioEngines in the
|
||||||
|
/// process are not ours to restart — rebuilding for either would interrupt healthy playback.
|
||||||
|
func testAConfigurationChangeFromAForeignEngineIsIgnored() {
|
||||||
|
let ours = AVAudioEngine()
|
||||||
|
let stranger = AVAudioEngine()
|
||||||
|
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||||
|
let watcher = AudioDeviceWatcher(
|
||||||
|
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
|
||||||
|
watcher.start()
|
||||||
|
defer { watcher.stop() }
|
||||||
|
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .AVAudioEngineConfigurationChange, object: stranger)
|
||||||
|
// Give it the same grace the positive case gets, then require silence.
|
||||||
|
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
|
||||||
|
XCTAssertTrue(reasons.isEmpty, "a foreign engine's change was taken for ours")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStopSilencesTheWatcher() {
|
||||||
|
let engine = AVAudioEngine()
|
||||||
|
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||||
|
let watcher = AudioDeviceWatcher(
|
||||||
|
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
|
||||||
|
watcher.start()
|
||||||
|
watcher.stop()
|
||||||
|
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .AVAudioEngineConfigurationChange, object: engine)
|
||||||
|
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
|
||||||
|
XCTAssertTrue(reasons.isEmpty, "a stopped watcher still reported")
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// The backstop, against the real HAL: move the system's default output device — the thing that
|
||||||
|
/// happens when AirPods come out of an ear — and require that the session hears about it. This
|
||||||
|
/// is the trigger the recovery leans on for the voice-processing engine, whose own notification
|
||||||
|
/// behaviour cannot be verified here (no Mac in this project's fleet can initialize VPIO).
|
||||||
|
func testTheDefaultOutputDeviceMovingReachesTheOwner() throws {
|
||||||
|
guard let original = AudioDevices.defaultOutputDevice() else {
|
||||||
|
throw XCTSkip("no default output device")
|
||||||
|
}
|
||||||
|
let others = AudioDevices.outputs()
|
||||||
|
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
|
||||||
|
.filter { $0 != original }
|
||||||
|
guard let target = others.first else {
|
||||||
|
throw XCTSkip("needs a second output device to switch to")
|
||||||
|
}
|
||||||
|
|
||||||
|
var reasons: [AudioDeviceWatcher.Reason] = []
|
||||||
|
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
|
||||||
|
watcher.start()
|
||||||
|
defer {
|
||||||
|
_ = Self.setDefaultOutput(original)
|
||||||
|
watcher.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(Self.setDefaultOutput(target), noErr)
|
||||||
|
XCTAssertTrue(
|
||||||
|
pump(until: { reasons.contains(.defaultOutputDevice) }, timeout: 5),
|
||||||
|
"the session was never told the default output device moved")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-local on purpose: nothing in the app ever changes the user's device, it only follows it.
|
||||||
|
private static func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var dev = id
|
||||||
|
return AudioObjectSetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
|
||||||
|
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// The remembered-controller glyph table.
|
||||||
|
//
|
||||||
|
// The load-bearing assertion here is that every SF Symbol name RESOLVES. `Image(systemName:)`
|
||||||
|
// renders a name the OS doesn't know as NOTHING at all — no crash, no log, no red build — so a
|
||||||
|
// typo in the table would silently blank a legend cell on real hardware and be invisible until
|
||||||
|
// someone looked at a device. This test is the only thing standing between that and a release.
|
||||||
|
|
||||||
|
import GameController
|
||||||
|
import XCTest
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
final class GamepadGlyphTests: XCTestCase {
|
||||||
|
private let roles: [GamepadButtonRole] = [.a, .b, .x, .y, .leftShoulder, .rightShoulder]
|
||||||
|
|
||||||
|
/// Does the running OS actually have this symbol?
|
||||||
|
private func symbolExists(_ name: String) -> Bool {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
return UIImage(systemName: name) != nil
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
return NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEveryGlyphNameResolvesOnThisOS() {
|
||||||
|
for kind in PunktfunkConnection.GamepadType.allCases {
|
||||||
|
for role in roles {
|
||||||
|
let name = GamepadGlyphs.symbol(role, for: kind)
|
||||||
|
XCTAssertTrue(
|
||||||
|
symbolExists(name),
|
||||||
|
"SF Symbol \"\(name)\" (\(role), \(kind)) does not resolve — the legend cell "
|
||||||
|
+ "would render blank on device")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ✕ is the BOTTOM button on a PlayStation pad, which is `GCExtendedGamepad.buttonA` — the
|
||||||
|
/// whole point of the table being positional. Getting this backwards would print ◯ where the
|
||||||
|
/// user has to press ✕.
|
||||||
|
func testPlayStationFaceButtonsAreShapesInPositionalOrder() {
|
||||||
|
for kind in [PunktfunkConnection.GamepadType.dualSense, .dualSenseEdge, .dualShock4] {
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: kind), "xmark.circle")
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: kind), "circle.circle")
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: kind), "square.circle")
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: kind), "triangle.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nintendo's labels sit transposed on the same physical positions: the bottom button (role
|
||||||
|
/// `.a`) is labelled B, and the right one (role `.b`) is labelled A.
|
||||||
|
func testSwitchFaceButtonsAreTransposed() {
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: .switchPro), "b.circle")
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: .switchPro), "a.circle")
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: .switchPro), "y.circle")
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: .switchPro), "x.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.auto` is what a device that has never seen a controller reports, and Xbox letters are the
|
||||||
|
/// neutral default — they are also the positional names `GCExtendedGamepad` itself uses.
|
||||||
|
func testUnknownAndXboxFamiliesUseLetters() {
|
||||||
|
for kind in [PunktfunkConnection.GamepadType.auto, .xbox360, .xboxOne, .steamDeck] {
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: kind), "a.circle")
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: kind), "b.circle")
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: kind), "x.circle")
|
||||||
|
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: kind), "y.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key-path bridge the legends reach this table through (`buttonGlyph` spells its buttons
|
||||||
|
/// as key paths). A wrong mapping here would print the wrong button on every family at once.
|
||||||
|
func testRolesResolveFromExtendedGamepadKeyPaths() {
|
||||||
|
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonA), .a)
|
||||||
|
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonB), .b)
|
||||||
|
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonX), .x)
|
||||||
|
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonY), .y)
|
||||||
|
XCTAssertEqual(GamepadButtonRole(keyPath: \.leftShoulder), .leftShoulder)
|
||||||
|
XCTAssertEqual(GamepadButtonRole(keyPath: \.rightShoulder), .rightShoulder)
|
||||||
|
// A button outside the six the legends name has no honest glyph on every family, so it
|
||||||
|
// falls through to the caller's own fallback rather than guessing.
|
||||||
|
XCTAssertNil(GamepadButtonRole(keyPath: \.leftTrigger))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Arrow-key navigation over the library's two-section poster grid. The cases that matter are the
|
||||||
|
// ones a flat index gets wrong: a PARTIAL last row, and the hand-off between the launcher section
|
||||||
|
// and the titles below it.
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class LibraryGridNavTests: XCTestCase {
|
||||||
|
/// Two sections, 3 columns:
|
||||||
|
/// launchers L0 L1 (one partial row)
|
||||||
|
/// titles T0 T1 T2
|
||||||
|
/// T3 T4
|
||||||
|
private let nav = LibraryGridNav(
|
||||||
|
sections: [["L0", "L1"], ["T0", "T1", "T2", "T3", "T4"]], columns: 3)
|
||||||
|
|
||||||
|
func testFirstPressSelectsTheFirstTile() {
|
||||||
|
XCTAssertEqual(nav.move(from: nil, .right), "L0")
|
||||||
|
XCTAssertEqual(nav.move(from: nil, .down), "L0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHorizontalMovesWithinARow() {
|
||||||
|
XCTAssertEqual(nav.move(from: "T0", .right), "T1")
|
||||||
|
XCTAssertEqual(nav.move(from: "T1", .left), "T0")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Left/right run through the whole grid in display order, crossing the section boundary —
|
||||||
|
/// the launchers are simply the first tiles.
|
||||||
|
func testHorizontalCrossesTheSectionBoundary() {
|
||||||
|
XCTAssertEqual(nav.move(from: "L1", .right), "T0")
|
||||||
|
XCTAssertEqual(nav.move(from: "T0", .left), "L1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVerticalMovesOneRowWithinASection() {
|
||||||
|
XCTAssertEqual(nav.move(from: "T0", .down), "T3")
|
||||||
|
XCTAssertEqual(nav.move(from: "T3", .up), "T0")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Down from the launcher row lands in the titles' first row at the SAME column — this is the
|
||||||
|
/// move a flat index gets wrong, because the launcher row is partial.
|
||||||
|
func testDownFromLaunchersKeepsTheColumn() {
|
||||||
|
XCTAssertEqual(nav.move(from: "L0", .down), "T0")
|
||||||
|
XCTAssertEqual(nav.move(from: "L1", .down), "T1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Up out of the titles' first row lands in the launcher row, clamped to what is actually
|
||||||
|
/// there: column 2 has no launcher above it, so it settles on the last one rather than
|
||||||
|
/// running off the end.
|
||||||
|
func testUpIntoAPartialLauncherRowClamps() {
|
||||||
|
XCTAssertEqual(nav.move(from: "T0", .up), "L0")
|
||||||
|
XCTAssertEqual(nav.move(from: "T1", .up), "L1")
|
||||||
|
XCTAssertEqual(nav.move(from: "T2", .up), "L1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Down from a full row into a SHORTER last row still moves — landing on the final tile — but
|
||||||
|
/// there is nothing below the last row itself.
|
||||||
|
func testDownIntoAPartialLastRow() {
|
||||||
|
XCTAssertEqual(nav.move(from: "T2", .down), "T4") // column 2 has no T5
|
||||||
|
XCTAssertNil(nav.move(from: "T4", .down))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEdgesRefuseRatherThanWrap() {
|
||||||
|
XCTAssertNil(nav.move(from: "L0", .left))
|
||||||
|
XCTAssertNil(nav.move(from: "L0", .up))
|
||||||
|
XCTAssertNil(nav.move(from: "T4", .right))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A library with no launcher entries renders ONE section — the common case, and it must
|
||||||
|
/// behave like a plain grid.
|
||||||
|
func testSingleSectionGrid() {
|
||||||
|
let single = LibraryGridNav(sections: [["A", "B", "C", "D"]], columns: 2)
|
||||||
|
XCTAssertEqual(single.move(from: "A", .down), "C")
|
||||||
|
XCTAssertEqual(single.move(from: "D", .up), "B")
|
||||||
|
XCTAssertNil(single.move(from: "A", .up))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An id that is no longer in the grid (the list reloaded under the cursor) re-seeds rather
|
||||||
|
/// than returning nil forever.
|
||||||
|
func testStaleCursorReseeds() {
|
||||||
|
XCTAssertEqual(nav.move(from: "gone", .down), "L0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyGridHasNowhereToGo() {
|
||||||
|
let empty = LibraryGridNav(sections: [], columns: 3)
|
||||||
|
XCTAssertNil(empty.move(from: nil, .down))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A degenerate column count must not divide by zero.
|
||||||
|
func testZeroColumnsIsClampedToOne() {
|
||||||
|
let single = LibraryGridNav(sections: [["A", "B"]], columns: 0)
|
||||||
|
XCTAssertEqual(single.columns, 1)
|
||||||
|
XCTAssertEqual(single.move(from: "A", .down), "B")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,18 @@ final class SharedFoundationTests: XCTestCase {
|
|||||||
XCTAssertEqual(try DeepLink(url: profiled.url).profile, "a1b2c3d4e5f6")
|
XCTAssertEqual(try DeepLink(url: profiled.url).profile, "a1b2c3d4e5f6")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The library widget's and the Open Library intent's emitter — the reserved `browse` route
|
||||||
|
/// with a bare UUID path. Same backward-compatibility stakes as connect: a Home-Screen widget
|
||||||
|
/// keeps sending yesterday's URL.
|
||||||
|
func testDeepLinkBrowseRoundTrips() throws {
|
||||||
|
let id = UUID(uuidString: "11111111-2222-4333-8444-555555555555")!
|
||||||
|
let link = DeepLink.browse(host: id)
|
||||||
|
XCTAssertEqual(link.route, .browse)
|
||||||
|
XCTAssertEqual(
|
||||||
|
link.urlString, "punktfunk://browse/11111111-2222-4333-8444-555555555555")
|
||||||
|
XCTAssertEqual(try DeepLink(url: link.url), link)
|
||||||
|
}
|
||||||
|
|
||||||
/// Self-emitted links ("Copy link", a shortcut) carry all three references, so they survive
|
/// Self-emitted links ("Copy link", a shortcut) carry all three references, so they survive
|
||||||
/// both a re-addressed host and a wiped store.
|
/// both a re-addressed host and a wiped store.
|
||||||
func testDeepLinkForHostCarriesIDAddressAndPin() throws {
|
func testDeepLinkForHostCarriesIDAddressAndPin() throws {
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ mkdir -p "$CFG/open" "$CFG/paired" "$CFG/guess"
|
|||||||
trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" "${GUESS_PID:-}" 2>/dev/null || true' EXIT
|
trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" "${GUESS_PID:-}" 2>/dev/null || true' EXIT
|
||||||
# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the
|
# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the
|
||||||
# handshake, so the Swift test can assert the host→client feedback planes end to end.
|
# handshake, so the Swift test can assert the host→client feedback planes end to end.
|
||||||
|
# The open host outlives the others on purpose: AudioDeviceSwitchTests connects to it and then
|
||||||
|
# spends tens of seconds moving the system's output device around, long after the 300 frames the
|
||||||
|
# round-trip test needs.
|
||||||
HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \
|
HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \
|
||||||
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 \
|
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 12000 \
|
||||||
--allow-tofu &
|
--allow-tofu &
|
||||||
HOST_PID=$!
|
HOST_PID=$!
|
||||||
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
|
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
|
||||||
@@ -61,4 +64,4 @@ cd clients/apple
|
|||||||
PUNKTFUNK_LOOPBACK_PORT="$PORT" PUNKTFUNK_PAIRING_PORT="$PAIR_PORT" PUNKTFUNK_PAIRING_PIN="$PIN" \
|
PUNKTFUNK_LOOPBACK_PORT="$PORT" PUNKTFUNK_PAIRING_PORT="$PAIR_PORT" PUNKTFUNK_PAIRING_PIN="$PIN" \
|
||||||
PUNKTFUNK_GUESS_PORT="$GUESS_PORT" PUNKTFUNK_GUESS_PIN="$GUESS_PIN" \
|
PUNKTFUNK_GUESS_PORT="$GUESS_PORT" PUNKTFUNK_GUESS_PIN="$GUESS_PIN" \
|
||||||
PUNKTFUNK_TEST_FEEDBACK=1 \
|
PUNKTFUNK_TEST_FEEDBACK=1 \
|
||||||
swift test --filter LoopbackIntegrationTests
|
swift test --filter 'LoopbackIntegrationTests|AudioDeviceSwitchTests'
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>
|
||||||
|
After Width: | Height: | Size: 952 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="#000000"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 951 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#000000"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,9 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- The shell's embedded icon assets: the host-card OS marks (derived from the
|
<!-- The shell's embedded icon assets: the host-card OS marks (derived from the
|
||||||
assets/os-icons masters; see that directory's README for provenance/licensing).
|
assets/os-icons masters) and the library's launcher-tile brand marks (from the
|
||||||
|
assets/launcher-icons masters). See each directory's README for provenance/licensing.
|
||||||
Registered under the hicolor-style layout IconTheme::add_resource_path expects. -->
|
Registered under the hicolor-style layout IconTheme::add_resource_path expects. -->
|
||||||
<gresources>
|
<gresources>
|
||||||
<gresource prefix="/io/unom/Punktfunk">
|
<gresource prefix="/io/unom/Punktfunk">
|
||||||
|
<file>icons/scalable/actions/pf-launcher-steam-symbolic.svg</file>
|
||||||
|
<file>icons/scalable/actions/pf-launcher-lutris-symbolic.svg</file>
|
||||||
|
<file>icons/scalable/actions/pf-launcher-heroic-symbolic.svg</file>
|
||||||
|
<file>icons/scalable/actions/pf-launcher-playnite-symbolic.svg</file>
|
||||||
|
<file>icons/scalable/actions/pf-launcher-epic-symbolic.svg</file>
|
||||||
|
<file>icons/scalable/actions/pf-launcher-gog-symbolic.svg</file>
|
||||||
|
<file>icons/scalable/actions/pf-launcher-xbox-symbolic.svg</file>
|
||||||
<file>icons/scalable/actions/pf-os-windows-symbolic.svg</file>
|
<file>icons/scalable/actions/pf-os-windows-symbolic.svg</file>
|
||||||
<file>icons/scalable/actions/pf-os-apple-symbolic.svg</file>
|
<file>icons/scalable/actions/pf-os-apple-symbolic.svg</file>
|
||||||
<file>icons/scalable/actions/pf-os-linux-symbolic.svg</file>
|
<file>icons/scalable/actions/pf-os-linux-symbolic.svg</file>
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ const CSS: &str = "
|
|||||||
named instead of a title monogram, and an accent badge. */
|
named instead of a title monogram, and an accent badge. */
|
||||||
.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); }
|
.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-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); }
|
||||||
|
/* The brand mark when we ship one for this launcher — same ink as the name it replaces, so the
|
||||||
|
two fallback rungs read as one design rather than two. */
|
||||||
|
.pf-poster-launcher-mark { color: alpha(currentColor, 0.85); }
|
||||||
.pf-store-badge.pf-launcher { color: white; background: @accent_color; }
|
.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); }
|
.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); }
|
||||||
";
|
";
|
||||||
|
|||||||
@@ -782,6 +782,7 @@ fn mock_library() -> (
|
|||||||
art: crate::library::Artwork::default(),
|
art: crate::library::Artwork::default(),
|
||||||
platform: None,
|
platform: None,
|
||||||
role: None,
|
role: None,
|
||||||
|
icon: None,
|
||||||
};
|
};
|
||||||
let games = vec![
|
let games = vec![
|
||||||
game("steam:570", "steam", "Dota 2"),
|
game("steam:570", "steam", "Dota 2"),
|
||||||
|
|||||||
@@ -285,27 +285,61 @@ fn render(state: &Rc<State>, games: &[GameEntry]) {
|
|||||||
.set_visible(!launchers.is_empty() && !titles.is_empty());
|
.set_visible(!launchers.is_empty() && !titles.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The launcher-tile brand marks this shell ships symbolic art for
|
||||||
|
/// (`data/icons/.../pf-launcher-<t>-symbolic.svg`, embedded via gresource). A plugin may name a
|
||||||
|
/// mark a newer build carries; an entry whose token isn't here falls back to the launcher's name,
|
||||||
|
/// which is exactly how every launcher tile looked before icons existed.
|
||||||
|
const LAUNCHER_ICON_TOKENS: &[&str] = &[
|
||||||
|
"steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The poster-sized brand mark for an entry, or `None` when it carries no token, names one we
|
||||||
|
/// don't ship, or already has real artwork (a plugin that sent a cover has out-voted the token).
|
||||||
|
///
|
||||||
|
/// Symbolic, so it recolors with the Adwaita theme like every other glyph in the shell.
|
||||||
|
fn launcher_icon_image(game: &GameEntry) -> Option<gtk::Image> {
|
||||||
|
if !game.art.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = game.icon_token()?;
|
||||||
|
if !LAUNCHER_ICON_TOKENS.contains(&token) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let img = gtk::Image::from_icon_name(&format!("pf-launcher-{token}-symbolic"));
|
||||||
|
img.set_pixel_size(72);
|
||||||
|
img.add_css_class("pf-poster-launcher-mark");
|
||||||
|
img.set_halign(gtk::Align::Center);
|
||||||
|
img.set_valign(gtk::Align::Center);
|
||||||
|
img.set_vexpand(true);
|
||||||
|
Some(img)
|
||||||
|
}
|
||||||
|
|
||||||
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
|
/// 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
|
/// monogram placeholder underneath the async art. Activation starts a session launching
|
||||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
/// this title (silent on a pinned host — the normal trust gate applies).
|
||||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||||
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens
|
// A launcher usually ships no poster. Its brand mark, when we ship one, IS the poster; failing
|
||||||
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load".
|
// that, 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", which is why games keep it.
|
||||||
let launcher = game.is_launcher();
|
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);
|
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
placeholder.append(&monogram);
|
if let Some(mark) = launcher_icon_image(game) {
|
||||||
monogram.set_vexpand(true);
|
placeholder.append(&mark);
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
monogram.set_vexpand(true);
|
||||||
|
placeholder.append(&monogram);
|
||||||
|
}
|
||||||
|
|
||||||
let pic = gtk::Picture::new();
|
let pic = gtk::Picture::new();
|
||||||
pic.set_content_fit(gtk::ContentFit::Cover);
|
pic.set_content_fit(gtk::ContentFit::Cover);
|
||||||
|
|||||||