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 | ||
|
|
77f0a25d18 | ||
|
|
f9fe496dbc | ||
|
|
13438b1287 | ||
|
|
ae35e8b4d7 | ||
|
|
f34acf1d73 | ||
|
|
d2a2bcc25d | ||
|
|
4e04c2bbf8 | ||
|
|
d498ff4a60 | ||
|
|
bbc0513f0c | ||
|
|
2de604ecab | ||
|
|
f26d21125d | ||
|
|
4f8cce6751 | ||
|
|
4a4118e3ce | ||
|
|
dcfba07803 | ||
|
|
99f2130b28 | ||
|
|
f266636392 |
@@ -280,6 +280,21 @@ jobs:
|
||||
done
|
||||
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
|
||||
|
||||
# 0.26.0-1 setcap'd `cap_sys_nice=ep` on the host from this package's .INSTALL scriptlet and
|
||||
# killed desktop streaming on every KDE box — with a green board, because nothing here ever
|
||||
# looked at what the built package would DO. The lesson recorded then was "verify the
|
||||
# PACKAGE, never the board"; this is that, and pacman is the channel where it matters most,
|
||||
# since capabilities live in the scriptlet rather than in package metadata.
|
||||
#
|
||||
# Host must carry NOTHING, the worker exactly cap_sys_nice=ep. `--self-test` runs first so a
|
||||
# guard that has quietly lost the ability to fail takes the job down rather than approving a
|
||||
# release. (Only the host package is checked: the client/web/scripting packages ship neither
|
||||
# binary and the script skips them by itself.)
|
||||
- name: Assert the capability matrix (Arch package)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
bash scripts/ci/assert-cap-matrix.sh "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst
|
||||
|
||||
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
|
||||
# completely different dependency set, published into the same repo so `pacman -S
|
||||
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
|
||||
|
||||
@@ -91,8 +91,27 @@ jobs:
|
||||
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
|
||||
# bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
|
||||
# noting why here).
|
||||
#
|
||||
# web carries two ignores, the ONLY ones in a blocking tree — both image-size advisories
|
||||
# (GHSA-w3rx-r6r6-pgpr ICNS, GHSA-5p2g-fcmc-qvqq JXL/HEIF infinite-loop DoS). They are
|
||||
# unfixable AND unreachable:
|
||||
# * unfixable — the vulnerable range is `<= 2.0.2` and 2.0.2 IS latest; upstream has
|
||||
# published no patched release, so no override can clear them.
|
||||
# * unreachable — image-size rides in under `@unom/ui › @payloadcms/richtext-lexical ›
|
||||
# … › payload`, and @payloadcms/richtext-lexical is a PEER of @unom/ui that only its
|
||||
# `./richtext` export needs. The console imports section/toast/button/card/dialog/
|
||||
# form/*/material/tabs — never `./richtext` — so payload is auto-installed peer weight
|
||||
# that no bundle, and no request path, ever touches.
|
||||
# Drop these the moment image-size ships a fix, or @unom/ui marks that peer optional
|
||||
# (peerDependenciesMeta) and the chain leaves web/bun.lock entirely — either one makes the
|
||||
# bare `bun audit` green again. Scoped per-tree so sdk/plugin-kit stay strictly fail-on-any.
|
||||
- name: bun audit
|
||||
run: 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
|
||||
# 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".
|
||||
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
|
||||
# Arch packages — which already split it — were fine.
|
||||
#
|
||||
# punktfunk-encode-worker IS in this invocation: it is the capability-carrying PyroWave
|
||||
# encode worker that ships next to the host in /usr/bin, and build-deb.sh only builds it
|
||||
# if the artifact is missing — building it here keeps it on the same sccache pass as the
|
||||
# host. Unlike the tray it shares the host's dependency graph by design (v1 accepts that
|
||||
# the worker links the same FFmpeg), so feature unification here is harmless.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host
|
||||
-p punktfunk-host -p punktfunk-encode-worker
|
||||
|
||||
- name: Build host .deb (FFmpeg bundled)
|
||||
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
||||
@@ -320,6 +326,17 @@ jobs:
|
||||
run: |
|
||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||
|
||||
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
|
||||
# capability metadata — the postinst applies them — so this reads the postinst that will
|
||||
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
|
||||
# from exactly that postinst and killed every KDE desktop session while every board stayed
|
||||
# green: host must carry NOTHING, worker exactly cap_sys_nice=ep. `--self-test` first so a
|
||||
# guard that can no longer fail takes the job down instead of waving the release through.
|
||||
- name: Assert the capability matrix (host .deb)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
|
||||
|
||||
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
||||
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
||||
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
||||
|
||||
@@ -7,10 +7,24 @@
|
||||
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
|
||||
# run for an hour on every push:
|
||||
#
|
||||
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
|
||||
# the NixOS module without building them. Catches the failures that actually happen to
|
||||
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
|
||||
# error, a package attribute dropped from packages.nix.
|
||||
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
|
||||
# without building them. Catches the failures that actually happen to this flake — a
|
||||
# renamed file, a callPackage argument that no longer exists, a syntax error, a package
|
||||
# attribute dropped from packages.nix.
|
||||
#
|
||||
# ⚠ It does NOT, on its own, check the NixOS module. `nix flake check` handles
|
||||
# `nixosModules` by forcing the value and asserting it is a lambda taking an open
|
||||
# attribute set — nothing more (nix's own source: `// FIXME: if we have a 'nixpkgs'
|
||||
# input, use it to check the module.`). MEASURED: a module setting a nonexistent
|
||||
# OPTION, referencing a nonexistent `pkgs` attribute AND calling a nonexistent `lib`
|
||||
# function passes clean, printing `checking NixOS module ... all checks passed!`. This
|
||||
# header used to claim the module was covered here; it was not, for the module's whole
|
||||
# life. It is covered NOW because `checks.<system>.nixos-module`
|
||||
# (packaging/nix/module-check.nix) evaluates it against real nixpkgs and asserts on the
|
||||
# rendered systemd units — and because those assertions are pure Nix, INSTANTIATING
|
||||
# that check runs them, so `--no-build` is enough. Keep them pure: a shell script in
|
||||
# the derivation body would only run under a full `nix flake check`, which builds the
|
||||
# hour-long Rust packages.
|
||||
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
|
||||
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
|
||||
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
|
||||
@@ -22,6 +36,12 @@
|
||||
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
|
||||
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
|
||||
#
|
||||
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
|
||||
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
|
||||
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
|
||||
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
|
||||
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
|
||||
#
|
||||
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
|
||||
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
|
||||
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
|
||||
@@ -66,6 +86,10 @@ on:
|
||||
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
|
||||
type: boolean
|
||||
default: false
|
||||
build-gamescope:
|
||||
description: "Also build punktfunk-gamescope (patched gamescope from source; run after a flake.lock bump)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
flake:
|
||||
@@ -165,3 +189,13 @@ jobs:
|
||||
if: ${{ github.event.inputs.build-rust == 'true' }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
|
||||
|
||||
# The patched compositor. Separate from build-rust because its failure mode is different: it
|
||||
# tracks nixpkgs' gamescope, not our Rust, so it wants a run after a flake.lock bump rather
|
||||
# than after a code change. `gamescope.nix` fails loudly (an eval-time `throw` if nixpkgs no
|
||||
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
|
||||
# something actually builds it.
|
||||
- name: Build the patched gamescope (dispatch opt-in)
|
||||
if: ${{ github.event.inputs.build-gamescope == 'true' }}
|
||||
run: |
|
||||
"$NIX" build --print-build-logs .#punktfunk-gamescope
|
||||
|
||||
@@ -103,7 +103,11 @@ jobs:
|
||||
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
|
||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||
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
|
||||
# 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 /
|
||||
@@ -155,6 +159,20 @@ jobs:
|
||||
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
|
||||
run: bash packaging/rpm/sign-rpms.sh
|
||||
|
||||
# Read the file-capability matrix out of the BUILT rpm, before anything is signed or
|
||||
# published. 0.26.0-1 shipped `%caps(cap_sys_nice=ep)` on the host through this very spec —
|
||||
# on Fedora and, via rpm-ostree layering, on Bazzite — and every board was green while every
|
||||
# KDE desktop session died in the field. The lesson recorded then was "verify the PACKAGE,
|
||||
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
|
||||
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
|
||||
# fail takes the job down instead of waving the release through.
|
||||
- name: Assert the capability matrix (rpm)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
# Only the main host package carries binaries; -debuginfo/-debugsource and the
|
||||
# client/web/scripting subpackages ship neither and are skipped by the script itself.
|
||||
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-[0-9]*.rpm
|
||||
|
||||
- name: Publish to the Gitea RPM registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -312,6 +330,19 @@ jobs:
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
# Read the capability matrix back OUT of the image that is about to be published — the one
|
||||
# channel where getting it wrong is unrepairable, because a merged sysext's /usr is read-only
|
||||
# squashfs and the only fix is a new image plus a feed republish. 0.26.0-1's Bazzite breakage
|
||||
# was confirmed exactly this way, after the fact, by mounting the published .raw and running
|
||||
# getcap on it. Doing it here means the .raw never reaches the feed.
|
||||
#
|
||||
# The script proves its own reader first (cap a file, squash it, unsquash it, read it back)
|
||||
# so a runner that cannot see file capabilities FAILS the leg instead of blessing the image.
|
||||
- name: Assert the capability matrix (sysext image)
|
||||
run: |
|
||||
bash scripts/ci/assert-cap-matrix.sh \
|
||||
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
|
||||
|
||||
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
|
||||
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
|
||||
# anything, sitting on the same registry as the images they describe.
|
||||
|
||||
@@ -12,6 +12,366 @@ 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
|
||||
|
||||
52 commits since v0.25.0.
|
||||
|
||||
@@ -994,7 +994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1114,7 +1114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2358,7 +2358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2463,7 +2463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3017,7 +3017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3052,7 +3052,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3070,7 +3070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3091,7 +3091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3101,7 +3101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3118,6 +3118,8 @@ dependencies = [
|
||||
"pf-zerocopy",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@@ -3125,7 +3127,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3137,7 +3139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3151,11 +3153,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3184,14 +3186,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3206,7 +3208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3214,7 +3216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3226,7 +3228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3235,7 +3237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3268,7 +3270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3279,7 +3281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3291,7 +3293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3514,7 +3516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3525,7 +3527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3543,7 +3545,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3560,7 +3562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3575,7 +3577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3594,7 +3596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3624,9 +3626,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3711,7 +3722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3725,7 +3736,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3748,7 +3759,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
@@ -4,6 +4,9 @@ members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/punktfunk-host/vendor/usbip-sim",
|
||||
# The capability-carrying PyroWave encode worker. A SEPARATE binary by design — never a
|
||||
# hardlink of, or a subcommand of, punktfunk-host (design/gpu-priority-capability-worker.md).
|
||||
"crates/punktfunk-encode-worker",
|
||||
"crates/punktfunk-tray",
|
||||
"crates/pf-bitstream",
|
||||
"crates/pf-bitstream/vendor/cros-codecs",
|
||||
@@ -46,6 +49,11 @@ members = [
|
||||
exclude = [
|
||||
"packaging/linux/steam-deck-gadget/usbip-poc",
|
||||
"clients/android/native/vendor/ndk",
|
||||
# Bring-your-own-hardware measurement tools. `hid-descriptor-dump` pulls `hidapi`, a C library
|
||||
# wanting libudev on Linux; `win-input-matrix` is Windows-only and asks the live input stacks
|
||||
# what they can see. Neither belongs in `cargo build --workspace` or on a CI leg with no pad.
|
||||
"tools/hid-descriptor-dump",
|
||||
"tools/win-input-matrix",
|
||||
]
|
||||
|
||||
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
|
||||
@@ -57,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.25.0"
|
||||
"version": "0.26.0"
|
||||
},
|
||||
"paths": {
|
||||
"/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."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||
@@ -4555,6 +4562,13 @@
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -5350,6 +5364,14 @@
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.",
|
||||
"example": "steam"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||
@@ -6869,6 +6891,13 @@
|
||||
"type": "string",
|
||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
// left is a faint indigo→violet ember in the bright corner. The accent stays the
|
||||
// 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(
|
||||
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),
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.pager.PageSize
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -54,6 +55,7 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.unom.punktfunk.components.launcherIcon
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
@@ -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
|
||||
)
|
||||
} else {
|
||||
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
|
||||
// would read as "a game whose cover failed to load".
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
// A launcher ships no poster by design, so its brand mark IS the poster — drawn big and
|
||||
// centred, tinted like the text it replaces. Falling back to the launcher's name says
|
||||
// "opens Steam" for a mark we don't ship; the title would read as "a game whose cover
|
||||
// failed to load".
|
||||
val mark = launcherIcon(game.iconToken)
|
||||
if (mark != null) {
|
||||
Icon(
|
||||
imageVector = mark,
|
||||
contentDescription = game.title,
|
||||
tint = ink.fg(0.75f),
|
||||
modifier = Modifier.fillMaxSize(0.45f),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
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
|
||||
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
|
||||
* game rather than break the decode (design D4).
|
||||
*
|
||||
* [icon] is the token for the entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||
* Null on every older host and on every ordinary title.
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
@@ -51,12 +54,25 @@ data class GameEntry(
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
val icon: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* The brand-icon token, re-validated rather than taken on trust.
|
||||
*
|
||||
* The host checks the shape on the way in, so this only fires for a host older than that
|
||||
* check or one that isn't ours. It costs a scan of a short string and means no consumer has
|
||||
* to wonder what it is about to look up.
|
||||
*/
|
||||
val iconToken: String? get() = icon?.takeIf { t ->
|
||||
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
|
||||
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for the store badge — the same table the other clients use
|
||||
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
|
||||
@@ -151,6 +167,7 @@ object LibraryClient {
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
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"]),
|
||||
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
|
||||
],
|
||||
dependencies: [
|
||||
// Progressive (gradient) backdrop blur for the form screens' trays — a real blur with no
|
||||
// material tint stage (see GamepadTrayBlur). Pinned by REVISION, not `from:`: the
|
||||
// GlurBackdrop product exists only on main — no release carries it (the newest tag,
|
||||
// `1.1`, predates it, and is not three-component semver anyway, so version-based
|
||||
// resolution stops at 1.0.4). The revision is main's head at adoption time; a revision
|
||||
// pin stays reproducible when the branch moves.
|
||||
.package(
|
||||
url: "https://github.com/joogps/Glur.git",
|
||||
revision: "ba4f05d3c9a608ec773b9305f2af6089390de68a"),
|
||||
],
|
||||
targets: [
|
||||
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
|
||||
// No dependencies by design — an extension process links this alone.
|
||||
@@ -39,6 +50,10 @@ let package = Package(
|
||||
// licensing in that README). `.process` compiles the catalog; loaded via
|
||||
// OsIcon.swift.
|
||||
.process("Resources/OsIcons.xcassets"),
|
||||
// The launcher tiles' brand marks (template vector imagesets generated from the
|
||||
// assets/launcher-icons masters by scripts/gen-launcher-icons.sh — per-mark
|
||||
// provenance and licensing in that README). Loaded via LauncherIcon.swift.
|
||||
.process("Resources/LauncherIcons.xcassets"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
@@ -51,7 +66,12 @@ let package = Package(
|
||||
// (The tvOS slide-transition package is referenced by the Xcode PROJECT only —
|
||||
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
|
||||
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
|
||||
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
|
||||
.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
|
||||
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
|
||||
.testTarget(
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
|
||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
|
||||
EE0000000000000000000012 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000002 /* Glur */; };
|
||||
EE0000000000000000000013 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000003 /* GlurBackdrop */; };
|
||||
EE0000000000000000000014 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000004 /* Glur */; };
|
||||
EE0000000000000000000015 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000005 /* GlurBackdrop */; };
|
||||
EE0000000000000000000016 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000006 /* Glur */; };
|
||||
EE0000000000000000000017 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000007 /* GlurBackdrop */; };
|
||||
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
|
||||
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
|
||||
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
@@ -88,6 +94,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
EE0000000000000000000012 /* Glur in Frameworks */,
|
||||
EE0000000000000000000013 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -96,6 +104,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
EE0000000000000000000014 /* Glur in Frameworks */,
|
||||
EE0000000000000000000015 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -105,6 +115,8 @@
|
||||
files = (
|
||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */,
|
||||
EE0000000000000000000016 /* Glur in Frameworks */,
|
||||
EE0000000000000000000017 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -175,6 +187,8 @@
|
||||
name = Punktfunk;
|
||||
packageProductDependencies = (
|
||||
AA0000000000000000000006 /* PunktfunkKit */,
|
||||
EE0000000000000000000002 /* Glur */,
|
||||
EE0000000000000000000003 /* GlurBackdrop */,
|
||||
);
|
||||
productName = Punktfunk;
|
||||
productReference = AA0000000000000000000001 /* Punktfunk.app */;
|
||||
@@ -201,6 +215,8 @@
|
||||
name = "Punktfunk-iOS";
|
||||
packageProductDependencies = (
|
||||
BB0000000000000000000006 /* PunktfunkKit */,
|
||||
EE0000000000000000000004 /* Glur */,
|
||||
EE0000000000000000000005 /* GlurBackdrop */,
|
||||
);
|
||||
productName = "Punktfunk-iOS";
|
||||
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
|
||||
@@ -226,6 +242,8 @@
|
||||
packageProductDependencies = (
|
||||
CC0000000000000000000006 /* PunktfunkKit */,
|
||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */,
|
||||
EE0000000000000000000006 /* Glur */,
|
||||
EE0000000000000000000007 /* GlurBackdrop */,
|
||||
);
|
||||
productName = "Punktfunk-tvOS";
|
||||
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
||||
@@ -283,6 +301,7 @@
|
||||
packageReferences = (
|
||||
AA000000000000000000000F /* XCLocalSwiftPackageReference "." */,
|
||||
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */,
|
||||
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = AA0000000000000000000008 /* Products */;
|
||||
@@ -848,6 +867,14 @@
|
||||
minimumVersion = 0.18.0;
|
||||
};
|
||||
};
|
||||
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/joogps/Glur.git";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = ba4f05d3c9a608ec773b9305f2af6089390de68a;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -868,6 +895,36 @@
|
||||
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
|
||||
productName = SwiftUINavigationTransitions;
|
||||
};
|
||||
EE0000000000000000000002 /* Glur */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = Glur;
|
||||
};
|
||||
EE0000000000000000000003 /* GlurBackdrop */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = GlurBackdrop;
|
||||
};
|
||||
EE0000000000000000000004 /* Glur */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = Glur;
|
||||
};
|
||||
EE0000000000000000000005 /* GlurBackdrop */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = GlurBackdrop;
|
||||
};
|
||||
EE0000000000000000000006 /* Glur */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = Glur;
|
||||
};
|
||||
EE0000000000000000000007 /* GlurBackdrop */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
|
||||
productName = GlurBackdrop;
|
||||
};
|
||||
E2CAFE000000000000000002 /* PunktfunkShared */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PunktfunkShared;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"originHash" : "5d17a752eb57d190a90cbd663718ff44034b24fe0ae1baafea7677db2d49da6f",
|
||||
"originHash" : "bb1ce9bc6042f166bd0aad78a15081e673781d3a90fa52fd8ec8a08875878ef6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "glur",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/joogps/Glur.git",
|
||||
"state" : {
|
||||
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "objc-runtime-tools",
|
||||
"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 {
|
||||
var body: some Widget {
|
||||
HostsWidget()
|
||||
LibraryWidget()
|
||||
PunktfunkSessionLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ struct ContentView: View {
|
||||
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
|
||||
/// the session model's published state below; iPhone/iPad only.
|
||||
@State private var liveActivity = SessionActivityController()
|
||||
/// The window's bottom safe-area inset (the home-indicator strip), reported by
|
||||
/// DisplayBottomInsetProbe from UIKit's own callbacks and published as
|
||||
/// `\.displayBottomInset` for the screens that pin a legend to the display's corner. Held
|
||||
/// HERE and read through the environment because asking UIKit for it during a body severs
|
||||
/// the asking view's updates on device (see the probe).
|
||||
@State private var displayBottomInset: CGFloat = 0
|
||||
#endif
|
||||
@State private var pairingTarget: StoredHost?
|
||||
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
|
||||
@@ -115,6 +121,23 @@ struct ContentView: View {
|
||||
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers — resign-active
|
||||
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
#endif
|
||||
|
||||
/// The gamepad UI's form-metric tier for this window, published from HERE — the app's root.
|
||||
/// A screen that applies `gamepadPaletteInk` itself sits ABOVE its own copy of the environment,
|
||||
/// so its `@Environment` resolves against its parent; publishing at the root is what makes
|
||||
/// every one of them (including the ones presented as sheets and covers, which inherit the
|
||||
/// environment) read its own window's tier instead of the bare default.
|
||||
private var gamepadMetrics: GamepadFormMetrics {
|
||||
#if os(iOS)
|
||||
.forWindow(h: hSizeClass, v: vSizeClass)
|
||||
#else
|
||||
.platformDefault
|
||||
#endif
|
||||
}
|
||||
private var gamepadUIActive: Bool {
|
||||
GamepadUIEnvironment.isActive(
|
||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||
@@ -181,6 +204,36 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var driven: some View {
|
||||
drivenBase
|
||||
.environment(\.gamepadMetrics, gamepadMetrics)
|
||||
#if os(iOS)
|
||||
.environment(\.displayBottomInset, displayBottomInset)
|
||||
// The probe is UIKit's, not any screen's: mounted once here as a background so the
|
||||
// legend-pinning screens can READ the inset from the environment without ever asking
|
||||
// UIKit during their own body (which severs their updates — see the probe).
|
||||
.background {
|
||||
DisplayBottomInsetProbe { displayBottomInset = $0 }
|
||||
}
|
||||
#endif
|
||||
#if os(iOS) || os(macOS)
|
||||
// The console's own modal, over WHICHEVER screen is up. Not attached to `home`, which
|
||||
// renders only while `model.connection == nil`: a connection exists through the
|
||||
// pair-required and approval handshakes, which is precisely when these prompts fire.
|
||||
// It sits above the connect takeover too — the delegated-approval wait is raised
|
||||
// DURING a dial and owns the only Cancel for it. (The takeover draws nothing in that
|
||||
// state: `connectingOverlayName` is nil while `awaitingApproval` is set, so the two
|
||||
// never poll the pad at once.)
|
||||
.overlay {
|
||||
if let prompt = consolePrompt {
|
||||
GamepadPromptView(prompt: prompt)
|
||||
.gamepadPaletteInk()
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var drivenBase: some View {
|
||||
Group {
|
||||
// The stream view's structural identity MUST be stable across the
|
||||
// awaiting-trust → streaming transition: recreating it restarts the pump,
|
||||
@@ -368,8 +421,21 @@ struct ContentView: View {
|
||||
// (the "Pair with PIN instead" path disconnects first — the host's accept loop
|
||||
// is sequential, a pairing connection would queue behind the live session).
|
||||
#if !os(tvOS)
|
||||
.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) }
|
||||
#endif
|
||||
}
|
||||
.sheet(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
@@ -409,9 +475,102 @@ struct ContentView: View {
|
||||
// budget (inline, they tip SwiftUI's per-expression limit — see the split sections idiom).
|
||||
|
||||
private var deepLinkNoticePresented: Binding<Bool> {
|
||||
Binding(get: { deepLinkNotice != nil }, 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 library in place (see the cover's comment).
|
||||
private var touchLibraryTarget: Binding<StoredHost?> {
|
||||
@@ -420,19 +579,37 @@ struct ContentView: View {
|
||||
set: { libraryTarget = $0 })
|
||||
}
|
||||
|
||||
/// The pairing sheet's item. On iOS it hides while the gamepad shell presents the pair screen
|
||||
/// in place — the same proxy the library uses, and for the same reason: every writer keeps
|
||||
/// writing `pairingTarget`, and whichever presentation the current mode owns picks it up.
|
||||
/// macOS has no shell, so the sheet stays and switches its CONTENT by mode instead.
|
||||
private var touchPairingTarget: Binding<StoredHost?> {
|
||||
#if os(macOS)
|
||||
Binding(get: { pairingTarget }, set: { pairingTarget = $0 })
|
||||
#else
|
||||
Binding(
|
||||
get: { gamepadUIActive ? nil : pairingTarget },
|
||||
set: { pairingTarget = $0 })
|
||||
#endif
|
||||
}
|
||||
|
||||
private var approvalChoicePresented: Binding<Bool> {
|
||||
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
|
||||
Binding(
|
||||
get: { approvalChoice != nil && !consolePromptShowing },
|
||||
set: { if !$0 { approvalChoice = nil } })
|
||||
}
|
||||
|
||||
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> {
|
||||
Binding(
|
||||
get: {
|
||||
guard model.errorMessage != nil else { return false }
|
||||
#if os(macOS)
|
||||
/// Whether the "Connection failed" state is ready to be shown at all — shared by the system
|
||||
/// alert and the console prompt so the two can never disagree about the macOS deferral below.
|
||||
private var connectionErrorReady: Bool {
|
||||
guard model.errorMessage != nil else { return false }
|
||||
#if os(macOS)
|
||||
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
|
||||
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
|
||||
// presenting it now strands the window fullscreen on the home screen after a
|
||||
@@ -441,10 +618,14 @@ struct ContentView: View {
|
||||
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
|
||||
// over the windowed home UI. Not gated when fullscreen is the user's own manual
|
||||
// choice (opt-out setting) — nothing is auto-exiting there to conflict with.
|
||||
if fullscreenForSession && isFullscreen { return false }
|
||||
#endif
|
||||
return true
|
||||
},
|
||||
if fullscreenForSession && isFullscreen { return false }
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
private var connectionErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { connectionErrorReady && !consolePromptShowing },
|
||||
set: { if !$0 { model.errorMessage = nil } })
|
||||
}
|
||||
|
||||
@@ -487,10 +668,20 @@ struct ContentView: View {
|
||||
?? "That link is malformed and was ignored."
|
||||
return
|
||||
}
|
||||
guard link.route == .connect else {
|
||||
// `wake` and `browse` are reserved in the grammar and parse today; this build routes
|
||||
// neither, and saying so beats silently connecting instead.
|
||||
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
|
||||
switch link.route {
|
||||
case .connect:
|
||||
break
|
||||
case .browse:
|
||||
// The reserved library route, now real: open the host's game library without starting
|
||||
// a session. `launch=`/`profile=` are meaningless on a browse (nothing streams until a
|
||||
// title is picked, and that connect resolves its own profile) — ignored, not refused,
|
||||
// per the unknown-parameter rule.
|
||||
openLibrary(from: link)
|
||||
return
|
||||
case .wake:
|
||||
// Still reserved: saying so beats silently connecting instead. (Shortcuts users have
|
||||
// the Wake Host intent, which never round-trips through a URL.)
|
||||
deepLinkNotice = "Punktfunk links can't do “wake” yet."
|
||||
return
|
||||
}
|
||||
// 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 {
|
||||
// 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
|
||||
@@ -576,9 +799,11 @@ struct ContentView: View {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
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,
|
||||
launchTitle: launchTitle)
|
||||
launchTitle: launchTitle,
|
||||
promptActive: consolePromptShowing)
|
||||
} else {
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
@@ -593,9 +818,11 @@ struct ContentView: View {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
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,
|
||||
launchTitle: launchTitle)
|
||||
launchTitle: launchTitle,
|
||||
promptActive: consolePromptShowing)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
||||
|
||||
@@ -13,6 +13,8 @@ import SwiftUI
|
||||
|
||||
struct GamepadAddHostView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
let onAdd: (StoredHost) -> Void
|
||||
@@ -48,7 +50,7 @@ struct GamepadAddHostView: View {
|
||||
isActive: controllerActive && editing == nil
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -61,25 +63,28 @@ struct GamepadAddHostView: View {
|
||||
if !compact {
|
||||
Text("Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else.")
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
.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, compact ? 12 : 18)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.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.
|
||||
// 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.
|
||||
.id(editing)
|
||||
GamepadHintBar(hints: [
|
||||
// "Type" names what A does to the key under the keyboard's cursor. There is
|
||||
// no tap equivalent — a touch user types by tapping the keycap itself — so
|
||||
// this one cell stays a label.
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||
.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
.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 {
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
|
||||
.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)
|
||||
}
|
||||
@@ -191,7 +207,7 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = GamepadFormMetrics.self
|
||||
let m = metrics
|
||||
return HStack(spacing: 14) {
|
||||
if row.isAction {
|
||||
Label("Add Host", systemImage: "plus.circle.fill")
|
||||
@@ -268,6 +284,15 @@ struct GamepadAddHostView: View {
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
|
||||
}
|
||||
|
||||
/// The legend's Delete cell (iOS/macOS). Applied to the field's binding rather than routed
|
||||
/// into `GamepadKeyboard`: the keyboard's X does exactly this to the same binding, and
|
||||
/// reaching into its state to trigger it would need a whole callback channel for one edit.
|
||||
private func backspace(_ id: String) {
|
||||
let binding = editingBinding(id)
|
||||
guard !binding.wrappedValue.isEmpty else { return }
|
||||
binding.wrappedValue.removeLast()
|
||||
}
|
||||
|
||||
private func editingBinding(_ id: String) -> Binding<String> {
|
||||
switch id {
|
||||
case "name": return $name
|
||||
|
||||
@@ -191,6 +191,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
#if os(iOS) || os(macOS)
|
||||
// A hardware keyboard drives the same cursor as the pad — arrows step, Return activates,
|
||||
// Esc backs out (iPad on a Magic Keyboard, couch Mac). tvOS routes arrows through the
|
||||
// focus engine instead, which owns navigation there.
|
||||
.gamepadKeyNavigation(
|
||||
active: isActive,
|
||||
onMove: { move($0) },
|
||||
onConfirm: { activate() },
|
||||
onBack: onBack)
|
||||
#endif
|
||||
.onAppear {
|
||||
reconcile()
|
||||
wire()
|
||||
@@ -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
|
||||
/// itself only grows, since it is already facing you. Each card carries its own delay (see
|
||||
/// `entrance(_:)`) — that stagger is what makes the strip read as one gesture instead of a
|
||||
/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede
|
||||
/// speaks, so the arrival and the scrolling feel like one object.
|
||||
/// simultaneous flash, and it is the same hinge language the coverflow's own recede speaks, so the
|
||||
/// arrival and the scrolling feel like one object.
|
||||
///
|
||||
/// ⚠️ APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
|
||||
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
|
||||
@@ -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
|
||||
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
|
||||
///
|
||||
/// ⚠️ NO `rotation3DEffect` HERE, however much the drum language invites one. It was the cause of
|
||||
/// the strip's "flash as the cards settle": a real 3D transform renders the card through an
|
||||
/// offscreen layer, and a card carries translucent glass, which resolves differently in there —
|
||||
/// so every card sat at the wrong fill for as long as the master animation ran and then snapped
|
||||
/// to its true one in a SINGLE frame the moment SwiftUI dropped that layer.
|
||||
///
|
||||
/// Measured on an iPad Pro 13": the centred tile held #4a3d87 for twelve frames in which nothing
|
||||
/// moved, then stepped to #423970 (−23 blue) in one. It is the ANIMATION ending, not the motion:
|
||||
/// stretching the timeline from 1.02 s to 2.82 s moved the step from 0.70 s to 2.50 s after the
|
||||
/// launcher appeared — the same 0.32 s before the end both times. Removing the rotation removed
|
||||
/// the step outright; `compositingGroup()` above or below the transforms did nothing.
|
||||
///
|
||||
/// So the turn is PROJECTED instead: `cos(angle)` as a horizontal squeeze is exactly the
|
||||
/// orthographic projection of a Y-axis rotation, hinged on the edge the card fans from. Affine,
|
||||
/// so no offscreen pass and no layer to drop — and it reads as the same gesture, losing only the
|
||||
/// perspective trapezoid, which at these card sizes was never what sold the motion.
|
||||
///
|
||||
/// Transforms only — nothing here touches layout, so the scroll view's snapping and the tvOS
|
||||
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
|
||||
/// unstaggered cross-fade.
|
||||
@@ -485,14 +512,15 @@ struct CardEntrance: ViewModifier, Animatable {
|
||||
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
|
||||
// it instead of swinging the opposite way.
|
||||
let away = reduceMotion ? 0 : 1 - travel
|
||||
// The turn, projected rather than rendered in 3D — see the type's note on the flash.
|
||||
// `cos` of the angle IS the orthographic projection of a Y-axis rotation, and hinging it
|
||||
// on the edge the card fans from restores the direction that the rotation's sign carried
|
||||
// (cos is even, so the sign alone would read the same both ways).
|
||||
let turn = cos(Angle.degrees(64 * away).radians)
|
||||
return content
|
||||
.opacity(reduceMotion ? raw : fade)
|
||||
.scaleEffect(1 - 0.26 * away)
|
||||
.rotation3DEffect(
|
||||
.degrees(side * -64 * away),
|
||||
axis: (x: 0, y: 1, z: 0),
|
||||
anchor: .center,
|
||||
perspective: 0.65)
|
||||
.scaleEffect(x: turn, y: 1, anchor: side < 0 ? .trailing : .leading)
|
||||
.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
|
||||
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
|
||||
|
||||
import Glur
|
||||
import GlurBackdrop
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||||
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
|
||||
/// The glyph a button wears in a legend: the ACTIVE controller's own (Xbox "A", DualSense ✕, …)
|
||||
/// via `sfSymbolsName` while one is attached, else the glyph of the last pad this device ever saw
|
||||
/// (`GamepadManager.lastKnownKind` → `GamepadGlyphs`), else the caller's generic fallback.
|
||||
///
|
||||
/// The middle rung is the whole point. `active` is nil whenever the pad sleeps, disconnects or
|
||||
/// runs flat — and permanently under `gamepadUIMode == "always"`, which puts the console UI up
|
||||
/// with no pad by design — and the fallbacks are letter glyphs, so a DualSense user's ✕/◯ legends
|
||||
/// used to turn into A/B the moment the controller dozed off. The remembered kind keeps the
|
||||
/// legends speaking the pad the user actually owns. The `fallback` still covers the genuinely
|
||||
/// unknown case: a fresh install that has never seen a controller, and any button outside the six
|
||||
/// `GamepadButtonRole` names.
|
||||
///
|
||||
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
|
||||
@MainActor
|
||||
func buttonGlyph(
|
||||
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
||||
) -> String {
|
||||
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
||||
?? fallback
|
||||
let manager = GamepadManager.shared
|
||||
if let live = manager.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName {
|
||||
return live
|
||||
}
|
||||
guard let role = GamepadButtonRole(keyPath: button) else { return fallback }
|
||||
return GamepadGlyphs.symbol(role, for: manager.lastKnownKind)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// GamepadAddHostView) — one set of numbers so the two screens read as the same surface,
|
||||
/// sized for the couch on tvOS and for the hand elsewhere.
|
||||
enum GamepadFormMetrics {
|
||||
#if os(tvOS)
|
||||
static let headerFont: CGFloat = 17
|
||||
static let labelFont: CGFloat = 23
|
||||
static let valueFont: CGFloat = 21
|
||||
static let iconFont: CGFloat = 24
|
||||
static let iconWidth: CGFloat = 40
|
||||
static let chevronFont: CGFloat = 16
|
||||
static let rowHPad: CGFloat = 24
|
||||
static let rowVPad: CGFloat = 19
|
||||
static let rowCorner: CGFloat = 18
|
||||
static let rowMaxWidth: CGFloat = 920
|
||||
static let detailFont: CGFloat = 19
|
||||
static let bandWidth: CGFloat = 380
|
||||
#else
|
||||
static let headerFont: CGFloat = 12
|
||||
static let labelFont: CGFloat = 16
|
||||
static let valueFont: CGFloat = 15
|
||||
static let iconFont: CGFloat = 17
|
||||
static let iconWidth: CGFloat = 28
|
||||
static let chevronFont: CGFloat = 12
|
||||
static let rowHPad: CGFloat = 16
|
||||
static let rowVPad: CGFloat = 13
|
||||
static let rowCorner: CGFloat = 14
|
||||
static let rowMaxWidth: CGFloat = 620
|
||||
static let detailFont: CGFloat = 13
|
||||
/// GamepadAddHostView) — one set of numbers so the screens read as the same surface, at the size
|
||||
/// the screen they are on calls for.
|
||||
///
|
||||
/// Three tiers, not two. The phone numbers used to serve every non-TV device, so an iPad Pro drew
|
||||
/// a settings list at iPhone scale in the middle of a 13" display — the field verdict was that the
|
||||
/// sizing "does not adapt to larger screens". `pad` sits between the in-hand and 10-foot sets.
|
||||
///
|
||||
/// Chosen from the SIZE CLASSES rather than the device idiom, so an iPad running a narrow Stage
|
||||
/// Manager or Split View window correctly gets the in-hand numbers — the window is what the user
|
||||
/// is reading, not the panel it sits on.
|
||||
struct GamepadFormMetrics {
|
||||
/// Which set this is, for the few things that are a KIND of layout rather than a number.
|
||||
enum Tier { case phone, pad, tv }
|
||||
|
||||
let tier: Tier
|
||||
let headerFont: CGFloat
|
||||
let labelFont: CGFloat
|
||||
let valueFont: CGFloat
|
||||
let iconFont: CGFloat
|
||||
let iconWidth: CGFloat
|
||||
let chevronFont: CGFloat
|
||||
let rowHPad: CGFloat
|
||||
let rowVPad: CGFloat
|
||||
let rowCorner: CGFloat
|
||||
let rowMaxWidth: CGFloat
|
||||
let detailFont: CGFloat
|
||||
/// 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
|
||||
}
|
||||
|
||||
private struct GamepadMetricsKey: EnvironmentKey {
|
||||
static let defaultValue = GamepadFormMetrics.platformDefault
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The form metrics for the screen currently drawing. Published from ContentView — the app
|
||||
/// ROOT — rather than only from `gamepadPaletteInk`, because a screen that applies that
|
||||
/// modifier itself sits ABOVE its own copy: its `@Environment` resolves against its parent, so
|
||||
/// it would read the bare default instead of its own window's tier.
|
||||
var gamepadMetrics: GamepadFormMetrics {
|
||||
get { self[GamepadMetricsKey.self] }
|
||||
set { self[GamepadMetricsKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
private struct DisplayBottomInsetKey: EnvironmentKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The display's bottom safe-area inset — the home-indicator strip — measured by
|
||||
/// `DisplayBottomInsetProbe` and published from ContentView. 0 until UIKit's first callback
|
||||
/// lands (the legend keeps its plain margin for that first frame) and always 0 on
|
||||
/// macOS/tvOS, where nothing publishes it.
|
||||
var displayBottomInset: CGFloat {
|
||||
get { self[DisplayBottomInsetKey.self] }
|
||||
set { self[DisplayBottomInsetKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Reports the hosting window's bottom safe-area inset from UIKit's OWN callbacks — never
|
||||
/// during a SwiftUI render.
|
||||
///
|
||||
/// This number has a history of wrong spellings, each failing silently:
|
||||
/// - a `GeometryReader` carrying `.ignoresSafeArea()` — a proxy reports NO insets for an edge it
|
||||
/// has been told to ignore, so that spelling can only ever answer 0;
|
||||
/// - `.ignoresSafeArea(.container, edges: .bottom)` on `safeAreaInset` CONTENT, which does not
|
||||
/// move content the inset mechanism itself placed; and
|
||||
/// - asking UIKit for the key window (`UIApplication.shared.connectedScenes…`) DURING body,
|
||||
/// which answered correctly and then KILLED the calling view: on an iPad (never the
|
||||
/// simulator) the walk re-enters UIKit layout mid-render and the view's update graph is
|
||||
/// silently severed — every later `@State` write lands in storage without ever re-running
|
||||
/// `body` again, which is how Settings and Add Host stopped opening while their triggers
|
||||
/// kept firing. No AttributeGraph warning, no log line; found by bisecting builds on glass.
|
||||
/// So: UIKit tells THIS view when the window or its insets change, on UIKit's schedule, and the
|
||||
/// answer hops out of the current update before anyone in SwiftUI reads it.
|
||||
struct DisplayBottomInsetProbe: UIViewRepresentable {
|
||||
let onChange: (CGFloat) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> ProbeView {
|
||||
let view = ProbeView()
|
||||
view.onChange = onChange
|
||||
// Mounted as a full-size `.background`; it must never eat a touch meant for the UI.
|
||||
view.isUserInteractionEnabled = false
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: ProbeView, context: Context) {
|
||||
view.onChange = onChange
|
||||
}
|
||||
|
||||
final class ProbeView: UIView {
|
||||
var onChange: ((CGFloat) -> Void)?
|
||||
private var last: CGFloat?
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
report()
|
||||
}
|
||||
|
||||
override func safeAreaInsetsDidChange() {
|
||||
super.safeAreaInsetsDidChange()
|
||||
report()
|
||||
}
|
||||
|
||||
// Rotation reshuffles the window's insets without necessarily touching this view's own.
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
report()
|
||||
}
|
||||
|
||||
private func report() {
|
||||
// The WINDOW's inset, not this view's: the probe sits inside the safe area, so its
|
||||
// own inset is 0 — the number the legend needs is the strip the window reserves.
|
||||
guard let bottom = window?.safeAreaInsets.bottom, bottom != last else { return }
|
||||
last = bottom
|
||||
let onChange = onChange
|
||||
// Out of the current UIKit/SwiftUI update before any state write.
|
||||
DispatchQueue.main.async { onChange?(bottom) }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The bottom padding that puts a pinned legend the same distance from the bottom of the DISPLAY
|
||||
/// as it sits from the leading edge — so it lands on the diagonal of the display's rounded corner,
|
||||
/// which is what the corner asks for.
|
||||
///
|
||||
/// A `safeAreaInset` places its content INSIDE the safe area, so a plain margin stacks on top of
|
||||
/// the device's own bottom inset and the pill ends up two to three times further from the bottom
|
||||
/// than from the left. On a tablet this therefore goes NEGATIVE, pulling the pill back down
|
||||
/// through the home-indicator strip; the pill is left-aligned and an iPad's indicator is a short
|
||||
/// bar in the middle, so the two never meet.
|
||||
///
|
||||
/// Phones keep the plain margin. Their inset is the taller indicator bar and their legend runs
|
||||
/// most of the width, so sitting it that low would cross the indicator rather than tuck beside it.
|
||||
///
|
||||
/// `displayBottom` is `\.displayBottomInset` — measured by `DisplayBottomInsetProbe`, NEVER asked
|
||||
/// of UIKit here: this runs during body, and a key-window walk mid-render severs the calling
|
||||
/// view's updates (see the probe's comment). Pure arithmetic only.
|
||||
func gamepadLegendBottomPadding(
|
||||
_ margin: CGFloat, tier: GamepadFormMetrics.Tier, displayBottom: CGFloat
|
||||
) -> CGFloat {
|
||||
guard tier == .pad else { return margin }
|
||||
// Floored at -inset: at worst the pill sits flush with the physical edge, never past it.
|
||||
return max(-displayBottom, margin - displayBottom)
|
||||
}
|
||||
|
||||
/// The tray gradient blur, back — as a real progressive BACKDROP blur this time.
|
||||
///
|
||||
/// GamepadTrayScrim did this with `.ultraThinMaterial`, and a material by definition lifts and
|
||||
/// tints whatever it blurs: it read grey over the aurora, and washed with the palette's ground it
|
||||
/// read coloured, which is why 2590238b deleted it. Glur's `GlurView` blurs the backdrop through
|
||||
/// a gradient with NO material stage on top, so the rows soften as they slide under the pinned
|
||||
/// title and legend and nothing carries a colour. It is the library's PRIVATE-API product
|
||||
/// (`GlurBackdrop`) — the public `.glur()` modifier is a shader on a view's own content and
|
||||
/// silently no-ops over platform-backed views like ScrollView, so it cannot reach a backdrop at
|
||||
/// all. Hit testing is disabled inside GlurView; the band never eats a touch.
|
||||
///
|
||||
/// Mounted exactly where the scrim was: `.background` of each form screen's safe-area tray.
|
||||
struct GamepadTrayBlur: View {
|
||||
let edge: VerticalEdge
|
||||
|
||||
var body: some View {
|
||||
// offset 0 puts the ramp's LITERAL ZERO exactly at the band's content edge, so nothing
|
||||
// in the open field is touched — which is why, unlike the scrim, this band takes NO
|
||||
// content-side overhang. The scrim's -44/-72 runway existed because a material carries
|
||||
// body at every alpha and had to dissolve OUTSIDE the tray; carrying those numbers over
|
||||
// here blurred fully-visible rows at rest (field verdict on the first cut). Full
|
||||
// strength lands at 60% of the band, so the tray's own text always sits on the strong
|
||||
// region while the ramp still reads as a gradient, not an edge.
|
||||
GlurView(
|
||||
radius: 14, offset: 0, interpolation: 0.6,
|
||||
direction: edge == .top ? .up : .down)
|
||||
// Full-bleed by LAYOUT, not `.ignoresSafeArea()`: safe-area expansion resolves a
|
||||
// beat after insertion (outside any geometry group and outside this view's own
|
||||
// transaction), which reads as a visible pop. 80 pt clears every inset on every
|
||||
// device, and backgrounds never clip — the overhang simply draws.
|
||||
.padding(edge == .top ? .top : .bottom, -80)
|
||||
.padding(.horizontal, -80)
|
||||
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
|
||||
// geometry would ride the push's transaction and visibly grow into place. The
|
||||
// layer's own fade/slide still carries the band; only its SHAPE is pinned.
|
||||
.transaction { $0.animation = nil }
|
||||
}
|
||||
}
|
||||
|
||||
/// One glyph + label cell in a hint bar.
|
||||
struct GamepadHint: Identifiable {
|
||||
let glyph: String
|
||||
let text: String
|
||||
/// What tapping/clicking this cell does — the same thing its button does. Optional because a
|
||||
/// few legend cells NAME an input rather than an action ("↔ Adjust" is the stick itself;
|
||||
/// there is no single thing a tap on it could mean), and those stay inert labels.
|
||||
var action: (() -> Void)? = nil
|
||||
var id: String { glyph + text }
|
||||
}
|
||||
|
||||
@@ -114,39 +338,75 @@ struct GamepadHint: Identifiable {
|
||||
/// the backdrop instead of dissolving into it.
|
||||
struct GamepadHintBar: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Sized with the screen it pins to — a legend at phone scale on a 13" iPad is the same
|
||||
/// mismatch the form rows had (see GamepadFormMetrics).
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
let hints: [GamepadHint]
|
||||
|
||||
// 10-foot legend on tvOS, in-hand sizes elsewhere.
|
||||
#if os(tvOS)
|
||||
private static let glyphFont: CGFloat = 27
|
||||
private static let textFont: CGFloat = 20
|
||||
private static let pad: CGFloat = 18
|
||||
#else
|
||||
private static let glyphFont: CGFloat = 19
|
||||
private static let textFont: CGFloat = 14
|
||||
private static let pad: CGFloat = 13
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 18) {
|
||||
ForEach(hints) { hint in
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: hint.glyph)
|
||||
.font(.system(size: Self.glyphFont))
|
||||
.foregroundStyle(ink.fg)
|
||||
Text(hint.text)
|
||||
}
|
||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||
cell(hint)
|
||||
}
|
||||
}
|
||||
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
|
||||
.font(.geist(metrics.hintTextFont, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(ink.fg(0.85))
|
||||
.padding(Self.pad)
|
||||
.padding(metrics.hintPad)
|
||||
.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 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
|
||||
@@ -220,14 +480,20 @@ struct GamepadScreenBackground: View {
|
||||
colorField(at: t, palette: palette)
|
||||
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||
// Calm = col·0.6 + ground·0.4: over the 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)
|
||||
// …and a plusLighter wash of the palette's own ground IS the add. Chosen so the
|
||||
// ground lands exactly where it was and the bright pools come down to meet it.
|
||||
// Mounted unconditionally — at opacity 0 a plusLighter layer contributes nothing,
|
||||
// and an always-present layer is what lets the mix animate instead of popping.
|
||||
// A further plusLighter wash of the ground, which lets a DARK palette's bright pools
|
||||
// come down to meet its ground rather than merely fading toward it.
|
||||
//
|
||||
// Suppressed on a pale palette (the factor goes to 0), because there it was destroying
|
||||
// the setting: a pale ground is near-white, so ADDING 0.4 of it on top of a field
|
||||
// already mixed 0.4 toward that same ground saturated the form screens to flat white —
|
||||
// the field ask was "in bright mode the sub-screens are basically just white". Written
|
||||
// as a factor rather than an `if` so the layer stays mounted and the calm chase keeps
|
||||
// animating instead of popping when a screen is pushed.
|
||||
Self.color(palette.ground)
|
||||
.opacity(0.4 * calmMix)
|
||||
.opacity(0.4 * calmMix * (palette.light ? 0 : 1))
|
||||
.blendMode(.plusLighter)
|
||||
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
|
||||
// pooled light. Soft (extends past the frame) so the corners deepen rather than
|
||||
@@ -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
|
||||
/// of glows over a deep indigo base — deliberately not near-black, because Liquid Glass refracts
|
||||
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
|
||||
|
||||
@@ -65,10 +65,24 @@ private struct HomeTile: Identifiable {
|
||||
|
||||
struct GamepadHomeView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
/// Published by ContentView at the app ROOT, so this reads its own window's tier — this screen
|
||||
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
/// The home-indicator strip's height, measured by DisplayBottomInsetProbe and published from
|
||||
/// ContentView — an environment READ is safe in body; asking UIKit for it here is not (see
|
||||
/// the probe's comment: a key-window walk mid-render severed this very view's updates).
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@ObservedObject var store: HostStore
|
||||
@ObservedObject var model: SessionModel
|
||||
@ObservedObject var discovery: HostDiscovery
|
||||
@Binding var libraryTarget: 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
|
||||
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
|
||||
@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
|
||||
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
|
||||
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
|
||||
/// 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))
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
GamepadHintBar(hints: hints)
|
||||
// Equal distance from the left and bottom edges — the pill's corner inset was the
|
||||
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
legend
|
||||
}
|
||||
}
|
||||
|
||||
/// The pinned controls legend, sitting the SAME distance from the leading and bottom edges of
|
||||
/// the DISPLAY — see `gamepadLegendBottomPadding` for why the bottom number is not simply the
|
||||
/// margin, and why measuring the inset (rather than trying to opt out of it) is what finally
|
||||
/// worked.
|
||||
private var legend: some View {
|
||||
GamepadHintBar(hints: hints)
|
||||
.padding(.leading, legendMargin)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
legendMargin, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
}
|
||||
|
||||
/// The legend pill's distance from the screen's leading and bottom edges.
|
||||
private var legendMargin: CGFloat { compact ? 12 : 18 }
|
||||
|
||||
#if os(iOS)
|
||||
/// The screen the shell shows over the launcher — derived from the same triggers every
|
||||
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
|
||||
private var topScreen: GamepadScreen? {
|
||||
// Pairing leads: it is a ceremony blocking a connect the user already asked for, and it
|
||||
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
|
||||
// it has to win. Backing out of it reveals whatever it interrupted.
|
||||
if let host = pairingTarget { return .pair(host) }
|
||||
if showSettings { return .settings }
|
||||
if showAddHost { return .addHost }
|
||||
if let host = libraryTarget { return .library(host) }
|
||||
@@ -248,6 +283,12 @@ struct GamepadHomeView: View {
|
||||
onAdd: { store.add($0) },
|
||||
close: { if !transitioning { showAddHost = false } },
|
||||
controllerActive: active)
|
||||
case .pair(let host):
|
||||
GamepadPairView(
|
||||
host: host,
|
||||
onPaired: { onPaired(host, $0) },
|
||||
close: { if !transitioning { pairingTarget = nil } },
|
||||
controllerActive: active)
|
||||
case .library(let host):
|
||||
GamepadLibraryScreen(
|
||||
store: store, host: host,
|
||||
@@ -294,11 +335,14 @@ struct GamepadHomeView: View {
|
||||
/// transition's input drop, during which NOBODY polls.
|
||||
private var homeOwnsController: Bool {
|
||||
#if os(iOS)
|
||||
topScreen == nil && !transitioning
|
||||
topScreen == nil && !transitioning && !promptActive
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
#else
|
||||
libraryTarget == nil && !showSettings && !showAddHost
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
// `pairingTarget` too: macOS presents the pair screen as a sheet and tvOS as a cover, and
|
||||
// either way the launcher underneath must stop consuming the pad — the pair screen's own
|
||||
// list is polling the same controller.
|
||||
libraryTarget == nil && pairingTarget == nil && !showSettings && !showAddHost
|
||||
&& !promptActive && waker.waking == nil && model.phase != .connecting
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -412,13 +456,22 @@ struct GamepadHomeView: View {
|
||||
case .rescan: "Rescan"
|
||||
default: nil
|
||||
}
|
||||
// Every cell's action re-resolves the selection when it FIRES rather than closing over the
|
||||
// one this render saw: the legend is rebuilt on selection changes, but a tap landing in
|
||||
// the same frame as a carousel move would otherwise activate the tile that was selected a
|
||||
// moment ago — the one failure mode a launcher cannot afford.
|
||||
var hints = [GamepadHint(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"),
|
||||
action: { tiles.first { $0.id == selection }?.activate() })]
|
||||
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
|
||||
}
|
||||
|
||||
@@ -573,11 +626,21 @@ private struct GamepadHostTile: View {
|
||||
}
|
||||
.padding(Self.pad)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
||||
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||
// Console tile — a brand wash marks a saved host as primary; discovered / Add-Host tiles
|
||||
// stay neutral with a dashed edge. The surface clips to the shape itself.
|
||||
//
|
||||
// `forceMaterial`: these tiles are the one console surface that gets TRANSFORMED while it
|
||||
// animates — `CardEntrance` swings each card in on a `rotation3DEffect` under an opacity
|
||||
// ramp, and the carousel's `.scrollTransition` keeps scaling and rotating the neighbours
|
||||
// forever after. Liquid Glass samples the backdrop through its own layer and cannot do
|
||||
// that under a 3D transform, so it drew one way through the swing and snapped to another
|
||||
// as the card landed — on glass it read as the tiles being swapped out for different ones
|
||||
// at the end of their entrance. A material composites flat, so the card looks the same at
|
||||
// every frame of the travel. (tvOS already takes this path for its own reasons.)
|
||||
.consoleGlass(
|
||||
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 {
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||
.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(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
}
|
||||
// A hardware keyboard's Esc still closes, without chrome.
|
||||
.background {
|
||||
|
||||
@@ -119,6 +119,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
||||
.sensoryFeedback(.selection, trigger: adjustTick)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
#if os(iOS) || os(macOS)
|
||||
// Hardware keyboard: up/down step the focus bar, left/right adjust the focused row's
|
||||
// value (exactly what the stick does), Return activates, Esc backs out.
|
||||
.gamepadKeyNavigation(
|
||||
active: isActive,
|
||||
onMove: { direction in
|
||||
switch direction {
|
||||
case .up: step(by: -1)
|
||||
case .down: step(by: 1)
|
||||
case .left: adjust(by: -1)
|
||||
case .right: adjust(by: 1)
|
||||
}
|
||||
},
|
||||
onConfirm: { activate() },
|
||||
onBack: onBack)
|
||||
#endif
|
||||
.onAppear {
|
||||
reconcile()
|
||||
wire()
|
||||
|
||||
@@ -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 {
|
||||
case settings
|
||||
case addHost
|
||||
case pair(StoredHost)
|
||||
case library(StoredHost)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .settings: return "settings"
|
||||
case .addHost: return "addHost"
|
||||
case .pair(let host): return "pair-\(host.id.uuidString)"
|
||||
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.
|
||||
var isForm: Bool {
|
||||
switch self {
|
||||
case .settings, .addHost: return true
|
||||
case .settings, .addHost, .pair: return true
|
||||
case .library: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ struct LibraryCoverflowView: View {
|
||||
) -> some View {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
onLoaded: { artSettled += 1 })
|
||||
icon: game.iconToken, onLoaded: { artSettled += 1 })
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) {
|
||||
@@ -204,13 +204,19 @@ struct LibraryCoverflowView: View {
|
||||
|
||||
private 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.
|
||||
let opens = games.first { $0.id == selection }?.isLauncher == true
|
||||
hints.append(
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ struct LibraryView: View {
|
||||
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
|
||||
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
|
||||
@State private var artLoader: LibraryArtLoader?
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
|
||||
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
|
||||
/// they didn't ask for.
|
||||
@State private var keyCursor: String?
|
||||
@State private var gridWidth: CGFloat = 0
|
||||
#endif
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
|
||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||
@@ -120,34 +127,103 @@ struct LibraryView: View {
|
||||
let launchers = games.filter(\.isLauncher)
|
||||
let titles = games.filter { !$0.isLauncher }
|
||||
let both = !launchers.isEmpty && !titles.isEmpty
|
||||
return ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !launchers.isEmpty {
|
||||
if both { sectionHeader("Launchers") }
|
||||
tiles(launchers)
|
||||
return ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !launchers.isEmpty {
|
||||
if both { sectionHeader("Launchers") }
|
||||
tiles(launchers)
|
||||
}
|
||||
if !titles.isEmpty {
|
||||
if both { sectionHeader("Games") }
|
||||
tiles(titles)
|
||||
}
|
||||
}
|
||||
if !titles.isEmpty {
|
||||
if both { sectionHeader("Games") }
|
||||
tiles(titles)
|
||||
.padding()
|
||||
#if os(iOS) || os(macOS)
|
||||
// The grid's own width, reported without affecting layout — a GeometryReader
|
||||
// SIBLING inside a ScrollView would claim the whole viewport. It's what tells the
|
||||
// keyboard cursor how many columns `.adaptive` actually produced, so it is only
|
||||
// measured where that cursor exists.
|
||||
.background {
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.onAppear { gridWidth = geo.size.width }
|
||||
.onChange(of: geo.size.width) { _, w in gridWidth = w }
|
||||
}
|
||||
}
|
||||
#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 {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(entries) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) }
|
||||
.buttonStyle(.plain)
|
||||
Button { onLaunch(game.id) } label: {
|
||||
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(game.id)
|
||||
} 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 {
|
||||
Text(text)
|
||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
||||
@@ -264,13 +340,24 @@ private struct LibraryBackCatcher: View {
|
||||
private struct GameCard: View {
|
||||
let game: GameEntry
|
||||
let artLoader: LibraryArtLoader?
|
||||
/// The hardware-keyboard cursor is on this tile — drawn as an accent ring, since the plain
|
||||
/// grid has no other way to say "Return launches THIS one".
|
||||
var selected = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
icon: game.iconToken)
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay {
|
||||
if selected {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(.tint, lineWidth: 3)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let loader: LibraryArtLoader?
|
||||
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
|
||||
/// no cover art by design, so for those the mark IS the poster — see `placeholder`.
|
||||
var icon: String?
|
||||
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
||||
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
||||
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
||||
@@ -121,11 +124,26 @@ struct PosterImage: View {
|
||||
private var placeholder: some View {
|
||||
ZStack {
|
||||
Rectangle().fill(.quaternary)
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
// A launcher's brand mark, drawn at poster size and tinted like the text it replaces.
|
||||
// `scaledToFit` inside a fraction of the card keeps a non-square master (the Steam mark
|
||||
// is 496×512, Playnite's 1024×1024) in its own aspect ratio rather than stretched.
|
||||
// Falling back to the title is the pre-icon design, so an unshipped mark loses nothing.
|
||||
if let mark = launcherIconImage(for: icon) {
|
||||
GeometryReader { geo in
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: geo.size.width * 0.44, height: geo.size.height * 0.44)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
} else {
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6).
|
||||
// Deliberately thin: every action already has an internal entry point — the deep-link router
|
||||
// (connect / connect-and-launch / connect-with-a-profile), the in-process end-session hook, and
|
||||
// the existing Wake-on-LAN path — so these intents only wrap them.
|
||||
// (connect / connect-and-launch / connect-with-a-profile, and the `browse` route into a host's
|
||||
// library), the in-process end-session hook, and the existing Wake-on-LAN path — so these
|
||||
// intents only wrap them.
|
||||
//
|
||||
// Connect 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
|
||||
@@ -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
|
||||
/// home, wake the tower") without foregrounding the app.
|
||||
struct WakeHostIntent: AppIntent {
|
||||
@@ -97,6 +120,13 @@ struct PunktfunkShortcuts: AppShortcutsProvider {
|
||||
"Stream \(\.$host) with \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Connect", systemImageName: "play.tv.fill")
|
||||
AppShortcut(
|
||||
intent: OpenLibraryIntent(),
|
||||
phrases: [
|
||||
"Open \(\.$host) library in \(.applicationName)",
|
||||
"Show \(\.$host) games in \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Game Library", systemImageName: "square.grid.2x2.fill")
|
||||
AppShortcut(
|
||||
intent: WakeHostIntent(),
|
||||
phrases: [
|
||||
|
||||
@@ -43,10 +43,27 @@ enum ScreenshotMode {
|
||||
/// readiness ping for the capture script.
|
||||
struct ScreenshotHostView: View {
|
||||
let scene: ShotScene
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
#endif
|
||||
|
||||
/// The gamepad UI's form-metric tier, published here for the same reason ContentView does it:
|
||||
/// this harness mounts those screens DIRECTLY, with no ContentView in the tree, so without it
|
||||
/// an iPad capture renders every gamepad screen at iPhone scale — a capture that doesn't look
|
||||
/// like the app.
|
||||
private var gamepadMetrics: GamepadFormMetrics {
|
||||
#if os(iOS)
|
||||
.forWindow(h: hSizeClass, v: vSizeClass)
|
||||
#else
|
||||
.platformDefault
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
scene.make()
|
||||
.environment(\.colorScheme, scene.colorScheme)
|
||||
.environment(\.gamepadMetrics, gamepadMetrics)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
|
||||
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
|
||||
|
||||
@@ -242,7 +242,8 @@ private struct ShotGamepadHome: View {
|
||||
var body: some View {
|
||||
GamepadHomeView(
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -300,7 +301,8 @@ private struct ShotConnect: View {
|
||||
if gamepadUI {
|
||||
GamepadHomeView(
|
||||
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 })
|
||||
} else {
|
||||
ShotHome()
|
||||
|
||||
@@ -54,6 +54,15 @@ struct AcknowledgementsView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Swift packages")
|
||||
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||
Text("Punktfunk uses Glur (progressive backdrop blur), "
|
||||
+ "© 2023 João Gabriel, under the MIT License.")
|
||||
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||
Text(
|
||||
|
||||
@@ -66,22 +66,20 @@ struct GamepadOptionBand: View {
|
||||
rotation: drumPosition,
|
||||
target: drumPosition,
|
||||
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
|
||||
radius: width * 0.72)
|
||||
radius: width * 0.72,
|
||||
width: width)
|
||||
}
|
||||
}
|
||||
.frame(width: width)
|
||||
.clipped()
|
||||
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut.
|
||||
.mask {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: .black, location: 0.12),
|
||||
.init(color: .black, location: 0.88),
|
||||
.init(color: .clear, location: 1),
|
||||
],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
}
|
||||
// NO `.mask` here. The soft edges used to be a gradient mask over the whole band, and a
|
||||
// mask RASTERISES what it covers — which flattens `rotation3DEffect`'s perspective, so the
|
||||
// drum was being composited as a flat sideways slide rather than a turning cylinder. That
|
||||
// is the "3D effect isn't what it should be" the field kept seeing: the geometry was
|
||||
// always right, and the mask was throwing the projection away every frame.
|
||||
//
|
||||
// The same soft edge is folded into each option's own opacity instead (see `Drum.option`),
|
||||
// which costs nothing and leaves the projection intact.
|
||||
.onChange(of: selection) { old, new in step(from: old, to: new) }
|
||||
// The options list itself can mutate under the drum (a custom resolution appears, a
|
||||
// controller connects, the buffer options re-derive from a new refresh rate) — re-seat
|
||||
@@ -131,6 +129,9 @@ private struct Drum: View, Animatable {
|
||||
let target: Double
|
||||
/// Drum radius in points (from the band width — see the caller).
|
||||
let radius: Double
|
||||
/// The band's own width — the stage the options turn on, and what the edge fade is measured
|
||||
/// against now that the container no longer carries a mask.
|
||||
let width: Double
|
||||
|
||||
var animatableData: Double {
|
||||
get { rotation }
|
||||
@@ -140,6 +141,17 @@ private struct Drum: View, Animatable {
|
||||
/// Angular pitch between adjacent options on the drum.
|
||||
private static let stepAngle = 34.0 * .pi / 180.0
|
||||
|
||||
// Neighbours exist only while the drum is MOVING, and that is not a compromise — it is the
|
||||
// documented field fix this file was written around. Showing them at rest was tried (to make a
|
||||
// settled row look more like a cylinder) and immediately reproduced the original defect: on the
|
||||
// simulator, "This device · 2752 × 2064" rendered with "280 ×" sitting on top of it, and
|
||||
// "Automatic" with "10 Mbps" through it. A long value and its neighbour occupy the same
|
||||
// pixels, and no opacity low enough to fix that is high enough to be worth drawing.
|
||||
//
|
||||
// The cylinder is meant to be READ WHILE IT TURNS. What was actually broken is fixed above:
|
||||
// the band used to mask itself, and the mask rasterised the drum and threw its perspective
|
||||
// away every frame, so the turn never looked like a turn.
|
||||
|
||||
var body: some View {
|
||||
let flight = min(1, abs(rotation - target) * 3)
|
||||
let content = ZStack {
|
||||
@@ -147,14 +159,18 @@ private struct Drum: View, Animatable {
|
||||
// Plain signed distance — the band is linear, so option i has ONE home and the
|
||||
// ends are the ends (nothing waits beyond the last option).
|
||||
let d = Double(i) - rotation
|
||||
// Only the facing option at rest; its neighbours join it for the travel (see the
|
||||
// note on `restingNeighbour`'s removal above).
|
||||
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
|
||||
option(i, distance: d, gate: flight)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Flatten the transform stack while travelling — the 10-foot GPU already made these
|
||||
// rows drop Liquid Glass, and five projected texts per step is the same class of cost.
|
||||
// Flatten the transform stack — the 10-foot GPU already made these rows drop Liquid
|
||||
// Glass, and several projected texts per step is the same class of cost. It costs the
|
||||
// projection (a rasterised layer has no perspective), which is the trade tvOS already
|
||||
// makes elsewhere on this screen.
|
||||
content.drawingGroup()
|
||||
#else
|
||||
content
|
||||
@@ -164,17 +180,30 @@ private struct Drum: View, Animatable {
|
||||
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
|
||||
let angle = d * Self.stepAngle
|
||||
let depth = cos(angle)
|
||||
let x = radius * sin(angle)
|
||||
// The facing option never gates: a resting row still shows its value.
|
||||
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
|
||||
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) * edgeFade(x)
|
||||
Text(options[i])
|
||||
.lineLimit(1)
|
||||
.fixedSize() // never let a turning label re-wrap to the band's width mid-flight
|
||||
.scaleEffect(0.70 + 0.30 * depth)
|
||||
// Foreshorten the label as it turns away — this is what sells the cylinder.
|
||||
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4)
|
||||
.offset(x: radius * sin(angle))
|
||||
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.55)
|
||||
.offset(x: x)
|
||||
.opacity(alpha)
|
||||
.zIndex(depth)
|
||||
}
|
||||
|
||||
/// The soft edge, per option, replacing the container mask that used to flatten the
|
||||
/// projection: full strength through the middle of the band, dissolving to nothing by the
|
||||
/// time an option reaches its rim, so the drum never ends on a cut.
|
||||
private func edgeFade(_ x: Double) -> Double {
|
||||
let halfWidth = width / 2
|
||||
guard halfWidth > 0 else { return 1 }
|
||||
let fadeStart = halfWidth * 0.55
|
||||
guard abs(x) > fadeStart else { return 1 }
|
||||
return max(0, min(1, (halfWidth - abs(x)) / (halfWidth - fadeStart)))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -46,6 +46,8 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
@@ -142,7 +144,7 @@ struct GamepadSettingsView: View {
|
||||
isActive: controllerActive
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -161,12 +163,12 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(focusedDetail)
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.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).
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.trailing, 22)
|
||||
.padding(.bottom, compact ? 12 : 18)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.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
|
||||
// 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 {
|
||||
let selected = t == tab
|
||||
return Text(t.rawValue)
|
||||
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
|
||||
.foregroundStyle(selected ? ink.fg : ink.fg(0.55))
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 7)
|
||||
.font(.geist(compact ? 12 : metrics.tabFont, .semibold, relativeTo: .footnote))
|
||||
// `onAccent`, not `fg` — the selected pill is FILLED with the palette accent, and
|
||||
// `onAccent` is the colour picked (by the accent's own luminance) to read on top of
|
||||
// it; its doc calls out "a filled pill's label" for exactly this surface. Using the
|
||||
// foreground meant white-on-white wherever a palette's accent is pale: Graphite's is
|
||||
// a light grey (luma ≈ 0.80), so its selected tab was unreadable.
|
||||
.foregroundStyle(selected ? ink.onAccent : ink.fg(0.55))
|
||||
// Proportional to the row metrics rather than fixed, so the strip grows with the
|
||||
// fields under it — a tab bar at phone scale above iPad-scale rows was half the
|
||||
// "does not adapt to larger screens" complaint.
|
||||
.padding(.horizontal, metrics.rowHPad * 0.8)
|
||||
.padding(.vertical, metrics.rowVPad * 0.55)
|
||||
.background {
|
||||
// One shared capsule that MOVES between pills, rather than one per pill fading
|
||||
// in and out — the highlight travels the way the press did. A Liquid Glass
|
||||
@@ -328,26 +341,39 @@ struct GamepadSettingsView: View {
|
||||
// shoulders exist at all (see `showsSectionHint`).
|
||||
let sections: [GamepadHint] = showsSectionHint
|
||||
? [.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
|
||||
// used to tell — only Done remains, and the detail line says what to turn on first.
|
||||
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
|
||||
return sections
|
||||
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
|
||||
+ [.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { back() })]
|
||||
}
|
||||
return sections + [
|
||||
// The stick itself, not an action — nothing to tap (see GamepadHint.action).
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change",
|
||||
action: { if let focusID { activate(id: focusID) } }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { back() }),
|
||||
]
|
||||
}
|
||||
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 [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin",
|
||||
action: { if let focusID { activate(id: focusID) } }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { back() }),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -365,7 +391,7 @@ struct GamepadSettingsView: View {
|
||||
// MARK: - Row rendering
|
||||
|
||||
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
|
||||
// first row of every tab was just a second label saying the same word.
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -443,9 +469,9 @@ struct GamepadSettingsView: View {
|
||||
/// narrows the stage.
|
||||
private var bandWidth: CGFloat {
|
||||
#if os(iOS)
|
||||
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
|
||||
hSizeClass == .compact && vSizeClass == .regular ? 170 : metrics.bandWidth
|
||||
#else
|
||||
GamepadFormMetrics.bandWidth
|
||||
metrics.bandWidth
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -200,15 +200,16 @@ final class HostStore: ObservableObject {
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
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/
|
||||
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
|
||||
/// `.never`-refresh entries and relies on this push.
|
||||
/// Ask WidgetKit to rebuild the launcher widgets' timelines after any store change (add/remove/
|
||||
/// pin/last-connected). iOS-only and a no-op where WidgetKit is absent; both widgets use
|
||||
/// `.never`-refresh entries and rely on this push.
|
||||
private func reloadHostsWidget() {
|
||||
#if canImport(WidgetKit) && os(iOS)
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkLibrary")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,12 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
let shape: S
|
||||
var tint: Color?
|
||||
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
|
||||
/// 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.
|
||||
@@ -117,8 +123,18 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
if #available(iOS 26, macOS 26, *), !forceMaterial {
|
||||
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 {
|
||||
content
|
||||
.background {
|
||||
@@ -137,13 +153,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
#if !os(tvOS)
|
||||
@available(iOS 26, macOS 26, *)
|
||||
private var glass: Glass {
|
||||
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share
|
||||
// it: mixed 60 % toward the caller's (the focused row must still read accented on
|
||||
// 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.
|
||||
let wash = ink.glass(ink.isLight ? 0.60 : 0.45)
|
||||
var g: Glass = .regular.tint(
|
||||
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash)
|
||||
// The glass carries the PALETTE wash only — the caller's focus tint is an animatable fill
|
||||
// above it now (see `body`).
|
||||
//
|
||||
// A pale palette gets `.clear` glass, not `.regular`. Its `ink.glass` is literal white, so
|
||||
// over `.regular` — which is already a bright, high-body material — even a light white
|
||||
// wash lands as a flat white slab: the refraction and the blurred field behind stop
|
||||
// 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() }
|
||||
return g
|
||||
}
|
||||
@@ -154,8 +178,13 @@ extension View {
|
||||
/// 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
|
||||
/// 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
|
||||
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this
|
||||
// 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 PunktfunkKit
|
||||
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 {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let host: StoredHost
|
||||
@@ -30,9 +26,10 @@ struct PairSheet: View {
|
||||
#else
|
||||
@State private var clientName = UIDevice.current.name
|
||||
#endif
|
||||
@State private var busy = false
|
||||
@State private var errorText: String?
|
||||
@State private var token = CeremonyToken()
|
||||
@StateObject private var ceremony = PairCeremony()
|
||||
|
||||
private var busy: Bool { ceremony.busy }
|
||||
private var errorText: String? { ceremony.errorText }
|
||||
#if os(tvOS)
|
||||
private enum EditField: String, Identifiable {
|
||||
case pin, clientName
|
||||
@@ -64,7 +61,7 @@ struct PairSheet: View {
|
||||
}
|
||||
HStack(spacing: 32) {
|
||||
Button("Cancel", role: .cancel) {
|
||||
token.cancelled = true
|
||||
ceremony.abandon()
|
||||
dismiss()
|
||||
}
|
||||
if busy {
|
||||
@@ -78,7 +75,7 @@ struct PairSheet: View {
|
||||
.frame(maxWidth: 1000)
|
||||
.padding(60)
|
||||
.navigationTitle("Pair with \(host.displayName)")
|
||||
.onDisappear { token.cancelled = true }
|
||||
.onDisappear { ceremony.abandon() }
|
||||
.fullScreenCover(item: $editing) { field in
|
||||
switch field {
|
||||
case .pin:
|
||||
@@ -142,7 +139,7 @@ struct PairSheet: View {
|
||||
#endif
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel) {
|
||||
token.cancelled = true
|
||||
ceremony.abandon()
|
||||
dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
@@ -180,7 +177,7 @@ struct PairSheet: View {
|
||||
.presentationDragIndicator(busy ? .hidden : .visible)
|
||||
#endif
|
||||
.interactiveDismissDisabled(busy)
|
||||
.onDisappear { token.cancelled = true } // any other dismissal path
|
||||
.onDisappear { ceremony.abandon() } // any other dismissal path
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -195,47 +192,11 @@ struct PairSheet: View {
|
||||
}
|
||||
|
||||
private func runCeremony() {
|
||||
busy = true
|
||||
errorText = nil
|
||||
let pin = pin.trimmingCharacters(in: .whitespaces)
|
||||
let name = clientName.trimmingCharacters(in: .whitespaces)
|
||||
let address = host.address
|
||||
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)?"
|
||||
}
|
||||
}
|
||||
ceremony.run(
|
||||
host: host.address, port: host.port, pin: pin, clientName: clientName
|
||||
) { fingerprint in
|
||||
onPaired(fingerprint)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// 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,
|
||||
// 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 PunktfunkKit
|
||||
@@ -13,6 +19,12 @@ struct TrustCardView: View {
|
||||
let onTrust: () -> 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 {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "lock.shield")
|
||||
@@ -60,12 +72,35 @@ struct TrustCardView: View {
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
.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)
|
||||
.frame(maxWidth: 440)
|
||||
// Floating trust card over the blurred stream — Liquid Glass on 26+, .regularMaterial
|
||||
// fallback below. The inner fingerprint box stays .quaternary (content, not glass).
|
||||
.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.
|
||||
@@ -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 {
|
||||
func chunks(of size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
|
||||
|
||||
@@ -90,6 +90,10 @@ public final class SessionAudio {
|
||||
/// 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
|
||||
|
||||
// MARK: - Device changes (see `installDeviceChangeRecovery`)
|
||||
@@ -219,9 +223,17 @@ public final class SessionAudio {
|
||||
// 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
|
||||
// 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(
|
||||
.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
|
||||
// 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
|
||||
@@ -229,10 +241,10 @@ public final class SessionAudio {
|
||||
try? session.setPreferredIOBufferDuration(0.005)
|
||||
try? session.setPreferredSampleRate(48_000)
|
||||
} else {
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
}
|
||||
#else // tvOS — no app-accessible mic
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
#endif
|
||||
try session.setActive(true)
|
||||
#if os(iOS)
|
||||
@@ -396,6 +408,8 @@ public final class SessionAudio {
|
||||
routeObserver = nil
|
||||
let mediaReset = mediaResetObserver
|
||||
mediaResetObserver = nil
|
||||
let interruption = interruptionObserver
|
||||
interruptionObserver = nil
|
||||
#endif
|
||||
stateLock.unlock()
|
||||
// Every watcher goes before the engines do: a device change landing during teardown must
|
||||
@@ -406,10 +420,13 @@ public final class SessionAudio {
|
||||
#if !os(macOS)
|
||||
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 so audio we interrupted (Music, podcasts) gets its resume cue. Like
|
||||
// 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
|
||||
// (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
|
||||
@@ -493,6 +510,7 @@ public final class SessionAudio {
|
||||
#if !os(macOS)
|
||||
installRouteObserver()
|
||||
installMediaResetObserver(micEnabled: micEnabled)
|
||||
installInterruptionObserver(micEnabled: micEnabled)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -654,6 +672,38 @@ public final class SessionAudio {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
|
||||
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" }
|
||||
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
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
|
||||
/// (`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.
|
||||
|
||||
@@ -1002,22 +1002,34 @@ public final class PunktfunkConnection {
|
||||
/// 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
|
||||
/// (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
|
||||
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
|
||||
/// 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
|
||||
-> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)?
|
||||
-> (
|
||||
pad: UInt16, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16,
|
||||
backstopMs: UInt32
|
||||
)?
|
||||
{
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||
|
||||
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 {
|
||||
case statusOK:
|
||||
return (pad, low, high, backstop)
|
||||
return (pad, low, high, lt, rt, backstop)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
|
||||
@@ -172,7 +172,8 @@ public final class GamepadFeedback {
|
||||
while rumbleBurst < 64, !flag.isStopped,
|
||||
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
|
||||
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
|
||||
}
|
||||
// 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
|
||||
/// 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] }
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// 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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
/// reselects immediately, so a Settings Picker can bind straight to this.
|
||||
@Published public var preferredID: String {
|
||||
@@ -97,12 +108,19 @@ public final class GamepadManager: ObservableObject {
|
||||
}
|
||||
|
||||
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.
|
||||
private var connectOrder: [ObjectIdentifier] = []
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
private init() {
|
||||
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(
|
||||
forName: .GCControllerDidConnect, object: nil, queue: .main
|
||||
) { [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.
|
||||
let pinned = candidates.last { $0.id == preferredID }
|
||||
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
|
||||
// (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.
|
||||
|
||||
@@ -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
|
||||
/// 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
|
||||
/// 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 sharpnessHigh: Float = 0.7
|
||||
static let sharpnessCombined: Float = 0.5
|
||||
@@ -140,9 +142,21 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
private var controller: GCController?
|
||||
private var low: Motor?
|
||||
private var high: Motor?
|
||||
/// Wire-truth target (raw wire units) — the engine command's level, applied verbatim; the
|
||||
/// core policy engine owns when it ends (explicit zero commands), so no deadline lives here.
|
||||
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
||||
/// The two Xbox impulse-trigger motors, when the pad offers
|
||||
/// `GCHapticsLocality.leftTrigger`/`.rightTrigger`. **Nil is the normal case** — every pad but
|
||||
/// 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,
|
||||
/// 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.
|
||||
@@ -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
|
||||
/// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
|
||||
/// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
|
||||
/// 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
|
||||
/// lapses mid-effect.
|
||||
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||
/// Set the wire-truth target: one policy-engine command's four motor levels, applied verbatim.
|
||||
/// Called with every 0xCA state the host sends — level changes AND renewals — and the core
|
||||
/// engine owns when a level ends (it emits explicit zero commands), so nothing here decides.
|
||||
///
|
||||
/// `leftTrigger`/`rightTrigger` are the Xbox impulse-trigger motors. They default to zero so
|
||||
/// handle-only callers (the debug test panel, the tuning tests) read unchanged, which is also
|
||||
/// 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 {
|
||||
let active = lowAmp != 0 || highAmp != 0
|
||||
let next = (lowAmp, highAmp, ltAmp, rtAmp)
|
||||
let active = next != (0, 0, 0, 0)
|
||||
if active != self.wasActive {
|
||||
self.wasActive = active
|
||||
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 }
|
||||
self.target = (lowAmp, highAmp)
|
||||
guard next != self.target else { return }
|
||||
self.target = next
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
@@ -241,7 +261,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
queue.sync {
|
||||
self.ticker?.cancel()
|
||||
self.ticker = nil
|
||||
self.target = (0, 0)
|
||||
self.target = (0, 0, 0, 0)
|
||||
self.wasActive = false
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
@@ -256,7 +276,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
defer { updateTicker() }
|
||||
if renderHID() { 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 {
|
||||
setup()
|
||||
}
|
||||
@@ -274,6 +294,18 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
|
||||
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 {
|
||||
let wasSplit = high != nil
|
||||
teardown()
|
||||
@@ -410,9 +442,11 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
/// 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.
|
||||
private func updateTicker() {
|
||||
let needed = target != (0, 0)
|
||||
let needed = target != (0, 0, 0, 0)
|
||||
|| low?.current != nil || low?.retiring != nil
|
||||
|| high?.current != nil || high?.retiring != nil
|
||||
|| leftTrigger?.current != nil || leftTrigger?.retiring != nil
|
||||
|| rightTrigger?.current != nil || rightTrigger?.retiring != nil
|
||||
if needed, ticker == nil {
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(
|
||||
@@ -477,6 +511,26 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
preferCombined = true
|
||||
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() {
|
||||
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.
|
||||
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||
m.engine.stoppedHandler = { _ in }
|
||||
@@ -577,6 +631,8 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
low = nil
|
||||
high = nil
|
||||
leftTrigger = nil
|
||||
rightTrigger = nil
|
||||
}
|
||||
|
||||
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
|
||||
/// 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).
|
||||
///
|
||||
/// **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 {
|
||||
#if os(macOS)
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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).
|
||||
|
||||
@@ -32,6 +32,15 @@ public enum DefaultsKey {
|
||||
public static let compositor = "punktfunk.compositor"
|
||||
public static let gamepadType = "punktfunk.gamepadType"
|
||||
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
|
||||
/// 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
|
||||
|
||||
@@ -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
|
||||
// faint indigo→violet ember in the bright corner. The accent stays the brand violet
|
||||
// — 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),
|
||||
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
|
||||
SIMD3(0.120, 0.024, 0.130)],
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// both a re-addressed host and a wiped store.
|
||||
func testDeepLinkForHostCarriesIDAddressAndPin() throws {
|
||||
|
||||
@@ -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"?>
|
||||
<!-- 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. -->
|
||||
<gresources>
|
||||
<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-apple-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. */
|
||||
.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); }
|
||||
/* 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-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(),
|
||||
platform: None,
|
||||
role: None,
|
||||
icon: None,
|
||||
};
|
||||
let games = vec![
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// monogram placeholder underneath the async art. Activation starts a session launching
|
||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens
|
||||
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load".
|
||||
// A launcher usually ships no poster. Its brand mark, when we ship one, IS the poster; failing
|
||||
// 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 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);
|
||||
placeholder.append(&monogram);
|
||||
monogram.set_vexpand(true);
|
||||
if let Some(mark) = launcher_icon_image(game) {
|
||||
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();
|
||||
pic.set_content_fit(gtk::ContentFit::Cover);
|
||||
|
||||
@@ -1274,13 +1274,18 @@ async fn session(args: Args) -> Result<()> {
|
||||
}
|
||||
} else if let Some(u) = punktfunk_core::quic::decode_rumble_envelope(&d) {
|
||||
// Log the first rumble so a loopback test can see the self-terminating v2
|
||||
// envelope tail (seq + TTL) arrived, not just the level.
|
||||
// envelope tail (seq + TTL) arrived, not just the level. `lt`/`rt` are the v3
|
||||
// impulse-trigger levels: printed beside the envelope because the wire-leg
|
||||
// check for trigger rumble is exactly "non-zero lt/rt AND the envelope still
|
||||
// present" — i.e. the trigger tail did not displace the seq/TTL tail.
|
||||
if !rumble_logged {
|
||||
rumble_logged = true;
|
||||
tracing::info!(
|
||||
pad = u.pad,
|
||||
low = u.low,
|
||||
high = u.high,
|
||||
lt = u.left_trigger,
|
||||
rt = u.right_trigger,
|
||||
envelope = ?u.envelope,
|
||||
"rumble (0xCA)"
|
||||
);
|
||||
|
||||
@@ -859,6 +859,7 @@ fn spawn_fetch(
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon: g.icon_token().unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -900,6 +901,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon: g.icon_token().unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |