Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c817e4bec0 | ||
|
|
bde4276632 | ||
|
|
147bc82130 | ||
|
|
30c47eb691 | ||
|
|
bd26949e0a | ||
|
|
61dfc3dadc | ||
|
|
1befa8a2c4 | ||
|
|
f8cde0adaf | ||
|
|
e93947969f | ||
|
|
159bbdbfc2 | ||
|
|
002702bcec | ||
|
|
88c1e94d94 | ||
|
|
657e82cd29 |
@@ -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
|
||||
|
||||
@@ -12,10 +12,72 @@ with the version table of the release you are moving to, then read **Breaking ch
|
||||
|
||||
---
|
||||
|
||||
## v0.27.0 — in development
|
||||
## v0.27.1 — in development
|
||||
|
||||
Sections accumulate here as work lands. The version table and the commit count are written at the
|
||||
version bump, alongside `docs/releases/v0.27.0.md` — see `docs/releases/README.md`.
|
||||
### 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
|
||||
|
||||
@@ -65,8 +127,218 @@ code. `PYROWAVE_QUEUE_PRIORITY` keeps its 0.26.0 grammar and is now forwarded **
|
||||
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).
|
||||
@@ -87,6 +359,16 @@ still means one thing on both platforms.
|
||||
- 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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -3127,7 +3127,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3139,7 +3139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3153,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",
|
||||
@@ -3186,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",
|
||||
@@ -3208,7 +3208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3216,7 +3216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3228,7 +3228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3237,7 +3237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3270,7 +3270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3281,7 +3281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3293,7 +3293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3516,7 +3516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3527,7 +3527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3545,7 +3545,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3562,7 +3562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3577,7 +3577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3596,7 +3596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3628,7 +3628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
@@ -3637,7 +3637,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3722,7 +3722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3736,7 +3736,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3759,7 +3759,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
@@ -65,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.26.0"
|
||||
"version": "0.25.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -4489,13 +4489,6 @@
|
||||
],
|
||||
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||
@@ -4562,13 +4555,6 @@
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -5364,14 +5350,6 @@
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.",
|
||||
"example": "steam"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||
@@ -6891,13 +6869,6 @@
|
||||
"type": "string",
|
||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
Font Awesome Free — brand icons (steam, xbox in assets/launcher-icons/) are from
|
||||
Font Awesome Free.
|
||||
|
||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
|
||||
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
|
||||
The icons are redistributed here as monochrome SVG path data with no
|
||||
modifications beyond color normalization (fill="currentColor").
|
||||
|
||||
Per the Font Awesome Free license (https://fontawesome.com/license/free):
|
||||
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
|
||||
commercial projects, open source projects, or really almost whatever you want.
|
||||
Attribution is required by MIT, SIL OFL, and CC BY licenses."
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for
|
||||
identification purposes only; their use does not imply endorsement.
|
||||
@@ -1,29 +0,0 @@
|
||||
Playnite — the `playnite` mark in assets/launcher-icons/ is the Playnite logo from the
|
||||
Playnite source repository (media/playnite-logo-black.svg).
|
||||
|
||||
Copyright (c) 2020 Josef Nemec (https://github.com/JosefNemec/Playnite)
|
||||
|
||||
Licensed under the MIT License:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Modifications: the colour was normalized to fill="currentColor"; the original viewBox
|
||||
(0 0 1024 1024) and path geometry are unchanged.
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for identification
|
||||
purposes only; their use does not imply endorsement.
|
||||
@@ -1,13 +0,0 @@
|
||||
Simple Icons — brand icons (lutris, heroic, epic, gog in assets/launcher-icons/) are
|
||||
from Simple Icons
|
||||
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
|
||||
Upstream slugs: lutris, heroicgameslauncher, epicgames, gogdotcom.
|
||||
|
||||
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||
required; this notice is provided for provenance.
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for
|
||||
identification purposes only; their use does not imply endorsement. See
|
||||
https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Launcher icon masters
|
||||
|
||||
The brand marks a **launcher tile** draws — the entries a library plugin publishes with
|
||||
`role: "launcher"` (design D4), which open Steam Big Picture or Heroic or Playnite rather
|
||||
than a game. One file per **icon token**, the value a plugin puts in an entry's `icon`
|
||||
field and every client resolves against the set it ships.
|
||||
|
||||
| token | mark | emitted by | source |
|
||||
|---|---|---|---|
|
||||
| `steam` | Steam | punktfunk-plugin-steam (Big Picture + desktop) | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `lutris` | Lutris | punktfunk-plugin-lutris | Simple Icons (CC0 1.0) |
|
||||
| `heroic` | Heroic Games Launcher | punktfunk-plugin-heroic | Simple Icons (CC0 1.0, slug `heroicgameslauncher`) |
|
||||
| `playnite` | Playnite | punktfunk-plugin-playnite | JosefNemec/Playnite (MIT) |
|
||||
| `epic` | Epic Games | punktfunk-plugin-epic — **dormant** | Simple Icons (CC0 1.0, slug `epicgames`) |
|
||||
| `gog` | GOG.com | punktfunk-plugin-gog — **dormant** | Simple Icons (CC0 1.0, slug `gogdotcom`) |
|
||||
| `xbox` | Xbox | punktfunk-plugin-xbox — **dormant** | Font Awesome Free brands (CC BY 4.0) |
|
||||
|
||||
The last three are **dormant on purpose**: those plugins carry a `launcher` config switch that
|
||||
is off by default and whose `launcherEntries` returns nothing, because the host has no verified
|
||||
`launcher_ui` activation for them yet — a tile would be a card that does nothing. Their marks
|
||||
ship anyway so that turning one on stays the one-line plugin change those plugins promise,
|
||||
instead of also needing a release of all six clients.
|
||||
|
||||
`steam` is the same mark as `assets/os-icons/steam.svg`, generated from that file rather than
|
||||
re-sourced, so the SteamOS host badge and the Steam launcher tile can never drift apart.
|
||||
|
||||
## Why a token and not the icon itself
|
||||
|
||||
A plugin sends the **name** of a mark, never its bytes, and never a URL.
|
||||
|
||||
The obvious alternative — a plugin ships its own `icon.svg` and the host's art proxy serves it —
|
||||
is closed by construction, and deliberately: `local_art_bytes` serves what the bytes *are*
|
||||
(`sniff_image_type`, `crates/punktfunk-host/src/library/art.rs`), and SVG is not on that list
|
||||
because it is script-capable XML and the web console renders library art in a browser. Widening
|
||||
that sniff to admit SVG would trade a rendering nicety for a stored-XSS surface.
|
||||
|
||||
Sending a token instead keeps that refusal intact and buys three things a proxied image could
|
||||
not have given us anyway: the glyph stays vector at every tile size a client picks, it takes the
|
||||
tile's own ink instead of arriving pre-coloured, and it costs no fetch, no cache and no bytes on
|
||||
a reconcile that is already body-limited.
|
||||
|
||||
The cost is that a **third-party** plugin cannot ship a mark no client bundles. Its tile falls
|
||||
back to the launcher's name on an accent face — exactly what every launcher tile looked like
|
||||
before this existed — and the fix is a pull request adding the master here.
|
||||
|
||||
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Those
|
||||
viewBoxes are not all square (`0 0 24 24`, `0 0 496 512`, `0 0 1024 1024`), so **a client must
|
||||
letterbox rather than stretch** — a mark drawn to a square box is a squashed mark.
|
||||
|
||||
## Regenerating the per-client derivatives
|
||||
|
||||
`bash scripts/gen-launcher-icons.sh [token ...]` turns a master into the three baked forms (GTK
|
||||
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
|
||||
that inline it (web console, Android, the in-session console UI). Adding a **new** token also
|
||||
means adding it to each client's shipped-token list — the script prints that checklist too.
|
||||
|
||||
## Licensing
|
||||
|
||||
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
|
||||
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
|
||||
are used here nominatively — to *identify* the launcher a tile opens, the standard practice in
|
||||
this ecosystem — and imply no affiliation or endorsement.
|
||||
@@ -1,2 +0,0 @@
|
||||
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>
|
||||
|
Before Width: | Height: | Size: 957 B |
@@ -1,2 +0,0 @@
|
||||
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 956 B |
@@ -1,2 +0,0 @@
|
||||
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -22,7 +22,6 @@ 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
|
||||
@@ -55,7 +54,6 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.unom.punktfunk.components.launcherIcon
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
@@ -369,28 +367,16 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||
)
|
||||
} else {
|
||||
// A launcher ships no poster by design, so its brand mark IS the poster — drawn big and
|
||||
// centred, tinted like the text it replaces. Falling back to the launcher's name says
|
||||
// "opens Steam" for a mark we don't ship; the title would read as "a game whose cover
|
||||
// failed to load".
|
||||
val mark = launcherIcon(game.iconToken)
|
||||
if (mark != null) {
|
||||
Icon(
|
||||
imageVector = mark,
|
||||
contentDescription = game.title,
|
||||
tint = ink.fg(0.75f),
|
||||
modifier = Modifier.fillMaxSize(0.45f),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
|
||||
// would read as "a game whose cover failed to load".
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
package io.unom.punktfunk.components
|
||||
|
||||
// GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.
|
||||
// Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.
|
||||
// Per-mark provenance and licensing: assets/launcher-icons/README.md.
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
|
||||
* Material ships no brand icons, so this is a curated registry — the sibling of [OsIcons],
|
||||
* which does the equivalent job for the host cards.
|
||||
*
|
||||
* Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds
|
||||
* the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT
|
||||
* all square, so the builder letterboxes — a mark forced into a square box is a squashed mark.
|
||||
*/
|
||||
private class LauncherGlyph(
|
||||
val viewportWidth: Float,
|
||||
val viewportHeight: Float,
|
||||
val d: String,
|
||||
)
|
||||
|
||||
private val GLYPHS: Map<String, LauncherGlyph> = mapOf(
|
||||
"steam" to LauncherGlyph(
|
||||
viewportWidth = 496f,
|
||||
viewportHeight = 512f,
|
||||
d = "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
|
||||
),
|
||||
"lutris" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z",
|
||||
),
|
||||
"heroic" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z",
|
||||
),
|
||||
"playnite" to LauncherGlyph(
|
||||
viewportWidth = 1024f,
|
||||
viewportHeight = 1024f,
|
||||
d = "M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z",
|
||||
),
|
||||
"epic" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z",
|
||||
),
|
||||
"gog" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z",
|
||||
),
|
||||
"xbox" to LauncherGlyph(
|
||||
viewportWidth = 512f,
|
||||
viewportHeight = 512f,
|
||||
d = "M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z",
|
||||
),
|
||||
)
|
||||
|
||||
private val CACHE = HashMap<String, ImageVector>()
|
||||
|
||||
/**
|
||||
* The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark
|
||||
* this build ships no art for — the caller then falls back to naming the launcher, which is
|
||||
* what every launcher tile looked like before the token existed.
|
||||
*
|
||||
* Tinted by the caller via `tint`, so one mark serves every palette.
|
||||
*/
|
||||
fun launcherIcon(token: String?): ImageVector? {
|
||||
val glyph = GLYPHS[token ?: return null] ?: return null
|
||||
return CACHE.getOrPut(token) {
|
||||
// Square the box and centre the mark in it, so a wide or tall master keeps its aspect
|
||||
// ratio instead of being stretched to the tile.
|
||||
val side = max(glyph.viewportWidth, glyph.viewportHeight)
|
||||
val dx = (side - glyph.viewportWidth) / 2f
|
||||
val dy = (side - glyph.viewportHeight) / 2f
|
||||
ImageVector.Builder(
|
||||
name = "launcher_$token",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = side,
|
||||
viewportHeight = side,
|
||||
).apply {
|
||||
addGroup(translationX = dx, translationY = dy)
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(glyph.d).toNodes(),
|
||||
fill = SolidColor(Color.White),
|
||||
)
|
||||
clearGroup()
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,6 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
||||
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
|
||||
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
|
||||
* game rather than break the decode (design D4).
|
||||
*
|
||||
* [icon] is the token for the entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||
* Null on every older host and on every ordinary title.
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
@@ -54,25 +51,12 @@ data class GameEntry(
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
val icon: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* The brand-icon token, re-validated rather than taken on trust.
|
||||
*
|
||||
* The host checks the shape on the way in, so this only fires for a host older than that
|
||||
* check or one that isn't ours. It costs a scan of a short string and means no consumer has
|
||||
* to wonder what it is about to look up.
|
||||
*/
|
||||
val iconToken: String? get() = icon?.takeIf { t ->
|
||||
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
|
||||
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for the store badge — the same table the other clients use
|
||||
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
|
||||
@@ -167,7 +151,6 @@ object LibraryClient {
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
role = str(o, "role"),
|
||||
icon = str(o, "icon"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,10 +50,6 @@ let package = Package(
|
||||
// licensing in that README). `.process` compiles the catalog; loaded via
|
||||
// OsIcon.swift.
|
||||
.process("Resources/OsIcons.xcassets"),
|
||||
// The launcher tiles' brand marks (template vector imagesets generated from the
|
||||
// assets/launcher-icons masters by scripts/gen-launcher-icons.sh — per-mark
|
||||
// provenance and licensing in that README). Loaded via LauncherIcon.swift.
|
||||
.process("Resources/LauncherIcons.xcassets"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
|
||||
@@ -125,7 +125,7 @@ struct LibraryCoverflowView: View {
|
||||
) -> some View {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
icon: game.iconToken, onLoaded: { artSettled += 1 })
|
||||
onLoaded: { artSettled += 1 })
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) {
|
||||
|
||||
@@ -346,9 +346,7 @@ private struct GameCard: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
icon: game.iconToken)
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
|
||||
@@ -71,9 +71,6 @@ struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let loader: LibraryArtLoader?
|
||||
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
|
||||
/// no cover art by design, so for those the mark IS the poster — see `placeholder`.
|
||||
var icon: String?
|
||||
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
||||
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
||||
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
||||
@@ -124,26 +121,11 @@ struct PosterImage: View {
|
||||
private var placeholder: some View {
|
||||
ZStack {
|
||||
Rectangle().fill(.quaternary)
|
||||
// A launcher's brand mark, drawn at poster size and tinted like the text it replaces.
|
||||
// `scaledToFit` inside a fraction of the card keeps a non-square master (the Steam mark
|
||||
// is 496×512, Playnite's 1024×1024) in its own aspect ratio rather than stretched.
|
||||
// Falling back to the title is the pre-icon design, so an unshipped mark loses nothing.
|
||||
if let mark = launcherIconImage(for: icon) {
|
||||
GeometryReader { geo in
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: geo.size.width * 0.44, height: geo.size.height * 0.44)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
} else {
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,28 +47,12 @@ 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.
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "epic.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "gog.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "heroic.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "lutris.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "playnite.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "steam.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "xbox.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#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>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#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>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#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>
|
||||
|
Before Width: | Height: | Size: 952 B |
@@ -1,2 +0,0 @@
|
||||
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#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>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="#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>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#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>
|
||||
|
Before Width: | Height: | Size: 951 B |
@@ -1,2 +0,0 @@
|
||||
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,17 +1,9 @@
|
||||
<?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) and the library's launcher-tile brand marks (from the
|
||||
assets/launcher-icons masters). See each directory's README for provenance/licensing.
|
||||
assets/os-icons masters; see that 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,9 +66,6 @@ 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,7 +782,6 @@ fn mock_library() -> (
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
role: None,
|
||||
icon: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -285,61 +285,27 @@ 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. 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.
|
||||
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens
|
||||
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load".
|
||||
let launcher = game.is_launcher();
|
||||
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
if let Some(mark) = launcher_icon_image(game) {
|
||||
placeholder.append(&mark);
|
||||
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 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 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);
|
||||
|
||||
let pic = gtk::Picture::new();
|
||||
pic.set_content_fit(gtk::ContentFit::Cover);
|
||||
|
||||
@@ -859,7 +859,6 @@ 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(),
|
||||
);
|
||||
@@ -901,7 +900,6 @@ 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(),
|
||||
);
|
||||
|
||||
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
@@ -1,71 +0,0 @@
|
||||
//! The library's launcher-tile brand marks. Reactor's `ImageSource` is `file:///`-URI raster
|
||||
//! only (no vector element, no icon font with brand glyphs), so the monochrome PNGs under
|
||||
//! `assets/launchers/` (mid-gray — legible on both WinUI themes; derived from the
|
||||
//! `assets/launcher-icons` masters, see that README for provenance/licensing) are embedded in
|
||||
//! the exe and materialized once into `%LOCALAPPDATA%\punktfunk\launcher-icons\`.
|
||||
//!
|
||||
//! The same disk-cache-to-URI pattern as [`super::os_icons`], and for the same reason — but
|
||||
//! baked much taller (128 px vs 32), because this mark fills a poster tile rather than sitting
|
||||
//! in a status row.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Embedded PNG per icon token. A plugin may name a mark a newer build ships; a tile whose token
|
||||
/// isn't here falls back to naming its launcher, which is how every launcher tile looked before
|
||||
/// icons existed.
|
||||
const ICONS: &[(&str, &[u8])] = &[
|
||||
("steam", include_bytes!("../../assets/launchers/steam.png")),
|
||||
(
|
||||
"lutris",
|
||||
include_bytes!("../../assets/launchers/lutris.png"),
|
||||
),
|
||||
(
|
||||
"heroic",
|
||||
include_bytes!("../../assets/launchers/heroic.png"),
|
||||
),
|
||||
(
|
||||
"playnite",
|
||||
include_bytes!("../../assets/launchers/playnite.png"),
|
||||
),
|
||||
("epic", include_bytes!("../../assets/launchers/epic.png")),
|
||||
("gog", include_bytes!("../../assets/launchers/gog.png")),
|
||||
("xbox", include_bytes!("../../assets/launchers/xbox.png")),
|
||||
];
|
||||
|
||||
fn dir() -> Option<PathBuf> {
|
||||
let base = std::env::var_os("LOCALAPPDATA")?;
|
||||
Some(PathBuf::from(base).join("punktfunk").join("launcher-icons"))
|
||||
}
|
||||
|
||||
/// Materialize the embedded PNGs to disk (idempotent; size mismatch rewrites, so an icon refresh
|
||||
/// in a newer build lands). Called once at GUI startup, before any tile renders.
|
||||
pub fn install() {
|
||||
let Some(dir) = dir() else { return };
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return; // tiles just render without the mark
|
||||
}
|
||||
for (token, bytes) in ICONS {
|
||||
let p = dir.join(format!("{token}.png"));
|
||||
let fresh = std::fs::metadata(&p)
|
||||
.map(|m| m.len() != bytes.len() as u64)
|
||||
.unwrap_or(true);
|
||||
if fresh {
|
||||
let _ = std::fs::write(&p, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `file:///` URI of the mark for an entry's `icon` token, or `None` — draw the launcher's
|
||||
/// name instead — when the entry carries no token or names one we ship no art for.
|
||||
///
|
||||
/// The token is matched against [`ICONS`] before it reaches a path join, so nothing a host sends
|
||||
/// can steer this at a file of its choosing.
|
||||
pub fn uri(token: Option<&str>) -> Option<String> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
let dir = DIR.get_or_init(dir).as_ref()?;
|
||||
let token = token.filter(|t| ICONS.iter().any(|(name, _)| name == t))?;
|
||||
let p = dir.join(format!("{token}.png"));
|
||||
p.exists()
|
||||
.then(|| format!("file:///{}", p.display().to_string().replace('\\', "/")))
|
||||
}
|
||||
@@ -43,10 +43,6 @@ pub(crate) struct Game {
|
||||
/// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that
|
||||
/// isn't `launcher` is a game" is decided in one place for every client.
|
||||
pub(crate) launcher: bool,
|
||||
/// The `file:///` URI of this entry's brand mark, already resolved by
|
||||
/// [`super::launcher_icons::uri`] — `None` when the entry names no mark or one we don't ship.
|
||||
/// Resolved at decode time rather than per render: the shelf re-renders on every art arrival.
|
||||
pub(crate) icon_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
@@ -144,7 +140,6 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon_uri: super::launcher_icons::uri(g.icon_token()),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -250,38 +245,23 @@ fn poster_tile(
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
// A launcher rarely has poster art, and an art-less launcher drawn like an art-less game
|
||||
// reads as "a game whose cover failed to load". Its brand mark, when we ship one, IS the
|
||||
// poster; failing that it names its launcher. Either way the frame below picks up the
|
||||
// accent stroke.
|
||||
//
|
||||
// `Uniform`, not `UniformToFill`: the marks keep their masters' aspect ratios (Steam is
|
||||
// 496x512, Playnite 1024x1024), and filling a 2:3 frame would crop them to a strip — the
|
||||
// very thing that kept launcher tiles art-less in the first place.
|
||||
None => match game.icon_uri.as_deref() {
|
||||
Some(uri) => border(
|
||||
Image::new_with_uri(uri)
|
||||
.stretch(Stretch::Uniform)
|
||||
.margin(uniform(poster_h * 0.28)),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
None => border(
|
||||
text_block(if game.launcher {
|
||||
store_label(&game.store).to_string()
|
||||
} else {
|
||||
initials(&game.title)
|
||||
})
|
||||
.font_size(if game.launcher { 18.0 } else { 28.0 })
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
},
|
||||
// reads as "a game whose cover failed to load". So it names its launcher instead of
|
||||
// showing a title monogram, and the frame below picks up the accent stroke.
|
||||
None => border(
|
||||
text_block(if game.launcher {
|
||||
store_label(&game.store).to_string()
|
||||
} else {
|
||||
initials(&game.title)
|
||||
})
|
||||
.font_size(if game.launcher { 18.0 } else { 28.0 })
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
};
|
||||
let framed = border(grid(vec![
|
||||
poster,
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
mod connect;
|
||||
mod help;
|
||||
mod hosts;
|
||||
mod launcher_icons;
|
||||
mod library;
|
||||
mod licenses;
|
||||
mod os_icons;
|
||||
@@ -187,10 +186,8 @@ pub struct AppCtx {
|
||||
}
|
||||
|
||||
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
|
||||
// The host tiles' OS marks and the library's launcher marks load as file:/// URIs — put the
|
||||
// embedded PNGs on disk first.
|
||||
// The host tiles' OS marks load as file:/// URIs — put the embedded PNGs on disk first.
|
||||
os_icons::install();
|
||||
launcher_icons::install();
|
||||
let ctx = Arc::new(AppCtx {
|
||||
identity,
|
||||
settings: Mutex::new(Settings::load()),
|
||||
|
||||
@@ -47,13 +47,6 @@ impl Artwork {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether this entry has no poster art at all — the condition a launcher's brand mark stands
|
||||
/// in for. Separate from `poster_candidates` so a caller asking the question doesn't have to
|
||||
/// invent a `base` it has no use for.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.portrait.is_none() && self.header.is_none() && self.hero.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// One title in the host's unified library. `id` is store-qualified (`steam:<appid>`,
|
||||
@@ -80,17 +73,6 @@ pub struct GameEntry {
|
||||
/// future value must never fail the whole library decode.
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
/// Which brand mark to draw for this entry — `"steam"`, `"heroic"`, `"playnite"` — or `None`
|
||||
/// when the host sent none (every older host, and every ordinary title).
|
||||
///
|
||||
/// A **token**, not art: the shell resolves it against the marks it ships
|
||||
/// (`assets/launcher-icons`) and falls back to naming the launcher for one it doesn't have, so
|
||||
/// a plugin can name a mark this client has never heard of without breaking the tile. The host
|
||||
/// guarantees the slug shape (`[a-z][a-z0-9-]{0,31}`), which is what makes it safe to
|
||||
/// interpolate into a resource name or an asset lookup — but see [`GameEntry::icon_token`],
|
||||
/// which re-checks rather than trusting it.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl GameEntry {
|
||||
@@ -98,23 +80,6 @@ impl GameEntry {
|
||||
pub fn is_launcher(&self) -> bool {
|
||||
self.role.as_deref() == Some("launcher")
|
||||
}
|
||||
|
||||
/// The brand-icon token, re-validated here rather than taken on trust.
|
||||
///
|
||||
/// The host validates the shape on the way in, so this can only fire for a host that is older
|
||||
/// than that check, compromised, or simply not ours. Every caller interpolates the result into
|
||||
/// a resource name (`pf-launcher-{t}-symbolic`), an asset-catalog lookup or a file path, and
|
||||
/// "the peer promised" is not the standard those deserve — a client re-checks what it is about
|
||||
/// to concatenate. Cheap enough to do at the call site.
|
||||
pub fn icon_token(&self) -> Option<&str> {
|
||||
let t = self.icon.as_deref()?;
|
||||
let ok = !t.is_empty()
|
||||
&& t.len() <= 32
|
||||
&& t.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& t.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
|
||||
ok.then_some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
|
||||
//! Skia parses SVG path data directly, so the masters need no transcription into a drawing
|
||||
//! DSL — the path string is the asset.
|
||||
|
||||
use skia_safe::{Matrix, Path, Rect};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// A parsed mark and the viewport its coordinates are in.
|
||||
type Glyph = (Path, f32, f32);
|
||||
|
||||
/// Token → parsed mark, with `None` memoizing "no such token / did not parse" so a miss is not
|
||||
/// re-attempted every frame. Named because `clippy::type_complexity` rejects it inline, and this
|
||||
/// file is generated — an inline type would fail the `-D warnings` gate on every regeneration.
|
||||
type GlyphCache = HashMap<String, Option<Glyph>>;
|
||||
|
||||
/// `(token, viewport width, viewport height, path data)` — the masters, verbatim.
|
||||
const GLYPHS: &[(&str, f32, f32, &str)] = &[
|
||||
("steam", 496.0, 512.0, "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", 24.0, 24.0, "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", 24.0, 24.0, "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", 1024.0, 1024.0, "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", 24.0, 24.0, "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", 24.0, 24.0, "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", 512.0, 512.0, "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"),
|
||||
];
|
||||
|
||||
/// The parsed path for a token plus the viewport it was authored in, or `None` when the token is
|
||||
/// absent, unknown, or (defensively) unparseable — the tile then names its launcher instead,
|
||||
/// which is exactly how every launcher tile looked before icons existed.
|
||||
///
|
||||
/// Parsed once per token and cached: `Path::from_svg` on a 3 kB string is not free, and the
|
||||
/// library shelf re-renders every frame while the cursor springs.
|
||||
fn glyph(token: &str) -> Option<Glyph> {
|
||||
static CACHE: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache.lock().ok()?;
|
||||
if let Some(hit) = cache.get(token) {
|
||||
return hit.clone();
|
||||
}
|
||||
let built = GLYPHS
|
||||
.iter()
|
||||
.find(|(t, ..)| *t == token)
|
||||
.and_then(|(_, w, h, d)| Path::from_svg(d).map(|p| (p, *w, *h)));
|
||||
cache.insert(token.to_string(), built.clone());
|
||||
built
|
||||
}
|
||||
|
||||
/// The mark for `token`, scaled to fit `dst` and centred in it — aspect ratio preserved, because
|
||||
/// the masters' viewports are not all square. `None` when there is no mark to draw.
|
||||
pub(crate) fn launcher_mark(token: &str, dst: Rect) -> Option<Path> {
|
||||
let (path, vw, vh) = glyph(token)?;
|
||||
let scale = (dst.width() / vw).min(dst.height() / vh);
|
||||
let mut m = Matrix::new_identity();
|
||||
m.set_scale((scale, scale), None);
|
||||
m.post_translate((
|
||||
dst.left + (dst.width() - vw * scale) / 2.0,
|
||||
dst.top + (dst.height() - vh * scale) / 2.0,
|
||||
));
|
||||
Some(path.with_transform(&m))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every shipped master parses. A mark that silently fails to parse is a tile that silently
|
||||
/// loses its icon, which no other test in this crate would notice.
|
||||
#[test]
|
||||
fn every_glyph_parses() {
|
||||
for (token, ..) in GLYPHS {
|
||||
assert!(glyph(token).is_some(), "{token} failed to parse");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_token_draws_nothing() {
|
||||
assert!(launcher_mark("not-a-launcher", Rect::from_wh(64.0, 64.0)).is_none());
|
||||
}
|
||||
|
||||
/// The mark is letterboxed into the destination, never stretched past it — the guarantee the
|
||||
/// non-square viewports (playnite is 1024x1024, steam 496x512) depend on.
|
||||
#[test]
|
||||
fn mark_is_contained_and_centred() {
|
||||
let dst = Rect::from_xywh(10.0, 20.0, 80.0, 40.0);
|
||||
let b = launcher_mark("steam", dst).unwrap().compute_tight_bounds();
|
||||
assert!(b.width() <= dst.width() + 0.5 && b.height() <= dst.height() + 0.5);
|
||||
let (cx, cy) = (b.center_x(), b.center_y());
|
||||
assert!(
|
||||
(cx - dst.center_x()).abs() < 1.0,
|
||||
"off-centre horizontally: {cx}"
|
||||
);
|
||||
assert!(
|
||||
(cy - dst.center_y()).abs() < 1.0,
|
||||
"off-centre vertically: {cy}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,6 @@ mod anim;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod glyphs;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod launcher_icons;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod library;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod model;
|
||||
|
||||
@@ -569,10 +569,6 @@ pub struct LibraryGame {
|
||||
/// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't
|
||||
/// `launcher` is a game" rule lives in exactly one place.
|
||||
pub launcher: bool,
|
||||
/// The token for this entry's brand mark (`"steam"`, `"heroic"`), already validated by
|
||||
/// [`pf_client_core::library::GameEntry::icon_token`]. Empty when the entry names no mark;
|
||||
/// a token we ship no art for simply draws nothing and the tile falls back to its name.
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
@@ -693,7 +689,6 @@ mod tests {
|
||||
title: title.to_string(),
|
||||
store: "steam".into(),
|
||||
launcher,
|
||||
icon: String::new(),
|
||||
};
|
||||
let shared = LibraryShared::default();
|
||||
shared.set_games(vec![
|
||||
@@ -722,7 +717,6 @@ mod tests {
|
||||
title: (*t).to_string(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -431,44 +431,22 @@ impl LibraryScreen {
|
||||
Color4f::new(0.118, 0.118, 0.145, 1.0)
|
||||
};
|
||||
canvas.draw_rect(crect, &Paint::new(face, None));
|
||||
// The launcher's brand mark IS the poster when we ship one for it. Inset to
|
||||
// ~44% of the card so it reads as a mark on a face rather than a cropped
|
||||
// cover; `launcher_mark` letterboxes inside that box, so a non-square master
|
||||
// (Steam 496x512, Playnite 1024x1024) keeps its proportions.
|
||||
let mark = (!game.icon.is_empty())
|
||||
.then(|| {
|
||||
let side = (card_w.min(card_h) as f32) * 0.44;
|
||||
crate::launcher_icons::launcher_mark(
|
||||
&game.icon,
|
||||
Rect::from_xywh(
|
||||
(card_w as f32 - side) / 2.0,
|
||||
(card_h as f32 - side) / 2.0,
|
||||
side,
|
||||
side,
|
||||
),
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
if let Some(path) = mark {
|
||||
canvas.draw_path(&path, &Paint::new(fg(0.85), None));
|
||||
let (glyph, size, ink) = if game.launcher {
|
||||
(store_label(&game.store).to_string(), 22.0 * k, fg(0.85))
|
||||
} else {
|
||||
let (glyph, size, ink) = if game.launcher {
|
||||
(store_label(&game.store).to_string(), 22.0 * k, fg(0.85))
|
||||
} else {
|
||||
(initials(&game.title), 38.0 * k, fg(0.45))
|
||||
};
|
||||
let font = fonts.font(W::Bold, size);
|
||||
let tw = font.measure_str(&glyph, None).0;
|
||||
canvas.draw_str(
|
||||
&glyph,
|
||||
Point::new(
|
||||
(card_w as f32 - tw) / 2.0,
|
||||
card_h as f32 / 2.0 + 13.0 * k as f32,
|
||||
),
|
||||
&font,
|
||||
&Paint::new(ink, None),
|
||||
);
|
||||
}
|
||||
(initials(&game.title), 38.0 * k, fg(0.45))
|
||||
};
|
||||
let font = fonts.font(W::Bold, size);
|
||||
let tw = font.measure_str(&glyph, None).0;
|
||||
canvas.draw_str(
|
||||
&glyph,
|
||||
Point::new(
|
||||
(card_w as f32 - tw) / 2.0,
|
||||
card_h as f32 / 2.0 + 13.0 * k as f32,
|
||||
),
|
||||
&font,
|
||||
&Paint::new(ink, None),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Store badge, top-left.
|
||||
|
||||
@@ -394,7 +394,6 @@ fn dump_console_screens() {
|
||||
title: (*t).to_string(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -88,7 +88,7 @@ pub use session::{session_epoch, try_recover_session};
|
||||
pub(crate) mod routing;
|
||||
pub use routing::{
|
||||
apply_input_env, managed_session_available, preflight_takeover_privilege,
|
||||
resolve_gamescope_route, restore_managed_session, restore_takeover_now,
|
||||
release_autologin_mask, resolve_gamescope_route, restore_managed_session, restore_takeover_now,
|
||||
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
|
||||
GamescopeRoute,
|
||||
};
|
||||
|
||||
@@ -91,6 +91,13 @@ static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(
|
||||
/// never gets DRM master — live-proven on the Nobara repro VM 2026-07-24).
|
||||
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Whether this takeover runtime-masked the [`STOPPED_AUTOLOGIN`] units ([`mask_unit`]) — i.e.
|
||||
/// whether there is a mask left to lift. Process memory, like the rest of the takeover mechanics.
|
||||
/// [`restore_takeover_on_startup`] sets it for a stranded takeover it adopts: unmasking a unit we
|
||||
/// never masked is a no-op, while missing one that IS masked leaves the box unable to enter its
|
||||
/// own Game Mode until reboot.
|
||||
static AUTOLOGIN_MASKED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
|
||||
/// "Switch to Desktop" detector compares against. Steam's session-select script writes
|
||||
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
|
||||
@@ -252,6 +259,12 @@ pub fn restore_takeover_on_startup() {
|
||||
stopped_dm = ?state.stopped_dm,
|
||||
"gamescope: found a stranded takeover from a previous host instance — scheduling TV restore"
|
||||
);
|
||||
// Assume the adopted takeover carries our runtime mask whenever it stopped units: whether it did
|
||||
// is not persisted (it follows from the DM flavor, which can only have stayed the same), and the
|
||||
// two errors are not symmetric — unmasking a unit we never masked is a no-op, while skipping one
|
||||
// that IS masked bars the box from its own game mode until reboot.
|
||||
*AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
!state.stopped_autologin.is_empty();
|
||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin;
|
||||
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos;
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_dm;
|
||||
@@ -645,10 +658,12 @@ pub fn foreign_gamescope_running() -> bool {
|
||||
if md.uid() != uid {
|
||||
continue;
|
||||
}
|
||||
let Ok(comm) = std::fs::read_to_string(e.path().join("comm")) else {
|
||||
// Resolved, not a raw `comm` read: nixpkgs wraps gamescope too, so on NixOS the kernel
|
||||
// reports `.gamescope-wrap` and this probe saw no foreign session at all.
|
||||
let Some(comm) = crate::proc::match_name(&e.path()) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(comm.trim(), "gamescope" | "gamescope-wl") {
|
||||
if !matches!(comm.as_str(), "gamescope" | "gamescope-wl") {
|
||||
continue;
|
||||
}
|
||||
if !descends_from(pid, our_pid) {
|
||||
@@ -1547,16 +1562,23 @@ fn kill_unit(unit: &str) {
|
||||
/// 2026-07-07). `--runtime` keeps the mask in tmpfs so a reboot clears it even if the host dies
|
||||
/// without restoring (the same semantics as the persisted takeover file).
|
||||
///
|
||||
/// ⚠ The mask only covers the UNIT path — it is NOT what stops the relogin loop itself. On images
|
||||
/// whose SDDM session helper execs the session script directly (`/etc/sddm/wayland-session
|
||||
/// gamescope-session-plus steam`, f43 bazzite-deck — live-diagnosed on the .41 VM 2026-07-31) the
|
||||
/// relogin never touches the unit, so the mask blocks nothing: SDDM relogins ~3×/s, each a full
|
||||
/// `bash --login` session start that fails against the managed instance — 328 forks/s, load 6+,
|
||||
/// 1481 logind sessions in 8 minutes, the journal flooded past its own rotation. The stream itself
|
||||
/// ⚠ The mask stops the UNIT from starting — it does NOT stop the relogin loop that keeps trying.
|
||||
/// On images whose SDDM session helper execs the session script directly (`/etc/sddm/wayland-session
|
||||
/// gamescope-session-plus steam`, f43 bazzite-deck — live-diagnosed on the .41 VM 2026-07-31) SDDM
|
||||
/// relogins ~3×/s regardless, each a full `bash --login` session start — 328 forks/s, load 6+, 1481
|
||||
/// logind sessions in 8 minutes, the journal flooded past its own rotation. The stream itself
|
||||
/// survives, but the storm starves the game and the encoder ("atrocious, unplayable 240fps"). The
|
||||
/// real defense is stopping the DM ([`dm_plan`]); the mask stays as belt-and-braces for the window
|
||||
/// before the stop lands, for images that DO route the relogin through the unit, and as the
|
||||
/// degraded takeover when the stop is impossible.
|
||||
/// real defense against the storm is stopping the DM ([`dm_plan`]); the mask stays as belt-and-braces
|
||||
/// for the window before the stop lands, and as the degraded takeover when the stop is impossible.
|
||||
///
|
||||
/// ⚠⚠ The mask DOES bite on that image, which is easy to miss and was the 2026-08-10 field bug: the
|
||||
/// session script's last act is `systemctl --user --wait start gamescope-session-plus@$1.service`, so
|
||||
/// a masked unit makes every entry into game mode — including the user's own deliberate "Return to
|
||||
/// Gaming Mode" — fail instantly, with Steam left sitting on its "Switch to Desktop…" modal forever.
|
||||
/// The mask is therefore only sound while our managed session actually holds the box: the moment the
|
||||
/// box leaves it (a mid-stream switch to a desktop session), [`lift_autologin_mask`] must lift it, or
|
||||
/// the way back is barred until reboot (`--runtime` lives in tmpfs — which is exactly why "it works
|
||||
/// again after a reboot").
|
||||
fn mask_unit(unit: &str) {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "mask", "--runtime", unit])
|
||||
@@ -1571,6 +1593,58 @@ fn unmask_unit(unit: &str) {
|
||||
.status();
|
||||
}
|
||||
|
||||
/// Lift the takeover's runtime mask ([`mask_unit`]) on the box's own autologin units, so the box can
|
||||
/// enter its own game mode again. Idempotent and cheap once lifted (the flag short-circuits), so
|
||||
/// every path that hands the box back may call it unconditionally.
|
||||
///
|
||||
/// Deliberately does NOT consume [`STOPPED_AUTOLOGIN`]: the mask's lifetime is shorter than the
|
||||
/// takeover's. Lifting it mid-stream only says "the box may start its own gaming session again"; the
|
||||
/// restore still owes that list a `start` if the box is still ours at disconnect
|
||||
/// ([`do_restore_tv_session`]).
|
||||
fn lift_autologin_mask() {
|
||||
let mut masked = AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !*masked {
|
||||
return;
|
||||
}
|
||||
*masked = false;
|
||||
let units = STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for unit in units.iter() {
|
||||
unmask_unit(unit);
|
||||
}
|
||||
tracing::info!(
|
||||
units = ?*units,
|
||||
"gamescope: lifted the takeover's runtime mask — the box can enter its own game mode again"
|
||||
);
|
||||
}
|
||||
|
||||
/// Does a mid-stream session switch TO `kind` end the window in which the takeover's mask is sound?
|
||||
/// Only a **desktop** session does: it means the box left our managed game session for one of its
|
||||
/// own, so nothing is left for the mask to defend and the user's next move — "Return to Gaming
|
||||
/// Mode" — needs the unit ([`mask_unit`]).
|
||||
///
|
||||
/// [`ActiveKind::Gaming`] must not lift by itself (a takeover's own managed session reads as Gaming,
|
||||
/// and lifting there would void the mask for the whole stream, in exactly the storm window it exists
|
||||
/// for), and neither must [`ActiveKind::None`] — a managed session momentarily down between
|
||||
/// relaunches reads as `None`, and that is mid-takeover, not the end of one.
|
||||
fn switch_ends_mask_window(kind: super::ActiveKind) -> bool {
|
||||
use super::ActiveKind;
|
||||
matches!(
|
||||
kind,
|
||||
ActiveKind::DesktopKde
|
||||
| ActiveKind::DesktopGnome
|
||||
| ActiveKind::DesktopWlroots
|
||||
| ActiveKind::DesktopHyprland
|
||||
)
|
||||
}
|
||||
|
||||
/// The host's mid-stream session watcher calls this on every switch it confirms; see
|
||||
/// [`switch_ends_mask_window`] for which ones actually lift the mask.
|
||||
pub fn release_autologin_mask(switched_to: super::ActiveKind) {
|
||||
if switch_ends_mask_window(switched_to) {
|
||||
lift_autologin_mask();
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit name of the display manager driving this box's graphical logins, from the
|
||||
/// `display-manager.service` alias symlink (the Fedora/Arch/openSUSE convention every
|
||||
/// gamescope-session distro follows). `None` when no DM is installed (a box that boots straight
|
||||
@@ -2118,7 +2192,13 @@ fn honor_session_select_switch(dm: String) {
|
||||
"gamescope: in-stream session-select detected — restoring the display manager and \
|
||||
switching the box to the desktop session"
|
||||
);
|
||||
// Consume the takeover state up front: from here on the box is the DM's again.
|
||||
// Consume the takeover state up front: from here on the box is the DM's again. The mask goes
|
||||
// FIRST and while the unit list still exists — this path discards that list, and it is the only
|
||||
// record of what carries a mask. Without this the mask outlived not just the stream but the
|
||||
// boot: the disconnect restore (the only other unmask) would find an empty list and lift
|
||||
// nothing. It is also what lets step 1 below work at all, since the DM's autologin heads back
|
||||
// into game mode through exactly this unit.
|
||||
lift_autologin_mask();
|
||||
std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
clear_takeover();
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
@@ -2327,6 +2407,11 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
}
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
let mut stopped = Vec::new();
|
||||
if plan.mask {
|
||||
// Record that a mask is outstanding BEFORE laying it: every hand-back path lifts it off this
|
||||
// flag, and one that ran between the mask and an unrecorded flag would leave it on forever.
|
||||
*AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner()) = true;
|
||||
}
|
||||
for unit in units {
|
||||
if plan.mask {
|
||||
mask_unit(&unit); // belt-and-braces under a stopped DM; the whole defense otherwise
|
||||
@@ -2623,6 +2708,12 @@ fn do_restore_tv_session() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Unmask BEFORE taking the list (it reads that list) and unconditionally — before the
|
||||
// desktop-active early return below, and before the restart loop: a unit left masked would break
|
||||
// the user's own return to gaming mode until reboot. Usually already lifted by then (the
|
||||
// mid-stream switch that ends the mask's window does it — [`release_autologin_mask`]), in which
|
||||
// case this is a no-op.
|
||||
lift_autologin_mask();
|
||||
let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
let dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
if units.is_empty() && dm.is_none() {
|
||||
@@ -2645,11 +2736,6 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
|
||||
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
||||
// Unmask UNCONDITIONALLY (before the desktop-active early return below): a unit left masked
|
||||
// would break the user's own return to gaming mode until reboot.
|
||||
for unit in &units {
|
||||
unmask_unit(unit);
|
||||
}
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
||||
// user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank
|
||||
@@ -3955,10 +4041,11 @@ mod tests {
|
||||
use super::{
|
||||
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
|
||||
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args,
|
||||
is_steam_launch, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind,
|
||||
script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
|
||||
xwayland_refusal_marker, BindOff, BindPlan, DmHelperError, SessionBind,
|
||||
DISTRO_GAMESCOPE_PATH, X11_SOCKET_DIR,
|
||||
is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind,
|
||||
release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
|
||||
shape_dedicated_command, switch_ends_mask_window, unmask_unit, xwayland_refusal_marker,
|
||||
BindOff, BindPlan, DmHelperError, SessionBind, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH,
|
||||
STOPPED_AUTOLOGIN, X11_SOCKET_DIR,
|
||||
};
|
||||
|
||||
/// The HDR spawn flags are what make a nested game render HDR at all — and their absence is
|
||||
@@ -4127,6 +4214,80 @@ mod tests {
|
||||
assert!(!p.skip && p.mask && !p.stop_dm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_desktop_switch_ends_the_mask_window() {
|
||||
use crate::ActiveKind;
|
||||
// The user switched the box to a desktop session mid-stream: our managed game session is
|
||||
// over, so the mask defends nothing — and the "Return to Gaming Mode" that follows has to
|
||||
// be able to start the unit (the distro session script starts exactly it).
|
||||
for kind in [
|
||||
ActiveKind::DesktopKde,
|
||||
ActiveKind::DesktopGnome,
|
||||
ActiveKind::DesktopWlroots,
|
||||
ActiveKind::DesktopHyprland,
|
||||
] {
|
||||
assert!(switch_ends_mask_window(kind), "{kind:?}");
|
||||
}
|
||||
// A takeover's own managed session reads as Gaming, so lifting here would void the mask for
|
||||
// the whole stream — in exactly the SDDM-relogin window it exists for. (Coming BACK to
|
||||
// gaming needs no lift either: it evidently already started.)
|
||||
assert!(!switch_ends_mask_window(ActiveKind::Gaming));
|
||||
// A managed session momentarily down between relaunches reads as None. That is
|
||||
// mid-takeover, not the end of one.
|
||||
assert!(!switch_ends_mask_window(ActiveKind::None));
|
||||
}
|
||||
|
||||
/// The same rule as above, but end-to-end against REAL systemd — that the decision is actually
|
||||
/// wired to the mask, that a lift leaves the restart list intact, and that `--runtime` is what
|
||||
/// comes off (a plain `unmask` does NOT clear a runtime mask, which is the whole reason the
|
||||
/// stranded mask survived every non-reboot remedy in the field).
|
||||
///
|
||||
/// Ignored by default: it needs a live `systemd --user` manager. On a Linux box with a session:
|
||||
/// `cargo test -p pf-vdisplay -- --ignored the_mask_comes_off`. Uses a unit name nothing owns —
|
||||
/// `mask` works on a non-existent unit (it is just a symlink to `/dev/null`), so this never goes
|
||||
/// near the box's real gaming session.
|
||||
#[test]
|
||||
#[ignore = "needs a live systemd --user manager (run explicitly on a Linux box with a session)"]
|
||||
fn the_mask_comes_off_only_when_the_box_takes_itself_back() {
|
||||
const PROBE: &str = "punktfunk-mask-probe@lifetime-test.service";
|
||||
let is_enabled = || {
|
||||
let out = std::process::Command::new("systemctl")
|
||||
.args(["--user", "is-enabled", PROBE])
|
||||
.output()
|
||||
.expect("systemctl --user");
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
unmask_unit(PROBE); // a previous failed run must not decide this one
|
||||
|
||||
// Lay the takeover's mask exactly as `stop_autologin_sessions` does.
|
||||
*STOPPED_AUTOLOGIN.lock().unwrap() = vec![PROBE.to_string()];
|
||||
*AUTOLOGIN_MASKED.lock().unwrap() = true;
|
||||
mask_unit(PROBE);
|
||||
assert_eq!(is_enabled(), "masked-runtime");
|
||||
|
||||
// Mid-stream, with the box still ours: the mask is doing its job and must stay. `Gaming` is
|
||||
// what our own managed session reads as, and `None` is one momentarily down between
|
||||
// relaunches — lifting on either would void the mask for the whole stream.
|
||||
release_autologin_mask(crate::ActiveKind::Gaming);
|
||||
release_autologin_mask(crate::ActiveKind::None);
|
||||
assert_eq!(is_enabled(), "masked-runtime");
|
||||
|
||||
// The user switched the box to its own desktop mid-stream: the window is over, and the way
|
||||
// back into game mode has to be clear before they ask for it.
|
||||
release_autologin_mask(crate::ActiveKind::DesktopKde);
|
||||
assert_ne!(is_enabled(), "masked-runtime");
|
||||
// The restart list SURVIVES the lift: the mask's lifetime is shorter than the takeover's,
|
||||
// and the disconnect restore still owes these units a `start`.
|
||||
assert_eq!(STOPPED_AUTOLOGIN.lock().unwrap().as_slice(), [PROBE]);
|
||||
// Idempotent — the watcher calls it on every switch it confirms.
|
||||
release_autologin_mask(crate::ActiveKind::DesktopGnome);
|
||||
assert_ne!(is_enabled(), "masked-runtime");
|
||||
|
||||
unmask_unit(PROBE);
|
||||
STOPPED_AUTOLOGIN.lock().unwrap().clear();
|
||||
*AUTOLOGIN_MASKED.lock().unwrap() = false;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connector_status_scan() {
|
||||
let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id()));
|
||||
|
||||
@@ -111,6 +111,132 @@ pub(crate) fn current_uid() -> u32 {
|
||||
unsafe { libc::getuid() }
|
||||
}
|
||||
|
||||
/// The longest `/proc/<pid>/comm` the kernel will report: `TASK_COMM_LEN` is 16 *including* the
|
||||
/// NUL, so a name of exactly this many bytes may be a truncation of a longer one.
|
||||
#[cfg(target_os = "linux")]
|
||||
const COMM_MAX: usize = 15;
|
||||
|
||||
/// The executable name to identify a process by, with nixpkgs wrapper decoration undone.
|
||||
///
|
||||
/// `comm` is the kernel's name for the **executed file**, truncated to [`COMM_MAX`] bytes — it is
|
||||
/// not `argv[0]` and not the command line. nixpkgs wraps essentially every graphical binary:
|
||||
/// `wrapProgram` moves the real ELF aside to `.<name>-wrapped` and installs a shell wrapper under
|
||||
/// the original name, and that wrapper `exec -a "$0"`s the hidden file. So `ps`/`pgrep -a` show a
|
||||
/// perfectly ordinary `kwin_wayland` (they read argv) while the kernel reports `.kwin_wayland-w`
|
||||
/// — 15 bytes of `.kwin_wayland-wrapped`, which can never equal `kwin_wayland`.
|
||||
///
|
||||
/// That is not a KDE-only detail. On NixOS `kwin_wayland`, `gamescope`, `gnome-shell` and
|
||||
/// `Hyprland` are all wrapped, so an exact `comm` comparison made [`super::session`]'s probe
|
||||
/// answer [`crate::ActiveKind::None`] on a visibly running desktop — and because the probe is the
|
||||
/// *only* input to that decision, no environment variable could reach it: `WAYLAND_DISPLAY` was
|
||||
/// correct, capture worked the moment detection was satisfied, and a `PUNKTFUNK_COMPOSITOR` pin
|
||||
/// turned the miss into a hard error via `pinned_at_a_dead_session`. (sway survives by accident —
|
||||
/// nixpkgs' wrapper execs a real binary that is itself still called `sway`.)
|
||||
///
|
||||
/// The `comm` fast path is kept for every ordinary distro: one read, no readlink. Only a name that
|
||||
/// *could* be decorated or truncated — it starts with `.`, or it is exactly [`COMM_MAX`] bytes —
|
||||
/// is re-resolved, first through `/proc/<pid>/exe` and then, when the kernel refuses that link,
|
||||
/// through `argv[0]`.
|
||||
///
|
||||
/// 🛑 **That last rung is not defensive padding — without it this resolver misses the exact box it
|
||||
/// was written for.** Reading `/proc/<pid>/exe` is *not* merely a matter of owning the process: the
|
||||
/// kernel gates it behind `cap_ptrace_access_check`, which demands the reader's effective set be a
|
||||
/// superset of the target's PERMITTED set. A compositor holding a capability is therefore opaque to
|
||||
/// our (deliberately uncapped — see the KWin identification note in `pf-encode`) host, same uid or
|
||||
/// not. And NixOS's own Plasma module ships exactly that:
|
||||
/// `security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`. So on NixOS + KDE the
|
||||
/// two traps compose — the name needs `exe` *because* nixpkgs wrapped it, and `exe` is denied
|
||||
/// *because* NixOS capped it — and the session probe went straight back to
|
||||
/// [`crate::ActiveKind::None`] on a running desktop.
|
||||
///
|
||||
/// Measured (Linux 6.x, same-uid reader, target holding `cap_sys_nice`), for a file capability and
|
||||
/// for the ambient-capability form `security.wrappers` actually uses, identically:
|
||||
///
|
||||
/// | probe | capped target |
|
||||
/// |---|---|
|
||||
/// | `/proc/<pid>` owner | ✅ still the real uid — the uid filter upstream is unaffected |
|
||||
/// | `comm` | ✅ readable (decorated/truncated, so still unusable on its own) |
|
||||
/// | `exe` | ❌ **EACCES** |
|
||||
/// | `cmdline` (`argv[0]`) | ✅ readable |
|
||||
///
|
||||
/// `argv[0]` is only consulted when the kernel has refused the authoritative answer, because it is
|
||||
/// the process's own claim about itself rather than the kernel's: a same-uid process can set it to
|
||||
/// anything. The exposure that buys is small and one-directional — the worst a spoof achieves is
|
||||
/// aiming detection at a compositor backend that then fails its own availability probe — whereas
|
||||
/// without the rung a capped compositor is simply invisible. It reads correctly here for the same
|
||||
/// reason `ps` does: make-wrapper's generated wrapper `exec -a "$0"`s the hidden binary, so
|
||||
/// `argv[0]` survives the decoration that `comm` does not.
|
||||
///
|
||||
/// `pid_path` is a `/proc/<pid>` directory. `None` when the process vanished mid-scan.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn match_name(pid_path: &std::path::Path) -> Option<String> {
|
||||
let comm = std::fs::read_to_string(pid_path.join("comm")).ok()?;
|
||||
let comm = comm.trim();
|
||||
// An undecorated name short enough to be complete is already the answer.
|
||||
if !comm.starts_with('.') && comm.len() < COMM_MAX {
|
||||
return Some(comm.to_string());
|
||||
}
|
||||
// The authoritative rung: the kernel's own record of the executed file, untruncated. Absent for
|
||||
// a kernel thread and for a process exiting under us, and REFUSED for a capability-holding one.
|
||||
let exe = std::fs::read_link(pid_path.join("exe")).ok();
|
||||
if let Some(full) = exe
|
||||
.as_deref()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
{
|
||||
return Some(undecorate(full).to_string());
|
||||
}
|
||||
// Refused or gone: fall back to what the process calls itself, then to the truncated `comm`.
|
||||
match argv0_name(pid_path) {
|
||||
Some(name) => {
|
||||
tracing::debug!(
|
||||
comm = %comm,
|
||||
resolved = %name,
|
||||
"/proc/<pid>/exe unreadable (a capability-holding process refuses it); \
|
||||
identified via argv[0]"
|
||||
);
|
||||
Some(name)
|
||||
}
|
||||
None => Some(comm.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name in `argv[0]`, with nixpkgs decoration undone — the last rung of [`match_name`].
|
||||
///
|
||||
/// `/proc/<pid>/cmdline` is NUL-separated, so the first field is `argv[0]` whole, with no splitting
|
||||
/// on whitespace to get wrong. `None` when it is unreadable or empty, which is the normal state for
|
||||
/// a kernel thread and for a zombie.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn argv0_name(pid_path: &std::path::Path) -> Option<String> {
|
||||
let raw = std::fs::read(pid_path.join("cmdline")).ok()?;
|
||||
let argv0 = raw.split(|b| *b == 0).next()?;
|
||||
// A process that rewrote its own argv (setproctitle-style) can leave anything here, including
|
||||
// something that is not a path at all — `file_name` simply yields it unchanged and it fails to
|
||||
// match any compositor name, which is the correct outcome.
|
||||
let argv0 = std::str::from_utf8(argv0).ok()?;
|
||||
let name = std::path::Path::new(argv0).file_name()?.to_str()?;
|
||||
(!name.is_empty()).then(|| undecorate(name).to_string())
|
||||
}
|
||||
|
||||
/// Strip nixpkgs `wrapProgram` decoration: `.<name>-wrapped`, plus the `_` suffixes make-wrapper
|
||||
/// appends when that hidden name is already taken (a doubly-wrapped app — Qt *and* GApps).
|
||||
///
|
||||
/// **Both** halves are required, and that is the load-bearing part rather than pedantry: KWin
|
||||
/// ships its own real binary called `kwin_wayland_wrapper` (the session's parent process), so a
|
||||
/// rule that merely stripped a `wrapper`-ish suffix would rewrite it into `kwin_wayland` and hand
|
||||
/// the session probe the wrong PID. Demanding the leading `.` as well keeps it — and any genuine
|
||||
/// `foo-wrapped` — under its real name.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn undecorate(name: &str) -> &str {
|
||||
let Some(rest) = name.strip_prefix('.') else {
|
||||
return name;
|
||||
};
|
||||
match rest.trim_end_matches('_').strip_suffix("-wrapped") {
|
||||
Some(real) if !real.is_empty() => real,
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ending the *tree* the helper started, not just the process we spawned.
|
||||
///
|
||||
/// [`std::process::Child::kill`] is one `TerminateProcess` / one `SIGKILL`: it ends exactly the
|
||||
@@ -280,6 +406,284 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `comm`-vs-real-name resolution ([`match_name`]). Linux-only, because the trap it exists for
|
||||
/// is a Linux kernel detail (`comm` names the executed FILE, truncated to 15 bytes) crossed with a
|
||||
/// nixpkgs packaging convention.
|
||||
///
|
||||
/// Driven against **fixture** `/proc/<pid>` directories rather than spawned processes, for the same
|
||||
/// reason the `/proc` matcher in `punktfunk-host` learned the hard way: a stand-in has to be a real
|
||||
/// ELF that tolerates being *renamed*, and `/bin/sleep` is not one. Modern coreutils (uutils on
|
||||
/// Ubuntu 25.10+, busybox elsewhere) is a MULTI-CALL binary — copied to `.kwin_wayland-wrapped` it
|
||||
/// prints "unknown program" and exits before `/proc` can be read, and restoring `argv[0]` does not
|
||||
/// save it. That reads exactly like this resolver being broken. The truncation the fixtures encode
|
||||
/// is not guessed: the strings below were measured from a live kernel (`.kwin_wayland-w`,
|
||||
/// `.kwin_wayland_w`, `.gamescope-wrap` — all 15 bytes) against binaries installed and exec'd the
|
||||
/// way nixpkgs does it.
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod name_tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A fake `/proc/<pid>` directory: a `comm` file and, optionally, the `exe` symlink and a
|
||||
/// `cmdline`. Removed on drop.
|
||||
///
|
||||
/// An absent `exe` stands in for **both** ways the real link yields nothing: a process exiting
|
||||
/// under the scan, and — the case that matters here — a capability-holding one, whose link the
|
||||
/// kernel refuses with EACCES. `match_name` cannot tell those apart and does not need to.
|
||||
struct FakePid {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl FakePid {
|
||||
/// `comm` is written exactly as the kernel would report it — i.e. already truncated.
|
||||
fn new(tag: &str, comm: &str, exe: Option<&str>) -> FakePid {
|
||||
FakePid::with_cmdline(tag, comm, exe, None)
|
||||
}
|
||||
|
||||
/// `cmdline` is the NUL-separated argument vector the kernel exposes; the fixture is given
|
||||
/// just `argv[0]` and appends the terminator, as a real one carries.
|
||||
fn with_cmdline(tag: &str, comm: &str, exe: Option<&str>, argv0: Option<&str>) -> FakePid {
|
||||
let dir = std::env::temp_dir().join(format!("pf-vd-name-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("fixture dir");
|
||||
std::fs::write(dir.join("comm"), format!("{comm}\n")).expect("comm");
|
||||
if let Some(exe) = exe {
|
||||
// The target need not exist: `read_link` reports the link's contents, and a real
|
||||
// `/proc/<pid>/exe` routinely points at a path that has since been replaced.
|
||||
std::os::unix::fs::symlink(
|
||||
format!("/nix/store/eeee-kwin-6.5.0/bin/{exe}"),
|
||||
dir.join("exe"),
|
||||
)
|
||||
.expect("exe symlink");
|
||||
}
|
||||
if let Some(argv0) = argv0 {
|
||||
std::fs::write(dir.join("cmdline"), format!("{argv0}\0--session\0")).expect("cmd");
|
||||
}
|
||||
FakePid { dir }
|
||||
}
|
||||
fn path(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FakePid {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoration table. The `kwin_wayland_wrapper` rows are the ones that earn their keep: it
|
||||
/// is a REAL KWin binary (the session's parent process), so the rule must leave it under its own
|
||||
/// name in both its plain and its wrapped form rather than collapsing either into
|
||||
/// `kwin_wayland` and handing the session probe the wrong PID.
|
||||
#[test]
|
||||
fn undecorate_strips_only_a_real_nixpkgs_wrapper() {
|
||||
for (raw, want) in [
|
||||
(".kwin_wayland-wrapped", "kwin_wayland"),
|
||||
(".gamescope-wrapped", "gamescope"),
|
||||
(".gnome-shell-wrapped", "gnome-shell"),
|
||||
(".Hyprland-wrapped", "Hyprland"),
|
||||
// make-wrapper appends `_`s when the hidden name is already taken (a Qt + GApps
|
||||
// double-wrap), so the underscores come off before the suffix does.
|
||||
(".kwin_wayland-wrapped_", "kwin_wayland"),
|
||||
(".kwin_wayland-wrapped__", "kwin_wayland"),
|
||||
// Not decoration — every one of these keeps its exact name.
|
||||
("kwin_wayland", "kwin_wayland"),
|
||||
("kwin_wayland_wrapper", "kwin_wayland_wrapper"),
|
||||
(".kwin_wayland_wrapper-wrapped", "kwin_wayland_wrapper"),
|
||||
("foo-wrapped", "foo-wrapped"),
|
||||
(".hidden", ".hidden"),
|
||||
(".-wrapped", ".-wrapped"),
|
||||
] {
|
||||
assert_eq!(undecorate(raw), want, "undecorate({raw:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole bug. Every compositor the session probe matches on is wrapped by nixpkgs, so the
|
||||
/// kernel reports a truncated, decorated `comm` that can never equal the name being compared —
|
||||
/// which is why `detect_active_session` answered `ActiveKind::None` on a *running* KDE desktop
|
||||
/// and every connect died "no usable compositor".
|
||||
#[test]
|
||||
fn a_nixpkgs_wrapped_compositor_resolves_to_its_real_name() {
|
||||
for (tag, comm, exe, want) in [
|
||||
(
|
||||
"kwin",
|
||||
".kwin_wayland-w",
|
||||
".kwin_wayland-wrapped",
|
||||
"kwin_wayland",
|
||||
),
|
||||
(
|
||||
"gamescope",
|
||||
".gamescope-wrap",
|
||||
".gamescope-wrapped",
|
||||
"gamescope",
|
||||
),
|
||||
(
|
||||
"gnome",
|
||||
".gnome-shell-wr",
|
||||
".gnome-shell-wrapped",
|
||||
"gnome-shell",
|
||||
),
|
||||
("hypr", ".Hyprland-wrapp", ".Hyprland-wrapped", "Hyprland"),
|
||||
] {
|
||||
let p = FakePid::new(tag, comm, Some(exe));
|
||||
assert_eq!(
|
||||
match_name(p.path()).as_deref(),
|
||||
Some(want),
|
||||
"a nixpkgs-wrapped {want} must resolve to the name the session probe matches"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// KWin's own `kwin_wayland_wrapper` is a real binary that runs *alongside* `kwin_wayland`, and
|
||||
/// its wrapped `comm` (`.kwin_wayland_w`) differs from the compositor's by a single byte. It
|
||||
/// must NOT resolve to `kwin_wayland`: the probe would then match the parent process and carry
|
||||
/// its PID as the compositor identity, which drives restart detection.
|
||||
#[test]
|
||||
fn kwins_own_wrapper_binary_does_not_masquerade_as_the_compositor() {
|
||||
let p = FakePid::new(
|
||||
"kwrap",
|
||||
".kwin_wayland_w",
|
||||
Some(".kwin_wayland_wrapper-wrapped"),
|
||||
);
|
||||
assert_eq!(
|
||||
match_name(p.path()).as_deref(),
|
||||
Some("kwin_wayland_wrapper")
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the 15-byte limit, with no nix involved: a long name is truncated too, and
|
||||
/// has to be recovered from `exe` rather than matched short.
|
||||
#[test]
|
||||
fn a_long_name_is_recovered_untruncated() {
|
||||
let p = FakePid::new(
|
||||
"long",
|
||||
"a-very-long-com",
|
||||
Some("a-very-long-compositor-name"),
|
||||
);
|
||||
assert_eq!(
|
||||
match_name(p.path()).as_deref(),
|
||||
Some("a-very-long-compositor-name")
|
||||
);
|
||||
}
|
||||
|
||||
/// The fast path answers without consulting `exe` at all — which is what keeps this probe at one
|
||||
/// read per process on every ordinary distro, and what lets it answer for a process whose `exe`
|
||||
/// is unreadable in the first place.
|
||||
#[test]
|
||||
fn an_ordinary_short_name_never_needs_the_exe_link() {
|
||||
let p = FakePid::new("plain", "kwin_wayland", None);
|
||||
assert_eq!(match_name(p.path()).as_deref(), Some("kwin_wayland"));
|
||||
}
|
||||
|
||||
/// A decorated-or-truncated name with neither `exe` nor `cmdline` to fall back on (a kernel
|
||||
/// thread, or a process exiting under the scan) degrades to the truncated `comm` instead of
|
||||
/// failing the whole entry.
|
||||
#[test]
|
||||
fn an_unreadable_exe_falls_back_to_comm() {
|
||||
let p = FakePid::new("noexe", ".kwin_wayland-w", None);
|
||||
assert_eq!(match_name(p.path()).as_deref(), Some(".kwin_wayland-w"));
|
||||
}
|
||||
|
||||
/// **The NixOS + KDE field bug in one assertion.** nixpkgs wraps the binary, so `comm` is
|
||||
/// `.kwin_wayland-w` and only `exe` carries the real name — and NixOS's own Plasma module hands
|
||||
/// KWin `cap_sys_nice+ep` through `security.wrappers`, so the kernel refuses that link to our
|
||||
/// uncapped host. Both traps at once is not a hypothetical combination: it is the default
|
||||
/// install. `argv[0]` is what survives, because make-wrapper's wrapper `exec -a "$0"`s the
|
||||
/// hidden binary.
|
||||
#[test]
|
||||
fn a_capped_wrapped_compositor_is_identified_by_argv0() {
|
||||
for (tag, comm, argv0, want) in [
|
||||
// Plasma's own startup execs the wrapper by absolute path.
|
||||
(
|
||||
"capkwin",
|
||||
".kwin_wayland-w",
|
||||
"/run/wrappers/bin/kwin_wayland",
|
||||
"kwin_wayland",
|
||||
),
|
||||
// …and a bare name is just as ordinary.
|
||||
("capbare", ".kwin_wayland-w", "kwin_wayland", "kwin_wayland"),
|
||||
// gamescope carries `cap_sys_nice` on a great many distros, wrapped or not.
|
||||
(
|
||||
"capgame",
|
||||
".gamescope-wrap",
|
||||
"/nix/store/aaaa-gamescope/bin/gamescope",
|
||||
"gamescope",
|
||||
),
|
||||
// A wrapper that passes the hidden path through as `argv[0]` still undecorates.
|
||||
(
|
||||
"capraw",
|
||||
".kwin_wayland-w",
|
||||
"/nix/store/eeee-kwin/bin/.kwin_wayland-wrapped",
|
||||
"kwin_wayland",
|
||||
),
|
||||
] {
|
||||
let p = FakePid::with_cmdline(tag, comm, None, Some(argv0));
|
||||
assert_eq!(
|
||||
match_name(p.path()).as_deref(),
|
||||
Some(want),
|
||||
"a capped, wrapped {want} must still be identified from argv[0]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `exe` outranks `argv[0]` whenever the kernel allows it: `argv[0]` is the process's own claim
|
||||
/// about itself and a same-uid process can set it to anything, so it may never override the
|
||||
/// kernel's answer — only stand in when there is none.
|
||||
#[test]
|
||||
fn a_readable_exe_outranks_a_lying_argv0() {
|
||||
let p = FakePid::with_cmdline(
|
||||
"liar",
|
||||
".gamescope-wrap",
|
||||
Some(".gamescope-wrapped"),
|
||||
Some("kwin_wayland"),
|
||||
);
|
||||
assert_eq!(match_name(p.path()).as_deref(), Some("gamescope"));
|
||||
}
|
||||
|
||||
/// A zombie's `cmdline` is empty, and `argv[0]` can be an empty string even when it is not —
|
||||
/// neither may yield an empty name (which would then be compared against, and could match, a
|
||||
/// compositor name only by accident).
|
||||
#[test]
|
||||
fn an_empty_cmdline_does_not_produce_a_name() {
|
||||
for (tag, cmdline) in [("zombie", ""), ("nulls", "\0\0")] {
|
||||
let dir = std::env::temp_dir().join(format!("pf-vd-name-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("fixture dir");
|
||||
std::fs::write(dir.join("comm"), ".kwin_wayland-w\n").expect("comm");
|
||||
std::fs::write(dir.join("cmdline"), cmdline).expect("cmdline");
|
||||
assert_eq!(match_name(&dir).as_deref(), Some(".kwin_wayland-w"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pid directory that does not exist yields `None`, not a bogus name — the scans `continue`.
|
||||
#[test]
|
||||
fn a_vanished_process_yields_none() {
|
||||
assert_eq!(match_name(Path::new("/proc/0")), None);
|
||||
}
|
||||
|
||||
/// The one thing a fixture cannot establish: that reading `/proc/<pid>/exe` is actually
|
||||
/// *permitted* for a process of our own uid. Checked against the only such process guaranteed
|
||||
/// to be running — this one. ⚠ It holds because *we* are uncapped, and says nothing about the
|
||||
/// processes being scanned: a capped target refuses this same link, which is what
|
||||
/// [`match_name`]'s `argv[0]` rung exists for.
|
||||
#[test]
|
||||
fn our_own_exe_link_is_readable() {
|
||||
let me = Path::new("/proc/self");
|
||||
let exe = std::fs::read_link(me.join("exe"))
|
||||
.expect("/proc/self/exe must be readable for our own uid");
|
||||
let name = exe.file_name().and_then(|n| n.to_str()).expect("exe name");
|
||||
let got = match_name(me).expect("our own name");
|
||||
// Whichever rung answered, it must agree with the real binary: the fast path returns the
|
||||
// (short, undecorated) comm, which is a prefix of it; the exe path returns it outright.
|
||||
assert!(
|
||||
name.starts_with(got.as_str()) || got == name,
|
||||
"resolved {got:?} disagrees with our real binary {name:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same two cases through `cmd /c`, so the budget logic is covered on the platform whose
|
||||
/// process model differs most (job objects, no `SIGKILL`). `ping -n` is the standard Windows
|
||||
/// no-extra-tooling sleep.
|
||||
|
||||
@@ -396,6 +396,22 @@ pub fn restore_takeover_now() {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn restore_takeover_now() {}
|
||||
|
||||
/// Tell the takeover that the box switched to `switched_to` mid-stream. A managed takeover
|
||||
/// runtime-masks the box's own autologin gaming unit so its session supervisor cannot restart it
|
||||
/// underneath us — but that mask is only sound while our managed session actually holds the box.
|
||||
/// Once the user has switched the box to a desktop session mid-stream, the mask defends nothing and
|
||||
/// bars the way back: the distro's session script starts that very unit, so "Return to Gaming Mode"
|
||||
/// fails instantly and Steam sits on its "Switch to Desktop…" modal until a reboot clears the mask.
|
||||
/// Call from the mid-stream session watcher on every switch it confirms; no-op when no takeover
|
||||
/// masked anything, and when the switch does not end the mask's window.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn release_autologin_mask(switched_to: crate::ActiveKind) {
|
||||
gamescope::release_autologin_mask(switched_to);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn release_autologin_mask(_switched_to: crate::ActiveKind) {}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -311,8 +311,15 @@ pub fn detect_active_session() -> ActiveSession {
|
||||
let dbus = default_bus(&env, &xdg_runtime_dir);
|
||||
|
||||
// Process probe: the running graphical compositor of THIS uid decides the kind. Priority lets
|
||||
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. comm names mirror the
|
||||
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
|
||||
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. Names are matched
|
||||
// exactly, `pkill -x` style — but resolved through [`crate::proc::match_name`], NOT a raw
|
||||
// `comm` read: on NixOS every one of these binaries is a nixpkgs wrapper whose real ELF is
|
||||
// `.<name>-wrapped`, so a raw `comm` says `.kwin_wayland-w` and this whole probe answered
|
||||
// `None` on a running KDE desktop. ⚠ Nor is `/proc/<pid>/exe` alone enough to undo that: NixOS
|
||||
// caps KWin (`security.wrappers.kwin_wayland`, `cap_sys_nice+ep`) and the kernel refuses that
|
||||
// link to an uncapped reader — see `match_name`, which falls through to `argv[0]` for exactly
|
||||
// this box. The uid filter below is unaffected: a capped process's `/proc/<pid>` keeps its
|
||||
// real owner (measured).
|
||||
let mut kind = ActiveKind::None;
|
||||
let mut best = 0u8;
|
||||
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
|
||||
@@ -332,10 +339,10 @@ pub fn detect_active_session() -> ActiveSession {
|
||||
if md.uid() != uid {
|
||||
continue;
|
||||
}
|
||||
let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else {
|
||||
let Some(comm) = crate::proc::match_name(&pid_path) else {
|
||||
continue;
|
||||
};
|
||||
let (k, prio) = match comm.trim() {
|
||||
let (k, prio) = match comm.as_str() {
|
||||
"gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1),
|
||||
"kwin_wayland" => (ActiveKind::DesktopKde, 4),
|
||||
"gnome-shell" => (ActiveKind::DesktopGnome, 4),
|
||||
|
||||
@@ -163,40 +163,6 @@ impl GameRole {
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest an [`GameEntry::icon`] token may be. Generous for a slug like `epic-games`, short
|
||||
/// enough that the field can never carry a payload.
|
||||
const ICON_TOKEN_MAX: usize = 32;
|
||||
|
||||
/// Whether `t` is a well-formed brand-icon token: `[a-z][a-z0-9-]{0,31}`.
|
||||
///
|
||||
/// The host validates the **shape** and nothing else. Which tokens actually draw is a client
|
||||
/// question — every client ships its own curated set of marks (`assets/launcher-icons`) and falls
|
||||
/// back to the launcher's name for one it doesn't have, so a host that gated on a registry would
|
||||
/// only be able to reject tokens that a *newer* client already knows how to draw.
|
||||
///
|
||||
/// The shape check is not cosmetic. Without it the field is free-form text a plugin controls, and
|
||||
/// every client interpolates it — into a resource name, an asset-catalog lookup, a file path. A slug
|
||||
/// alphabet makes `../`, a URL, a `data:` payload and a NUL unrepresentable, so no client has to
|
||||
/// re-derive that guard for itself.
|
||||
pub fn is_icon_token(t: &str) -> bool {
|
||||
!t.is_empty()
|
||||
&& t.len() <= ICON_TOKEN_MAX
|
||||
&& t.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& t.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
}
|
||||
|
||||
/// Reject a malformed [`GameEntry::icon`] token, naming the offender. `Ok(())` when absent.
|
||||
pub fn validate_icon(icon: Option<&str>) -> std::result::Result<(), String> {
|
||||
match icon {
|
||||
Some(t) if !is_icon_token(t) => Err(format!(
|
||||
"`icon` must be a brand token matching [a-z][a-z0-9-]{{0,{}}} (got {t:?})",
|
||||
ICON_TOKEN_MAX - 1
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One title in the unified library, regardless of which store it came from.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
pub struct GameEntry {
|
||||
@@ -211,25 +177,6 @@ pub struct GameEntry {
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
|
||||
#[serde(default, skip_serializing_if = "GameRole::is_game")]
|
||||
pub role: GameRole,
|
||||
/// Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —
|
||||
/// never image bytes and never a URL. See [`is_icon_token`].
|
||||
///
|
||||
/// It exists for launcher tiles, which by design ship no cover art: a launcher's own icon is
|
||||
/// square, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so
|
||||
/// until now those tiles were the launcher's name on a flat accent face. The token lets a client
|
||||
/// draw the real mark from art it already ships, at whatever size its tile happens to be.
|
||||
///
|
||||
/// A token rather than art on the wire because the host's art proxy serves *raster* bytes only
|
||||
/// ([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much
|
||||
/// included — it is script-capable XML and the console renders art in a browser). Sending the
|
||||
/// name of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every
|
||||
/// tile size, and lets it take the tile's ink.
|
||||
///
|
||||
/// Ordinary titles may carry one too — nothing here is launcher-specific — but nothing sets it
|
||||
/// for them: a game has real cover art, which is strictly better than a brand mark.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(example = "steam")]
|
||||
pub icon: Option<String>,
|
||||
/// How the host would launch it, when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub launch: Option<LaunchSpec>,
|
||||
@@ -441,7 +388,6 @@ mod tests {
|
||||
title: title.into(),
|
||||
art: Artwork::default(),
|
||||
role: GameRole::default(),
|
||||
icon: None,
|
||||
launch: None,
|
||||
provider: None,
|
||||
detect: DetectSpec::default(),
|
||||
|
||||
@@ -39,10 +39,6 @@ pub struct CustomEntry {
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
|
||||
#[serde(default, skip_serializing_if = "GameRole::is_game")]
|
||||
pub role: GameRole,
|
||||
/// Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token
|
||||
/// (`steam`, `heroic`), never bytes and never a URL.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// How to recognize this title's process once it is running (design §9) — the one thing a
|
||||
/// provider knows that the host cannot work out for itself.
|
||||
///
|
||||
@@ -72,10 +68,6 @@ pub struct CustomInput {
|
||||
/// entry is legal (an operator may want a "Steam" tile without installing the steam plugin).
|
||||
#[serde(default)]
|
||||
pub role: GameRole,
|
||||
/// Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`
|
||||
/// is: an operator's own "Steam" tile should be able to look like one.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// How to recognize this title's process — see [`CustomEntry::detect`].
|
||||
#[serde(default)]
|
||||
pub detect: DetectHint,
|
||||
@@ -103,10 +95,6 @@ pub struct ProviderEntryInput {
|
||||
/// emits its `launchers(cfg)` entries with `role: "launcher"`.
|
||||
#[serde(default)]
|
||||
pub role: GameRole,
|
||||
/// Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets
|
||||
/// on its `launchers(cfg)` tiles, and the whole reason the token exists.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its
|
||||
/// titles' install directories (Playnite does) should send them: it is what lets a game launched
|
||||
/// through the provider's own client still end its session when the player quits.
|
||||
@@ -139,7 +127,6 @@ impl From<CustomEntry> for GameEntry {
|
||||
title: c.title,
|
||||
art: c.art,
|
||||
role: c.role,
|
||||
icon: c.icon,
|
||||
launch: c.launch,
|
||||
provider: c.provider,
|
||||
detect,
|
||||
@@ -323,7 +310,6 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
external_id: None,
|
||||
store: None,
|
||||
role: input.role,
|
||||
icon: input.icon,
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
};
|
||||
@@ -348,7 +334,6 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
|
||||
slot.launch = input.launch;
|
||||
slot.prep = input.prep;
|
||||
slot.role = input.role;
|
||||
slot.icon = input.icon;
|
||||
slot.detect = input.detect;
|
||||
slot.meta = input.meta;
|
||||
let updated = slot.clone();
|
||||
@@ -557,7 +542,6 @@ fn reconcile_entries(
|
||||
// (`<store>:<external_id>`) — see `library_id_for`.
|
||||
store: store.map(str::to_string),
|
||||
role: input.role,
|
||||
icon: input.icon,
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
});
|
||||
@@ -676,7 +660,6 @@ mod tests {
|
||||
external_id: None,
|
||||
store: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
@@ -690,7 +673,6 @@ mod tests {
|
||||
launch: None,
|
||||
prep: Vec::new(),
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
@@ -826,60 +808,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The `icon` token takes the same route as `role`: payload → stored entry → the `GameEntry` a
|
||||
/// client renders → the wire. Same reasoning as the test above — the field is skipped when
|
||||
/// absent, so it could be dropped anywhere along that path and every existing test would still
|
||||
/// pass, while every launcher tile silently lost its mark.
|
||||
#[test]
|
||||
fn an_icon_token_survives_reconcile_onto_the_wire() {
|
||||
let mut launcher = input("launcher", "Lutris");
|
||||
launcher.role = GameRole::Launcher;
|
||||
launcher.icon = Some("lutris".into());
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let out = reconcile_entries(
|
||||
&mut entries,
|
||||
"lutris",
|
||||
Some("lutris"),
|
||||
vec![launcher, input("42", "Some Game")],
|
||||
);
|
||||
|
||||
assert_eq!(out[0].icon.as_deref(), Some("lutris"));
|
||||
assert_eq!(out[1].icon, None, "the game is untouched");
|
||||
|
||||
let tile: GameEntry = out[0].clone().into();
|
||||
assert_eq!(tile.icon.as_deref(), Some("lutris"));
|
||||
assert_eq!(serde_json::to_value(&tile).unwrap()["icon"], "lutris");
|
||||
|
||||
let game: GameEntry = out[1].clone().into();
|
||||
assert!(
|
||||
serde_json::to_value(&game)
|
||||
.unwrap()
|
||||
.get("icon")
|
||||
.is_none(),
|
||||
"an entry with no mark stays byte-identical on the wire, so older clients are unaffected"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second reconcile that drops the token must clear it, not leave the old one behind. The
|
||||
/// reconcile is declarative — `slot.icon = input.icon` — and this is the test that would fail if
|
||||
/// someone ever made it an `Option`-merging update, which for `art` would be a defensible choice
|
||||
/// and here would strand a mark on a tile whose plugin removed it.
|
||||
#[test]
|
||||
fn dropping_the_icon_on_a_later_reconcile_clears_it() {
|
||||
let mut first = input("launcher", "Lutris");
|
||||
first.role = GameRole::Launcher;
|
||||
first.icon = Some("lutris".into());
|
||||
|
||||
let mut entries = Vec::new();
|
||||
reconcile_entries(&mut entries, "lutris", Some("lutris"), vec![first]);
|
||||
|
||||
let mut second = input("launcher", "Lutris");
|
||||
second.role = GameRole::Launcher;
|
||||
let out = reconcile_entries(&mut entries, "lutris", Some("lutris"), vec![second]);
|
||||
assert_eq!(out[0].icon, None);
|
||||
}
|
||||
|
||||
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
|
||||
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
|
||||
/// pre-metadata `library.json` / payload still parses (all-optional).
|
||||
|
||||
@@ -101,7 +101,6 @@ fn epic_entry(
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("epic:{app_name}"),
|
||||
store: "epic".into(),
|
||||
|
||||
@@ -58,7 +58,6 @@ fn gog_games() -> Vec<GameEntry> {
|
||||
out.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "gog".into(),
|
||||
|
||||
@@ -110,7 +110,6 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("heroic:{runner}:{app_name}"),
|
||||
store: "heroic".into(),
|
||||
|
||||
@@ -85,7 +85,6 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("lutris:{id}"),
|
||||
store: "lutris".into(),
|
||||
|
||||
@@ -30,7 +30,6 @@ impl LibraryProvider for SteamProvider {
|
||||
.map(|app| GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", app.appid),
|
||||
store: "steam".into(),
|
||||
@@ -386,7 +385,6 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", sc.appid),
|
||||
store: "steam".into(),
|
||||
|
||||
@@ -71,7 +71,6 @@ fn xbox_games() -> Vec<GameEntry> {
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "xbox".into(),
|
||||
|
||||
@@ -29,7 +29,6 @@ fn check_entry_fields(
|
||||
art: &crate::library::Artwork,
|
||||
launch: Option<&crate::library::LaunchSpec>,
|
||||
prep: &[crate::hooks::PrepCmd],
|
||||
icon: Option<&str>,
|
||||
) -> Option<(String, Response)> {
|
||||
if !lane.may_set_privileged_fields() {
|
||||
if let Some(field) = crate::library::privileged_field(launch, prep) {
|
||||
@@ -48,13 +47,6 @@ fn check_entry_fields(
|
||||
));
|
||||
}
|
||||
}
|
||||
// Shape-only, and on every lane: an icon token names no resource the host owns, so there is
|
||||
// nothing here for an operator token to unlock — it is refused for being unrepresentable as a
|
||||
// slug, not for being privileged. Clients interpolate the value, so the guard belongs upstream
|
||||
// of all of them (`crate::library::validate_icon`).
|
||||
if let Err(e) = crate::library::validate_icon(icon) {
|
||||
return Some((e.clone(), api_error(StatusCode::BAD_REQUEST, &e)));
|
||||
}
|
||||
crate::library::validate_art_paths(art)
|
||||
.err()
|
||||
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
|
||||
@@ -309,13 +301,9 @@ pub(crate) async fn create_custom_game(
|
||||
if input.title.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
||||
}
|
||||
if let Some((_, denied)) = check_entry_fields(
|
||||
lane,
|
||||
&input.art,
|
||||
input.launch.as_ref(),
|
||||
&input.prep,
|
||||
input.icon.as_deref(),
|
||||
) {
|
||||
if let Some((_, denied)) =
|
||||
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
match crate::library::add_custom(input) {
|
||||
@@ -348,13 +336,9 @@ pub(crate) async fn update_custom_game(
|
||||
if input.title.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
||||
}
|
||||
if let Some((_, denied)) = check_entry_fields(
|
||||
lane,
|
||||
&input.art,
|
||||
input.launch.as_ref(),
|
||||
&input.prep,
|
||||
input.icon.as_deref(),
|
||||
) {
|
||||
if let Some((_, denied)) =
|
||||
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep)
|
||||
{
|
||||
return denied;
|
||||
}
|
||||
use crate::library::MutateOutcome;
|
||||
@@ -480,8 +464,7 @@ pub(crate) async fn reconcile_provider_entries(
|
||||
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
|
||||
// one privileged field anywhere in it is one command execution.
|
||||
for (i, e) in inputs.iter().enumerate() {
|
||||
if let Some((reason, denied)) =
|
||||
check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep, e.icon.as_deref())
|
||||
if let Some((reason, denied)) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep)
|
||||
{
|
||||
tracing::warn!(
|
||||
provider,
|
||||
|
||||
@@ -1144,6 +1144,13 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<At
|
||||
match pending {
|
||||
// Stable at the new kind for the debounce window — the switch is real, signal it.
|
||||
Some((k, since)) if k == cur && since.elapsed() >= DEBOUNCE => {
|
||||
// Before anything about capture: a managed takeover runtime-masks the box's own
|
||||
// autologin gaming unit, and a switch to a desktop session ends the window where
|
||||
// that mask is sound. Left on, it silently bars the way back — the user's "Return
|
||||
// to Gaming Mode" cannot start a masked unit, so Steam sits on its "Switch to
|
||||
// Desktop…" modal until a reboot clears it. Ahead of the `compositor_for_kind`
|
||||
// arm below on purpose: a switch we cannot follow still has to unbar the return.
|
||||
vdisplay::release_autologin_mask(cur);
|
||||
match vdisplay::compositor_for_kind(cur) {
|
||||
Some(comp) => {
|
||||
tracing::info!(from = ?current, to = ?cur, compositor = comp.id(),
|
||||
|
||||
@@ -44,8 +44,10 @@ manifest is OpenPGP-signed by packages@unom.io (key `AF245C506F4E4763`, the same
|
||||
RPMs), and `punktfunk-sysext` checks that signature against a key baked into the script before it
|
||||
trusts a single checksum — so it needs `gpg` on the box, and it refuses a feed it can't verify.
|
||||
|
||||
The plugin runner rides along in the image but isn't started: run
|
||||
`systemctl --user enable --now punktfunk-scripting` when you want [plugins](/docs/plugins).
|
||||
The plugin runner rides along in the image and is **started for you** — the image bakes in its
|
||||
`default.target.wants` symlink, because the game-library scanners ship as
|
||||
[plugins](/docs/plugins). To turn it off: `systemctl --user mask punktfunk-scripting` (`mask`, not
|
||||
`disable` — a plain disable cannot remove a symlink that lives in `/usr`).
|
||||
|
||||
From then on:
|
||||
|
||||
|
||||
@@ -109,11 +109,6 @@ key, the answer is cached on the host, and a lookup that fails just leaves a tit
|
||||
A [plugin](/docs/plugins) can own a slice of the library and keep it in sync — this is how the ROM
|
||||
Manager and Playnite plugins get your collection into the grid, box art and all.
|
||||
|
||||
A library plugin can also publish a **launcher tile** — an entry that opens Steam Big Picture,
|
||||
Heroic, Lutris or Playnite itself rather than a game, so you can install or fix something from the
|
||||
couch. Clients group those into their own row above your titles, and each one draws its launcher's
|
||||
logo. A launcher tile you don't want is a switch in that plugin's settings.
|
||||
|
||||
Entries a plugin owns are read-only to you. The host refuses a hand edit or a delete of one, because
|
||||
the next sync would overwrite it anyway — change the title at its source and let the plugin sync
|
||||
again. Only the plugin can remove its own entries, and it removes every one of them at once. Your
|
||||
|
||||
@@ -101,6 +101,7 @@ services.punktfunk.host = {
|
||||
enable = true;
|
||||
users = [ "alice" ]; # added to the `input` group, for virtual gamepads
|
||||
openFirewall = true;
|
||||
desktopSession = true; # on a machine you log into — see below
|
||||
settings = { RUST_LOG = "info"; }; # these become host.env
|
||||
};
|
||||
```
|
||||
@@ -108,13 +109,27 @@ services.punktfunk.host = {
|
||||
The module does declaratively what the deb/RPM scriptlets do — the systemd user service, udev rules,
|
||||
kernel modules, sysctl tuning, the firewall ports and `input` group membership — and brings in the
|
||||
web console alongside the host. Because `settings` writes the environment file for you, skip the
|
||||
`host.env` step in [After installing](#after-installing). The user services are defined but not
|
||||
started, so from your graphical session enable the host and the console:
|
||||
`host.env` step in [After installing](#after-installing).
|
||||
|
||||
**Set `desktopSession = true` on any machine somebody logs into.** It ties the host to
|
||||
`graphical-session.target`, so restarting Plasma or GNOME restarts the host with it. Without it the
|
||||
host keeps running against a compositor that no longer exists — still listening, still answering,
|
||||
and failing at capture on every session after that. Leave it off for the headless appliance route
|
||||
(a pinned compositor or a gamescope box), which may never reach that target. Same reasoning, and
|
||||
the same caveats for Sway and Hyprland, as [Restart the host with your
|
||||
desktop](/docs/running-as-a-service#restart-the-host-with-your-desktop).
|
||||
|
||||
The host and console user services are defined but not started (set `autoStart = true` for an
|
||||
appliance), so from your graphical session enable them:
|
||||
|
||||
```sh
|
||||
systemctl --user enable --now punktfunk-host punktfunk-web
|
||||
```
|
||||
|
||||
The plugin runner needs no such step — like the deb and RPM, the module starts it for you, because
|
||||
the game-library scanners ship as plugins. Opt out with
|
||||
`services.punktfunk.scripting.autoStart = false;`.
|
||||
|
||||
The full option reference (client, console and scripting options, GPU driver notes, headless
|
||||
appliance setup) is in
|
||||
[packaging/nix](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/nix/README.md). To
|
||||
@@ -138,14 +153,20 @@ update, run `nix flake update punktfunk` in your flake directory, then `sudo nix
|
||||
For Gaming Mode, add the [Decky plugin](/docs/steam-deck) on top of it. Full client instructions
|
||||
for every device: [Install a Client](/docs/install-client).
|
||||
|
||||
- **`punktfunk-scripting`** — the plugin/script runner. Install it if you want
|
||||
[plugins](/docs/plugins) or [automation](/docs/automation). It's inert until you add something to
|
||||
run, so its user unit ships **disabled** — enable it once you have:
|
||||
- **`punktfunk-scripting`** — the plugin/script runner, behind [plugins](/docs/plugins) and
|
||||
[automation](/docs/automation). The game-library scanners ship as plugins, so a host without the
|
||||
runner can come up with an empty library — which is why **apt, dnf, the Bazzite sysext and the
|
||||
NixOS module all start it for you**. On **Arch** and source installs it is not started, so enable
|
||||
it yourself:
|
||||
|
||||
```sh
|
||||
systemctl --user enable --now punktfunk-scripting
|
||||
```
|
||||
|
||||
To opt out where it *is* on: `systemctl --user mask punktfunk-scripting` (`mask`, not `disable` —
|
||||
a plain disable cannot remove a symlink that lives in `/etc` or `/usr`), or on NixOS
|
||||
`services.punktfunk.scripting.autoStart = false;`.
|
||||
|
||||
## After installing
|
||||
|
||||
These three steps are for the **Linux packages**. On Windows the installer does the equivalent for
|
||||
|
||||
@@ -108,6 +108,12 @@ the host running against a compositor that no longer exists. It keeps listening
|
||||
every session after that fails at capture, which is a confusing way to find out. The drop-in makes a
|
||||
compositor restart a host restart.
|
||||
|
||||
On **NixOS** don't copy anything — the module has the option:
|
||||
|
||||
```nix
|
||||
services.punktfunk.host.desktopSession = true;
|
||||
```
|
||||
|
||||
Skip it on the headless/appliance route below (which has its own session unit), and on **Sway or
|
||||
Hyprland**, which don't hand their session to systemd: they never reach `graphical-session.target`, so
|
||||
the drop-in is harmless there but does nothing. To make the host come and go with the session on
|
||||
@@ -243,9 +249,11 @@ KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to
|
||||
```
|
||||
|
||||
which reads exactly like a missing or mis-installed `.desktop` file and survives reinstalling both
|
||||
ends. Version 0.26.0-1 granted the host the capability for the reason above and shipped precisely
|
||||
this, on every Linux channel; 0.26.0-2 revoked it everywhere. If you ever see that error, check the
|
||||
binaries before anything else — the host's own message names the capability when it finds one:
|
||||
ends. The first 0.26.0 packages granted the host the capability for the reason above and shipped
|
||||
precisely this, on every Linux channel; the grant was revoked everywhere later the same day, and
|
||||
0.27.0 is the first release whose version number carries that revocation to a machine that already
|
||||
installed one of them. If you ever see that error, check the binaries before anything else — the
|
||||
host's own message names the capability when it finds one:
|
||||
|
||||
```sh
|
||||
getcap /usr/bin/punktfunk-host # correct output is nothing at all
|
||||
|
||||
@@ -1,28 +1,74 @@
|
||||
<!--
|
||||
Accumulating ahead of the version bump, per docs/releases/README.md: entries are added as work
|
||||
lands, and the lead-in, the TL;DR and any "Before you update" section are written at the bump,
|
||||
when the shape of the whole release is known. The angle-bracket lines below are the TEMPLATE.md
|
||||
placeholders and must be replaced before the tag is pushed. docs/releases/whatsnew/v0.27.0.txt is
|
||||
still owed and is a hard gate on the android job.
|
||||
-->
|
||||
Wire-compatible with 0.26.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
|
||||
|
||||
Wire-compatible with 0.26.x — everything you have already paired keeps working, and you can update one side at a time. <one sentence on how older/newer clients negotiate or fall back, so nobody fears updating.>
|
||||
|
||||
<Lead-in: one or two sentences naming the headline change of this release — everything above the first `##` is what the Discord #releases embed shows, so make it read as a standalone summary.>
|
||||
This release does three things. Streaming performance on Linux finally gets the graphics-card priority it has been asking for since 0.25, without the privilege that broke KDE. An Xbox controller on a Windows host becomes a real controller that games can actually see, with rumble for the first time. And the controller interface on iPad, Apple TV and Mac becomes something you can drive end to end with a controller in your hands — including pairing, which used to be a dead end. Alongside all of it, everything 0.26.0 got wrong in the field is repaired.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **<The one thing you would tell someone in a sentence.>**
|
||||
- **<Second. Anything that needs the reader to ACT goes here too, and in `## Before you update`.>**
|
||||
- **<Third. Three to six bullets, one line each. This is what a reader sees instead of giving up.>**
|
||||
- **The wavelet codec can ask your graphics card for priority again**, on every Linux desktop, with the privilege moved to a small separate program so the host never holds one. Worth about a third off the worst frames under a demanding game. Nothing to switch on.
|
||||
- **An Xbox controller on a Windows host was invisible to games** — not Steam, not the Windows controller panel, not most modern titles. It is a real controller now, and it rumbles. Update the host.
|
||||
- **HDR streams from an NVIDIA host had red and blue swapped.** Every one of them, on every client. Update the host.
|
||||
- **A gamescope session crashed on essentially every connect**, black screen, until the machine gave up and came back on the unpatched compositor. Update the host.
|
||||
- **On NixOS the host could not detect a running desktop at all**, so streaming a desktop never worked. Update the host.
|
||||
- **You can now pair, trust a host and answer every prompt with a controller** on iPad, Apple TV and Mac — and drive the whole interface from a keyboard. Update the client.
|
||||
|
||||
## Before you update
|
||||
|
||||
Most people need to do nothing. Check this list if any of it applies to you.
|
||||
|
||||
- **Any Linux host you installed on 9 August:** the very first 0.26.0 packages gave the host a system privilege that stops KDE recognising it, and desktop streaming failed outright as a result. Corrected packages went out the same day under the *same* version number, so a machine that installed an early one was never offered the fix. This release carries a new version number and therefore actually reaches it. If you are unsure, `getcap /usr/bin/punktfunk-host` should print nothing at all.
|
||||
- **Never grant that privilege to the host yourself.** Not by hand, not through a service file, not through a NixOS wrapper. The small helper program beside it is the thing that needs one, and your packages already gave it one.
|
||||
- **Fedora or Nobara, if the patched compositor refused to install:** `punktfunk-gamescope` was never signed, so installing it failed on every Fedora and Nobara machine even though the package was sitting right there in the repository. It is signed now. Install it again.
|
||||
- **Fedora or Nobara, if every connect gives you a black screen:** Game Mode needs your user to be in the `punktfunk` group, and nothing said so — the host blamed a missing permission rule instead, and neither remedy it suggested added anyone to a group. Run `sudo usermod -aG punktfunk "$USER"` and log back in. The host now checks this at startup and tells you plainly.
|
||||
- **Bazzite, if 0.26.0 seemed to change nothing:** the system-extension upgrade is driven by the *old* release's script, so a step added in 0.26.0 was executed by nobody, and every check afterwards reported there was nothing to do. Among the casualties was that same `punktfunk` group. Run `sudo punktfunk-sysext update`, or `sudo punktfunk-sysext reapply` to re-run those steps without reinstalling the image.
|
||||
- **NixOS:** the plugin runner now starts by default, matching every other packaging. If you do not want it, set `scripting.autoStart = false`.
|
||||
- **Windows, if you stream with an Xbox controller:** the new controller is the default now. Nothing to do, but if a title behaves oddly with it, `PUNKTFUNK_XBOX_BACKEND=xusb` restores the old one without reinstalling anything.
|
||||
|
||||
## New
|
||||
|
||||
- **An Xbox controller on a Windows host is a real controller.** Until now it existed only as a companion device, visible to the oldest of Windows' controller interfaces and to nothing else — so Steam, the Windows game controller panel and everything a modern title actually uses could not see it at all. One report spent two weeks on a controller that was, from every angle that mattered, simply absent. It is now built as genuine hardware and promoted by Windows exactly as a real Xbox pad is, so everything that can see a controller sees this one.
|
||||
- **That controller rumbles.** It never had rumble of any kind — nothing was listening for it. Games now drive it through the ordinary Windows path.
|
||||
- **Three Xbox models to choose from:** Xbox Wireless Controller, Xbox Wireless Controller (One S), and Xbox Elite Wireless Controller Series 2, so glyphs and the name a game shows match what you picked. The Elite is reachable by setting it explicitly; no client offers it in a menu yet, and its four paddles are not wired up.
|
||||
- **Pairing you can do with a controller.** On iPhone, iPad and Mac the pairing sheet is a form with text fields, which a controller cannot reach, type into, or submit — so for anyone using the controller interface, the one thing standing between a fresh install and a first stream was a dead end. It is fully pad-driven now. The trust prompt that appears mid-connect takes controller input too, and so do the alerts and confirmations along the way, which were all dead ends for the same reason.
|
||||
- **Drive the interface from a keyboard.** Asked for by an iPad user on a Magic Keyboard: arrow keys move, Return launches. A couch Mac gets the same.
|
||||
- **A Shortcut or a home-screen widget can jump straight into a host's library** on Apple devices, landing in whichever view the current mode uses.
|
||||
- **The controls legend is clickable.** Every hint that names an action is a real button on iPhone, iPad and Mac, so an iPad on a stand or a Mac driven by trackpad can use the interface it was reading.
|
||||
|
||||
## Improved
|
||||
|
||||
- **The wavelet codec can ask your graphics card for priority again — on every desktop this time.** Under a demanding game it encodes on the same graphics cores the game is using and gets crowded out, and asking the driver to put that work first is worth about a third off the worst frames. It needs a system privilege, and 0.26 could not use it: an interim build gave that privilege to the host, KDE stopped recognising the host as a result, and desktop streaming failed outright — so the privilege was taken away everywhere and the switch sat dormant. The encoding now happens in a small separate program installed alongside the host, and the privilege is granted to **that** program rather than to the host. The host still holds nothing at all, so KDE keeps recognising it, and the priority request works on KDE, GNOME, gamescope and everything else alike. Nothing to switch on. There is also nothing to grant by hand — in particular, do not give the host that privilege yourself, which is still the one thing that takes KDE desktop streaming away. Linux hosts, wavelet-codec sessions only.
|
||||
- **A host that cannot start that helper simply carries on.** If the program is missing, refuses to start, is a mismatched version, or stops mid-session, the host does the encoding itself exactly as it did before, notes one line in the log, and the stream keeps running. The same is true if the privilege was never granted: the request is refused, the encoding runs at normal priority, and the only cost is frame pacing under a game that is already saturating the graphics card.
|
||||
- **The controller interface is sized for an iPad.** It had two sizes — Apple TV, and everything else — so a 13" iPad Pro drew its rows, tabs and legend at phone scale across a very large display. There is a size between them now, and the controls pill sits in the display's corner instead of floating well above it.
|
||||
- **The interface remembers which controller is yours.** The button hints read the live controller, so the moment a DualSense slept or ran flat every ✕/◯ turned into A/B under its owner's hands. They now stay as the pad you actually own.
|
||||
- **The OLED palette is called Eclipse.** Every other background is named for a place or a material, and "OLED" was a spec sheet sitting among them.
|
||||
- **When Game Mode cannot take over the screen, the host says exactly why.** It used to discard the one message that explained it and guess instead, naming a permission rule that was installed and working, and offering two remedies that could not have helped. The real reason now comes through word for word, and a startup check raises it before you are mid-stream rather than during one.
|
||||
- **The console is on the current design system**, and card spacing is fixed at the root rather than worked around in seven places. Dropdowns were the visible casualty of the old one: a near-white border and focus ring, with a chevron and placeholder text so dark they all but disappeared.
|
||||
- **The Virtual displays page has tabs** — Configuration and Live displays. The configuration card alone is taller than most screens, so the list of live displays was in practice off the bottom of the page.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **A gamescope session crashed on essentially every connect.** Eleven crashes in three minutes on the machine that surfaced it, until the session supervisor ran out of retries and brought the box up on the unpatched compositor instead — so the visible symptom was a black screen, and then a machine quietly running something other than what you installed.
|
||||
- **Streaming a desktop never worked on NixOS.** The host works out whether a desktop is running by asking the system what each program is called, and on NixOS essentially every graphical program is wrapped, so the name it gets back is the wrapper's — which matched nothing. Every check answered "no desktop here" on a perfectly ordinary running desktop, and nothing downstream could recover: naming your compositor explicitly turned the miss into a hard error instead. Sway and river escaped by accident.
|
||||
- **Every HDR stream from an NVIDIA host had red and blue swapped.** The compositor writes those pixels in its own order on NVIDIA hardware while labelling them with the ordinary one, so the host believed a label that was correct at every step while the contents underneath it were reversed — which is why this survived a full trace of both ends. Confirmed against two unrelated clients, so it was never a client fault. Ordinary streams were never affected.
|
||||
- **Game Mode could never start on Fedora and Nobara.** Nobara's session script names the system compositor outright and ignores every way we have of pointing it somewhere else, so it ran the unpatched one and the host refused it. Once that was solved a second fault appeared behind it: the graphics layer shipped with the distribution speaks a different dialect to the compositor we ship, and it was killing every graphical program before anything could draw, so Steam never appeared. Black screen, no error. Both are handled, and the machine can no longer end up dumped on the plain desktop with a leftover setting that also breaks the next boot.
|
||||
- **Taking over the screen barred the way back into Game Mode.** The takeover switches off the machine's own session supervisor so it cannot restart underneath the stream, and only switched it back on when the client disconnected — so for the whole stream the button the user was most likely to press did nothing.
|
||||
- **Connecting to a host paused your music, and resuming it silenced the stream.** The audio session was claiming exclusive use, so it evicted whatever was playing on connect, and handing it back later left the stream mute for good. Both halves were the same defect.
|
||||
- **Changing your audio device mid-stream killed the sound on a Mac.** Starting a stream with AirPods in and taking them out gave you nothing on the speakers; putting them back in gave you nothing in the AirPods either, and only restarting the whole stream brought it back. The audio engine stops itself when the hardware underneath it changes and nothing was starting it again. It now rebuilds on whatever device is there, including when the microphone and echo cancellation are on, which is the default on a Mac.
|
||||
- **The library's hide button was invisible and clickable at the same time.** Hiding a title shipped in 0.26.0 and was in practice unusable: the operator reported there was no button, then found it by clicking the empty corner of a poster — which meant anyone could drop a game from their library by accident and never know how.
|
||||
- **One failure to start the video encoder could take down every session on the machine**, not just the one that failed. Separately, the check that asks whether your card can encode 10-bit was itself enough to wedge every later attempt on an NVIDIA host using the direct path.
|
||||
- **`punktfunk-gamescope` could not be installed on Fedora or Nobara.** Every copy ever published was unsigned, and the repository we tell you to add requires signatures — so it failed on every machine while both the release notes and the documentation said it was available.
|
||||
- **Uninstalling on Windows left its audio devices behind forever.** "Punktfunk Speakers", "Punktfunk Microphone" and a per-controller entry stayed in your sound settings after the host was gone, because they are created while it runs rather than installed. Uninstalling now takes them with it, restores whatever playback device you had before if a stream left it switched, and matches only its own — Steam's identically named devices are untouched.
|
||||
- **A Bazzite system-extension update could report success and change nothing.** Because the old release's script drives the upgrade, a step added in a new release is run by nobody, and every check afterwards said there was nothing to do. That is why 0.26.0 quietly failed to create the group Game Mode needs. Those steps are re-run now, and a `reapply` command re-applies them on demand.
|
||||
- **The console reported the resolution the client asked for, not the one it got.** When the two differ — and they do, whenever a mode is adjusted on the way up — the number on screen was the request rather than the result.
|
||||
- **The controller interface's cards flashed once as they settled**, sat at the wrong fill for the whole entrance, and the selected settings tab was white on light grey in one palette.
|
||||
- **A controller that could not be created blamed the drivers.** When a live stream already held that controller slot — which is entirely legitimate — the host told you to reinstall or repair the drivers, which was never the problem and never helped. It now names what is actually holding the slot.
|
||||
- **On NixOS three service settings had quietly diverged** from the ones every other packaging ships, including one that could leave the web console permanently stopped if it was enabled before the host's first run.
|
||||
|
||||
## For developers
|
||||
|
||||
Protocol, ABI, driver and add-on detail — including the version table and any breaking changes — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.27.0/CHANGELOG.md).
|
||||
Protocol, ABI, driver and add-on detail — the version table, the new environment variables and what did *not* move — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.27.0/CHANGELOG.md).
|
||||
|
||||
The short version: **nothing breaks.** The wire protocol stays at 2. The C ABI moves 17 → 18, and it moves by *addition* — one new entry point for four-motor rumble, alongside the existing one, which keeps its signature and the values it reports. An embedder that ignores the new symbol behaves exactly as it does today.
|
||||
|
||||
If you package Punktfunk, one thing changes shape: there is a second installed binary, `punktfunk-encode-worker`, and it is the **only** file that may carry `cap_sys_nice=ep`. It must stay a separate file — never a hardlink to `punktfunk-host`, never a subcommand of it — because a shared inode shares the file capability and re-creates the KDE breakage. `punktfunk-host` carries no capability, on any channel, ever.
|
||||
|
||||
The shared virtual-display and gamepad definitions have also changed for the first time in three releases: three new Xbox controller identities, all additive.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
• The OLED colour scheme is called Eclipse now — the same black-with-a-violet-ember palette, under a name that belongs beside Nebula and Abyss. Your saved choice carries over.
|
||||
• The rest of this release is on the computer you stream from. Once it updates: HDR streams from an NVIDIA machine no longer have red and blue swapped, and Linux hosts hold their frame rate far better under a demanding game.
|
||||
@@ -126,6 +126,7 @@
|
||||
system:
|
||||
let
|
||||
pf = packagesFor system;
|
||||
pkgs = pkgsFor system;
|
||||
in
|
||||
{
|
||||
inherit (pf)
|
||||
@@ -134,6 +135,17 @@
|
||||
punktfunk-web
|
||||
punktfunk-scripting
|
||||
;
|
||||
|
||||
# The NixOS module, actually evaluated. `nix flake check` does NOT do this for
|
||||
# `nixosModules` — it only forces the value and asserts it is a lambda taking an open
|
||||
# attribute set, so a module with a nonexistent option, a nonexistent `pkgs` attribute
|
||||
# and a nonexistent `lib` function passes clean (measured). Routing the module through a
|
||||
# `checks` entry instead means the eval-only CI leg has to instantiate it, and every
|
||||
# assertion in module-check.nix is pure Nix so instantiation is enough to run them.
|
||||
nixos-module = pkgs.callPackage ./packaging/nix/module-check.nix {
|
||||
inherit nixpkgs system;
|
||||
module = import ./packaging/nix/nixos-module.nix;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ The other packaging targets have their own READMEs: [`debian/`](debian/README.md
|
||||
[`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext),
|
||||
[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
|
||||
drivers), plus `kde/` and `linux/` helpers. **NixOS / Nix** users get a flake (`flake.nix` at the
|
||||
repo root) with reproducible host + client packages and a `services.punktfunk` NixOS module —
|
||||
see [`nix/README.md`](nix/README.md).
|
||||
repo root) with reproducible `punktfunk-host`, `-client`, `-web`, `-scripting` and `-gamescope`
|
||||
packages plus a `services.punktfunk` NixOS module — see [`nix/README.md`](nix/README.md).
|
||||
|
||||
## What's needed beyond base Fedora
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ and the native Linux **client**, a **NixOS module** that wires up everything the
|
||||
| `packages.x86_64-linux.default` | = `punktfunk-host` |
|
||||
| `nixosModules.default` | `services.punktfunk.host` / `.client` / `.web` / `.scripting` |
|
||||
| `devShells.x86_64-linux.default` | pinned Rust (from `rust-toolchain.toml`) + all build deps |
|
||||
| `checks.x86_64-linux.nixos-module` | evaluates the NixOS module against real nixpkgs and asserts on the rendered systemd units |
|
||||
| `apps` / `checks` / `formatter` | `nix run`, `nix flake check`, `nix fmt` |
|
||||
|
||||
One binary per GPU vendor: NVENC/CUDA entry points are `dlopen`'d at runtime, so the host runs on
|
||||
@@ -65,6 +66,7 @@ Add the flake and enable the host and/or client:
|
||||
enable = true;
|
||||
users = [ "alice" ]; # → added to the `input` group for virtual gamepads
|
||||
openFirewall = true; # native + GameStream ports
|
||||
desktopSession = true; # a machine you log into — restart the host with the desktop
|
||||
settings = {
|
||||
PUNKTFUNK_VIDEO_SOURCE = "virtual";
|
||||
RUST_LOG = "info";
|
||||
@@ -84,10 +86,11 @@ Add the flake and enable the host and/or client:
|
||||
}
|
||||
```
|
||||
|
||||
Then, in your graphical session:
|
||||
Then, in your graphical session (the console follows with `punktfunk-web`; the plugin runner is
|
||||
already started for you — see `scripting.autoStart` below):
|
||||
|
||||
```sh
|
||||
systemctl --user enable --now punktfunk-host
|
||||
systemctl --user enable --now punktfunk-host punktfunk-web
|
||||
```
|
||||
|
||||
### Options
|
||||
@@ -99,12 +102,35 @@ systemctl --user enable --now punktfunk-host
|
||||
| `enable` | `false` | Install the host + wire udev/sysctl/kernel-modules/firewall and the user service. |
|
||||
| `gamestream` | `true` | `serve --gamestream` (Moonlight-compatible). `false` = native-only, more secure. |
|
||||
| `autoStart` | `false` | Add the user service to `default.target` (appliance mode — pair with lingering). |
|
||||
| `users` | `[ ]` | Users added to the `input` group (virtual gamepads). |
|
||||
| `desktopSession` | `false` | Bind the host to `graphical-session.target` — **turn this on for a machine somebody logs into** (see below). |
|
||||
| `users` | `[ ]` | Users added to the `input` **and `punktfunk`** groups (virtual gamepads; the second covers the usbip/vhci nodes the virtual Steam Deck pad attaches through — it can emulate arbitrary USB hardware, so list only users you'd trust with that). |
|
||||
| `settings` | `{ }` | `host.env` key/values (see `${package}/share/punktfunk-host/host.env.example`). |
|
||||
| `environmentFile` | `null` | Extra `EnvironmentFile` for secrets (e.g. `PUNKTFUNK_MGMT_TOKEN`); loaded optionally. |
|
||||
| `openFirewall` | `false` | Open the inbound ports (see below). |
|
||||
| `gamescopeHdr` | `true` | Put `punktfunk-gamescope` (gamescope + our `pipewire-hdr` patches) on the service PATH, so a 10-bit client can stream true HDR10 off a gamescope output. Costs a gamescope build from source — set `false` to skip it and stay SDR on that backend. |
|
||||
| `gamescopePackage` | flake's | The patched gamescope used when `gamescopeHdr = true`. |
|
||||
| `package` | flake's | Override the package. |
|
||||
|
||||
**`desktopSession` — set it on a desktop, leave it off on an appliance.** On a machine somebody logs
|
||||
into, a compositor restart (a crash, a logout/login, "restart the shell") otherwise leaves the host
|
||||
running while it holds a Wayland socket and a portal D-Bus connection that both died with the old
|
||||
compositor. It cannot recover either in-process, and the failure is *silent*: the host still
|
||||
listens, still answers, and every session it then serves fails at capture. `desktopSession = true`
|
||||
adds `PartOf=`/`WantedBy=graphical-session.target` (in addition to `default.target`), so the host
|
||||
restarts with the session. Leave it `false` for an appliance — a pinned `PUNKTFUNK_COMPOSITOR`, a
|
||||
headless KWin or a gamescope box — which may never reach that target and would be left permanently
|
||||
stopped. sway/Hyprland and anything else not under systemd session management never reach it
|
||||
either; there, start the host from the compositor's config after `systemctl --user
|
||||
import-environment`.
|
||||
|
||||
**Portals.** The host reaches the desktop through `xdg-desktop-portal` on several backends (Mutter's
|
||||
ScreenCast/RemoteDesktop, and the libei input path), so a hand-assembled machine wants
|
||||
`xdg.portal.enable = true` plus the backend for its compositor
|
||||
(`xdg-desktop-portal-kde` / `-gnome` / `-hyprland` / `-wlr`). The KDE and GNOME desktop-manager
|
||||
modules already do this. The module emits a warning if the host is enabled and portals are not —
|
||||
the KWin backend's own virtual output uses the privileged `zkde_screencast` protocol and needs no
|
||||
portal, so KDE-only setups are unaffected in practice.
|
||||
|
||||
`services.punktfunk.client`: `enable`, `openFirewall` (UDP 5353), `package`.
|
||||
|
||||
`services.punktfunk.web` (the management console — **on by default whenever the host is enabled**,
|
||||
@@ -125,39 +151,67 @@ with `journalctl --user -u punktfunk-web-init` (or `~/.config/punktfunk/web-pass
|
||||
`https://<host-ip>:47992` and trust the self-signed host cert once. Enable it (with the host) via
|
||||
`systemctl --user enable --now punktfunk-web`.
|
||||
|
||||
`services.punktfunk.scripting` (the plugin/script runner — installed with the host, but **opt-in to
|
||||
run**):
|
||||
`services.punktfunk.scripting` (the plugin/script runner — installed **and started** with the host,
|
||||
matching the deb/rpm, which `systemctl --global enable` it):
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `enable` | `host.enable` | Install the runner + define its `systemd --user` unit `punktfunk-scripting`. |
|
||||
| `autoStart` | `false` | Add the unit to `default.target`. Off even on an auto-start host — running operator scripts/plugins is a deliberate opt-in. |
|
||||
| `autoStart` | `scripting.enable` | Add the unit to `default.target`. **On by default** — the game-library scanners are plugins, so a host without the runner has an empty library. |
|
||||
| `package` | flake's | Override the package. |
|
||||
|
||||
The runner discovers loose scripts under `~/.config/punktfunk/scripts` and installed
|
||||
`punktfunk-plugin-*` packages under `~/.config/punktfunk/plugins`, and supervises each as an Effect
|
||||
fiber (SIGTERM shuts the tree down structurally so plugin finalizers run). A plugin auto-wires to
|
||||
the host's mgmt token + identity cert. It's inert until you add automation, so the unit ships
|
||||
un-started; turn it on with `systemctl --user enable --now punktfunk-scripting`.
|
||||
the host's mgmt token + identity cert.
|
||||
|
||||
It used to ship un-started here, on the reasoning that the runner is inert until you add
|
||||
automation. That stopped being true when the library scanners became plugins — a host with the
|
||||
runner off comes up with an empty library and no obvious reason why — so it now runs by default,
|
||||
as it already did on every other channel. Opt out with `scripting.autoStart = false`, or per user
|
||||
`systemctl --user mask punktfunk-scripting` (`mask`, not `disable`).
|
||||
|
||||
The runner is sandboxed exactly as the deb/rpm unit is (`NoNewPrivileges`, `ProtectSystem=strict`,
|
||||
`ReadWritePaths=%h /tmp`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`) — with `PrivateTmp`
|
||||
deliberately **off**, because plugins integrate with things that talk over `/tmp`. `ProtectSystem`
|
||||
on a *user* unit needs unprivileged user namespaces; drop it with
|
||||
`systemctl --user edit punktfunk-scripting` on a kernel that restricts them.
|
||||
|
||||
### What the host module configures for you
|
||||
|
||||
Everything the RPM's `%install` + `%post` do, declaratively:
|
||||
|
||||
- **systemd `--user` service** `punktfunk-host` → `serve [--gamestream]`, `EnvironmentFile` from
|
||||
`settings` (+ optional secret file), `Restart=on-failure`.
|
||||
`settings` (+ optional secret file), `Restart=on-failure`, and — with `desktopSession` —
|
||||
`PartOf=graphical-session.target`.
|
||||
- **udev rules** (`60-punktfunk.rules`): `/dev/uinput` + `/dev/uhid` group access and the vhci
|
||||
sysfs perms for the virtual Steam Deck.
|
||||
- **kernel modules**: `uinput`, `uhid`, `vhci-hcd` (usbip transport so Steam Input adopts the
|
||||
virtual Deck).
|
||||
- **sysctl**: `net.core.{r,w}mem_max = 32 MB` (high-bitrate UDP headroom; `mkDefault`).
|
||||
- **`input` group** membership for `users`.
|
||||
- **`input` and `punktfunk` groups**, declared and joined for `users`. Both are required: the udev
|
||||
rule `chgrp punktfunk`s the vhci nodes and fails outright if nothing ever created that group.
|
||||
- **A `security.wrappers` entry for `punktfunk-encode-worker`** carrying `cap_sys_nice=ep`, with
|
||||
`PUNKTFUNK_ENCODE_WORKER` pointed at it. A file capability cannot live on a read-only store path,
|
||||
so a wrapper is the only mechanism NixOS has. The capability is deliberately **not** on the host
|
||||
itself — see the caveat below.
|
||||
- **`hardware.graphics.enable = true`** (`mkDefault`) so `/run/opengl-driver/lib` has the driver
|
||||
libs the binaries `dlopen`.
|
||||
- **firewall** (when `openFirewall`): native UDP 9777/5353 + TCP 47990; with `gamestream` also TCP
|
||||
47984/47989/48010 + UDP 47998/47999/48000. The media data plane is an ephemeral, hole-punched
|
||||
UDP port — nothing fixed to open.
|
||||
47984/47989/48010 + UDP 47998/47999/48000; with the console, TCP 47992 + 47993. The media data
|
||||
plane is an ephemeral, hole-punched UDP port — nothing fixed to open.
|
||||
- **tray autostart** entry (`--autostart`; self-gates to users who actually run a host).
|
||||
- **A warning** if `xdg.portal.enable` is off (see the portal note above).
|
||||
|
||||
> **Why the capability is on the worker and not the host.** KWin only advertises its restricted
|
||||
> protocols (`zkde_screencast_unstable_v1` for the virtual output, `org_kde_kwin_fake_input` for
|
||||
> input) to a client it can *identify*, by resolving that client's `/proc/<pid>/exe` and matching an
|
||||
> installed `.desktop`'s `Exec=`. The kernel refuses that readlink to any reader whose effective set
|
||||
> is not a superset of the target's permitted set, and KWin holds no capabilities. A NixOS wrapper
|
||||
> does not dodge this — it raises the capability into the ambient set before exec'ing, which lands
|
||||
> it in the permitted set and fails the readlink identically. Giving the host `cap_sys_nice` broke
|
||||
> desktop streaming on every KDE box in 0.26.0-1. The encode worker is a separate binary that
|
||||
> nothing ever has to identify, so the grant is safe there.
|
||||
|
||||
### GPU drivers (out of scope of the module — set these yourself)
|
||||
|
||||
@@ -180,8 +234,15 @@ services.punktfunk.host = {
|
||||
settings = { PUNKTFUNK_COMPOSITOR = "gamescope"; }; # appliance-only; omit to auto-detect
|
||||
};
|
||||
users.users.streamer.linger = true;
|
||||
# For the gamescope/KWin backends extend the service PATH, e.g.:
|
||||
# systemd.user.services.punktfunk-host.path = [ pkgs.gamescope ];
|
||||
```
|
||||
|
||||
Leave `desktopSession` off here — an appliance starts its own compositor and may never reach
|
||||
`graphical-session.target`, which would leave the host permanently stopped. `gamescopeHdr` (on by
|
||||
default) already puts the patched `punktfunk-gamescope` on the service PATH, so the gamescope
|
||||
backend needs no PATH surgery; extend it only for a helper the module doesn't know about:
|
||||
|
||||
```nix
|
||||
# systemd.user.services.punktfunk-host.path = [ pkgs.some-helper ];
|
||||
```
|
||||
|
||||
The `${package}/share/punktfunk-host/headless/` helpers (KDE/Sway session scripts, example
|
||||
@@ -278,10 +339,30 @@ The shell exports an
|
||||
to consume a prebuilt Skia offline (a fixed-output derivation of the rust-skia tarball) or a
|
||||
vendored from-source Skia build — a tracked follow-up.
|
||||
|
||||
- **⚠ `nix flake check` does NOT check the NixOS module — that is why `module-check.nix` exists.**
|
||||
For `nixosModules`, nix forces the value and asserts it is a lambda taking an open attribute set,
|
||||
and stops there (its source still carries `// 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 'nixosModules.default'... all checks passed!`. So the reassuring line means
|
||||
nothing. `checks.<system>.nixos-module` (`packaging/nix/module-check.nix`) closes it: it evaluates
|
||||
the module against real nixpkgs in four scenarios and asserts on the rendered systemd units.
|
||||
Two rules if you edit it — **keep every assertion pure Nix** (instantiating the derivation is what
|
||||
runs them, which is what lets the cheap `--no-build` CI leg cover it; a shell script in the
|
||||
`runCommand` body would only run under a full `nix flake check`, i.e. an hour of Rust), and
|
||||
**assert list-valued unit fields on the evaluated lists**, not the rendered text — systemd renders
|
||||
`After=` as one space-separated line, so an `hasInfix` on it silently depends on ordering.
|
||||
|
||||
## Verified
|
||||
|
||||
Both packages build, install, and run on real Nix hardware (NixOS-equivalent: CachyOS + Nix,
|
||||
The packages build, install, and run on real Nix hardware (NixOS-equivalent: CachyOS + Nix,
|
||||
RTX 5070 Ti, driver 610). `punktfunk-host --version` and `punktfunk-session` run; the driver
|
||||
RUNPATH (`/run/opengl-driver/lib`) and the GTK GApps wrapper (GSettings schemas + pixbuf loaders)
|
||||
are present. Fixes discovered during that bring-up: `CMAKE_POLICY_VERSION_MINIMUM=3.5` (CMake ≥ 4),
|
||||
system `libopus` (audiopus_sys), and the session Skia note above.
|
||||
|
||||
In CI (`.gitea/workflows/nix.yml`): `nix flake check --no-build` evaluates every output *including*
|
||||
the module check above, and `punktfunk-web` + `punktfunk-scripting` are built for real. The Rust
|
||||
packages and `punktfunk-gamescope` are `workflow_dispatch` opt-ins (`build-rust`,
|
||||
`build-gamescope`) — run the latter after a `flake.lock` bump, since it patches whatever gamescope
|
||||
the pinned nixpkgs carries.
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# Does the NixOS module actually evaluate, and does it still render the units we decided on?
|
||||
#
|
||||
# WHY THIS FILE EXISTS. `nix flake check` does NOT check `nixosModules`. It forces the value and
|
||||
# asserts it is a lambda taking an open attribute set — nothing more; nix's own source carries
|
||||
# `// FIXME: if we have a 'nixpkgs' input, use it to check the module.` MEASURED: a flake whose
|
||||
# `nixosModules.default` sets a nonexistent OPTION, references a nonexistent `pkgs` attribute AND
|
||||
# calls a nonexistent `lib` function passes clean, printing the thoroughly reassuring
|
||||
#
|
||||
# checking NixOS module 'nixosModules.default'... all checks passed!
|
||||
#
|
||||
# So every option name, type, `pkgs.*` reference and systemd directive in nixos-module.nix was
|
||||
# unverified by CI while reading as covered — on a flake whose own history is Nix regressions
|
||||
# reaching main invisibly (`nix build .#punktfunk-web` was broken for 553 commits).
|
||||
#
|
||||
# HOW IT CLOSES THAT. Exposed as a flake `checks` output, so `nix flake check --no-build` — the leg
|
||||
# CI already runs — must INSTANTIATE it, and instantiating forces the `assert` below. Every
|
||||
# assertion is therefore pure Nix, evaluated at instantiation: a shell script inside the derivation
|
||||
# would only run under a full `nix flake check`, which builds the hour-long Rust packages and is
|
||||
# exactly what CI cannot afford. Keep it that way — if you add a check, add it to `results`, not to
|
||||
# a `runCommand` body.
|
||||
#
|
||||
# STUB PACKAGES, on purpose. The real derivations would drag punktfunk-host, punktfunk-client and
|
||||
# (via `gamescopeHdr`) a from-source gamescope into this check's closure, making the cheap leg
|
||||
# expensive and coupling a module regression to a Rust build. What is under test here is the MODULE.
|
||||
# ⚠ The stubs must be fake DERIVATIONS, not store-path strings: `types.package` accepts anything
|
||||
# `isDerivation` as-is, but runs a store-path string through `builtins.storePath`, which demands the
|
||||
# path actually exist and fails eval with "no substituter can build it".
|
||||
{
|
||||
lib,
|
||||
runCommand,
|
||||
# The nixpkgs SOURCE. Interpolated rather than `nixpkgs + "/..."` because this is called with the
|
||||
# flake INPUT, which is an attribute set (coerced through its `outPath`) and not a path — the
|
||||
# difference only shows up as "expected a set but found a string" from whichever side is wrong.
|
||||
nixpkgs,
|
||||
system,
|
||||
module,
|
||||
}:
|
||||
let
|
||||
fakeDrv = name: {
|
||||
type = "derivation";
|
||||
inherit name;
|
||||
outPath = "/pf-stub/${name}";
|
||||
outputs = [ "out" ];
|
||||
};
|
||||
|
||||
stubSelf = {
|
||||
packages.${system} = lib.genAttrs [
|
||||
"punktfunk-host"
|
||||
"punktfunk-client"
|
||||
"punktfunk-web"
|
||||
"punktfunk-scripting"
|
||||
"punktfunk-gamescope"
|
||||
] fakeDrv;
|
||||
};
|
||||
|
||||
# A machine just complete enough for eval-config, plus the scenario under test.
|
||||
evalWith =
|
||||
scenario:
|
||||
(import "${nixpkgs}/nixos/lib/eval-config.nix" {
|
||||
inherit system;
|
||||
modules = [
|
||||
(module stubSelf)
|
||||
{
|
||||
boot.loader.grub.enable = false;
|
||||
fileSystems."/" = {
|
||||
device = "/dev/sda1";
|
||||
fsType = "ext4";
|
||||
};
|
||||
system.stateVersion = "24.11";
|
||||
nixpkgs.hostPlatform = system;
|
||||
# `host.users` adds group membership to an existing user; declare one so NixOS's own
|
||||
# "isNormalUser or isSystemUser" assertion is not what this check trips over.
|
||||
users.users.alice.isNormalUser = true;
|
||||
}
|
||||
scenario
|
||||
];
|
||||
}).config;
|
||||
|
||||
# Every scenario keeps `gamescopeHdr = false`: it is the one option whose default would pull a
|
||||
# real (stub, here) gamescope onto the unit PATH, and nothing below is about that.
|
||||
desktop = evalWith {
|
||||
services.punktfunk.host = {
|
||||
enable = true;
|
||||
users = [ "alice" ];
|
||||
gamescopeHdr = false;
|
||||
desktopSession = true;
|
||||
};
|
||||
};
|
||||
|
||||
appliance = evalWith {
|
||||
services.punktfunk.host = {
|
||||
enable = true;
|
||||
autoStart = true;
|
||||
openFirewall = true;
|
||||
gamescopeHdr = false;
|
||||
};
|
||||
};
|
||||
|
||||
nativeOnly = evalWith {
|
||||
services.punktfunk.host = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
gamestream = false;
|
||||
gamescopeHdr = false;
|
||||
};
|
||||
};
|
||||
|
||||
clientOnly = evalWith { services.punktfunk.client.enable = true; };
|
||||
|
||||
unit = cfg: name: cfg.systemd.user.units."${name}.service".text;
|
||||
has =
|
||||
cfg: name: infix:
|
||||
lib.hasInfix infix (unit cfg name);
|
||||
# A module's own failed assertions, as messages.
|
||||
failedAssertions = cfg: map (a: a.message) (lib.filter (a: !a.assertion) cfg.assertions);
|
||||
|
||||
results = [
|
||||
# --- the module evaluates at all, in every shape an operator can ask for -------------------
|
||||
{
|
||||
name = "desktop scenario has no failing assertions";
|
||||
ok = failedAssertions desktop == [ ];
|
||||
}
|
||||
{
|
||||
name = "appliance scenario has no failing assertions";
|
||||
ok = failedAssertions appliance == [ ];
|
||||
}
|
||||
{
|
||||
name = "client-only scenario has no failing assertions";
|
||||
ok = failedAssertions clientOnly == [ ];
|
||||
}
|
||||
|
||||
# --- the KWin identification trap (packaging/arch/punktfunk-host.install) -------------------
|
||||
# The host MUST exec the plain store path. A capability wrapper here would put CAP_SYS_NICE in
|
||||
# the process's permitted set, and the kernel then refuses KWin the /proc/<pid>/exe readlink it
|
||||
# identifies the client by — which cost every KDE box its desktop streaming in 0.26.0-1.
|
||||
{
|
||||
name = "host ExecStart is the store binary, never a capability wrapper";
|
||||
ok =
|
||||
has desktop "punktfunk-host" "ExecStart=/pf-stub/punktfunk-host/bin/punktfunk-host serve"
|
||||
&& !(has desktop "punktfunk-host" "ExecStart=/run/wrappers");
|
||||
}
|
||||
# ...while the ENCODE WORKER, which nothing ever has to identify, is pointed at the wrapper.
|
||||
{
|
||||
name = "host points PUNKTFUNK_ENCODE_WORKER at the capability wrapper";
|
||||
ok =
|
||||
has desktop "punktfunk-host"
|
||||
"PUNKTFUNK_ENCODE_WORKER=/run/wrappers/bin/punktfunk-encode-worker";
|
||||
}
|
||||
{
|
||||
name = "the encode-worker wrapper carries exactly cap_sys_nice=ep";
|
||||
ok = desktop.security.wrappers.punktfunk-encode-worker.capabilities == "cap_sys_nice=ep";
|
||||
}
|
||||
|
||||
# --- the desktop-login route (scripts/punktfunk-host-desktop-session.conf) -----------------
|
||||
# Asserted on the evaluated LISTS, not the rendered text: systemd renders `After=` as one
|
||||
# space-separated line, so `hasInfix "After=graphical-session.target"` silently depends on
|
||||
# ordering — it failed against a correct module the first time this check ran.
|
||||
{
|
||||
name = "desktopSession binds the host to graphical-session.target";
|
||||
ok =
|
||||
let
|
||||
u = desktop.systemd.user.services.punktfunk-host;
|
||||
in
|
||||
lib.elem "graphical-session.target" u.after
|
||||
&& lib.elem "graphical-session.target" u.partOf
|
||||
# IN ADDITION to default.target, never instead of it.
|
||||
&& lib.elem "graphical-session.target" u.wantedBy;
|
||||
}
|
||||
{
|
||||
name = "desktopSession is NOT applied to the appliance route (it would stay stopped there)";
|
||||
ok =
|
||||
let
|
||||
u = appliance.systemd.user.services.punktfunk-host;
|
||||
in
|
||||
!(lib.elem "graphical-session.target" u.partOf) && lib.elem "default.target" u.wantedBy;
|
||||
}
|
||||
|
||||
# --- GameStream opt-out reaches both the argv and the firewall -----------------------------
|
||||
{
|
||||
name = "gamestream=true passes --gamestream";
|
||||
ok = has desktop "punktfunk-host" "serve --gamestream";
|
||||
}
|
||||
{
|
||||
name = "gamestream=false drops --gamestream and its firewall ports";
|
||||
ok =
|
||||
!(has nativeOnly "punktfunk-host" "--gamestream")
|
||||
&& !(lib.elem 47984 nativeOnly.networking.firewall.allowedTCPPorts)
|
||||
&& lib.elem 47990 nativeOnly.networking.firewall.allowedTCPPorts;
|
||||
}
|
||||
{
|
||||
name = "openFirewall opens the console AND its plugin origin";
|
||||
ok =
|
||||
lib.elem 47992 appliance.networking.firewall.allowedTCPPorts
|
||||
&& lib.elem 47993 appliance.networking.firewall.allowedTCPPorts;
|
||||
}
|
||||
|
||||
# --- the three divergences from the shipped units, as regression guards --------------------
|
||||
# Each of these was ONCE wrong here while right in scripts/*.service. Assert the decision, so a
|
||||
# future edit cannot quietly drift back.
|
||||
{
|
||||
# Without this, systemd's default 5-starts-per-10s against RestartSec=2 gives up permanently
|
||||
# after ~10 s — the exact window before the host's first `serve` writes the mgmt token.
|
||||
name = "web console retries indefinitely while the host writes its mgmt token";
|
||||
ok = has appliance "punktfunk-web" "StartLimitIntervalSec=0";
|
||||
}
|
||||
{
|
||||
# A console that exits 0 has still stopped serving.
|
||||
name = "web console restarts on ANY exit, not just failure";
|
||||
ok =
|
||||
has appliance "punktfunk-web" "Restart=always"
|
||||
&& !(has appliance "punktfunk-web" "Restart=on-failure");
|
||||
}
|
||||
{
|
||||
# The one unit here that runs arbitrary operator TypeScript by design.
|
||||
name = "the plugin runner is sandboxed like the deb/rpm unit";
|
||||
ok =
|
||||
has appliance "punktfunk-scripting" "NoNewPrivileges=true"
|
||||
&& has appliance "punktfunk-scripting" "ProtectSystem=strict"
|
||||
&& has appliance "punktfunk-scripting" "ReadWritePaths=%h"
|
||||
&& has appliance "punktfunk-scripting" "ReadWritePaths=/tmp"
|
||||
&& has appliance "punktfunk-scripting" "RestrictAddressFamilies=AF_UNIX";
|
||||
}
|
||||
{
|
||||
# PrivateTmp is OFF on purpose (the VirtualHere field report: a private /tmp hides
|
||||
# /tmp/vhclient and /tmp/.X11-unix, so a plugin cannot reach the daemon it integrates with).
|
||||
name = "the plugin runner keeps the real /tmp";
|
||||
ok = has appliance "punktfunk-scripting" "PrivateTmp=false";
|
||||
}
|
||||
{
|
||||
# Since the library scanners became plugins, a runner that is off means an EMPTY LIBRARY and
|
||||
# no obvious reason why — which is why deb+rpm `systemctl --global enable` it.
|
||||
name = "the plugin runner is started by default, like every other channel";
|
||||
ok = has appliance "punktfunk-scripting" "WantedBy=default.target";
|
||||
}
|
||||
|
||||
# --- the client half must not drag the host's system wiring in -----------------------------
|
||||
{
|
||||
name = "a client-only machine defines no host/web/scripting units";
|
||||
ok =
|
||||
!(clientOnly.systemd.user.services ? punktfunk-host)
|
||||
&& !(clientOnly.systemd.user.services ? punktfunk-web)
|
||||
&& !(clientOnly.systemd.user.services ? punktfunk-scripting);
|
||||
}
|
||||
];
|
||||
|
||||
failures = map (r: r.name) (lib.filter (r: !r.ok) results);
|
||||
in
|
||||
# The `assert` is what makes `--no-build` sufficient: instantiating this derivation forces it.
|
||||
assert
|
||||
failures == [ ]
|
||||
|| throw ''
|
||||
The punktfunk NixOS module no longer renders what packaging/nix/module-check.nix requires.
|
||||
Failing checks (${toString (lib.length failures)} of ${toString (lib.length results)}):
|
||||
- ${lib.concatStringsSep "\n - " failures}
|
||||
'';
|
||||
runCommand "punktfunk-nixos-module-check"
|
||||
{
|
||||
# Recorded in the output so a green run says what it actually covered.
|
||||
passed = toString (lib.length results);
|
||||
}
|
||||
''
|
||||
echo "punktfunk NixOS module: $passed checks passed at eval time" > "$out"
|
||||
''
|
||||
@@ -3,8 +3,8 @@
|
||||
# the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB
|
||||
# UDP socket-buffer sysctls, the firewall openers, the `input`- and `punktfunk`-group membership
|
||||
# for virtual gamepads, the management web console (`services.punktfunk.web`, on by default with
|
||||
# the host — the RPM/deb Recommends), and the opt-in plugin/script runner
|
||||
# (`services.punktfunk.scripting`).
|
||||
# the host — the RPM/deb Recommends), and the plugin/script runner
|
||||
# (`services.punktfunk.scripting`, likewise on by default — the game-library scanners are plugins).
|
||||
#
|
||||
# Usage (flake):
|
||||
# { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
|
||||
@@ -107,6 +107,34 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
desktopSession = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Bind the host to the DESKTOP LOGIN session's lifetime
|
||||
(`PartOf=`/`WantedBy=graphical-session.target`), so a Plasma/GNOME restart restarts the
|
||||
host with it.
|
||||
|
||||
Turn this on for a machine somebody logs into. Without it, when the compositor restarts
|
||||
(a crash, a logout/login, "restart the shell") the host keeps running while holding a
|
||||
Wayland socket and a portal D-Bus connection that both died with the old compositor. It
|
||||
cannot recover either in-process, and the failure is silent: the host still listens,
|
||||
still answers, and every session it then serves fails at capture. A host that idles for
|
||||
days between sessions is exactly the shape that gets discovered at the worst moment.
|
||||
|
||||
Leave it OFF for an appliance — a pinned `PUNKTFUNK_COMPOSITOR`, a headless KWin or a
|
||||
gamescope box. Those start their own compositor and may never reach
|
||||
`graphical-session.target` at all, and this would leave the host permanently stopped.
|
||||
|
||||
No effect under sway/Hyprland or any session not managed by systemd (they never reach
|
||||
that target either): there, start the host from the compositor's own config, after
|
||||
`systemctl --user import-environment`, so it dies and comes back with the session.
|
||||
|
||||
This is the declarative equivalent of the `scripts/punktfunk-host-desktop-session.conf`
|
||||
drop-in the deb/rpm document for the same route.
|
||||
'';
|
||||
};
|
||||
|
||||
users = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
@@ -257,9 +285,9 @@ in
|
||||
};
|
||||
|
||||
# The plugin/script runner — host automation on bun. Ships with the host (the RPM/deb Recommends
|
||||
# it), but running it is OPT-IN: the `systemd --user` unit is defined yet NOT added to
|
||||
# `default.target`, because the runner is inert until you add scripts/plugins. Turn it on with
|
||||
# `systemctl --user enable --now punktfunk-scripting`.
|
||||
# it) and, like them, runs by default: the game-library scanners are plugins, so a host with the
|
||||
# runner off has an empty library. Opt out with `scripting.autoStart = false` or, per user,
|
||||
# `systemctl --user mask punktfunk-scripting`.
|
||||
scripting = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
@@ -267,9 +295,10 @@ in
|
||||
defaultText = literalExpression "config.services.punktfunk.host.enable";
|
||||
description = ''
|
||||
Install the plugin/script runner and define its `systemd --user` unit
|
||||
(`punktfunk-scripting`). Enabled by default whenever the host is — but the unit is not
|
||||
auto-started (see `autoStart`), since the runner does nothing until you add scripts to
|
||||
`~/.config/punktfunk/scripts` or install `punktfunk-plugin-*` packages under
|
||||
(`punktfunk-scripting`). Enabled by default whenever the host is, and started by default
|
||||
too (see `autoStart`) — the game-library scanners are plugins, so a host without the
|
||||
runner has an empty library. It also runs whatever you put in
|
||||
`~/.config/punktfunk/scripts` or install as `punktfunk-plugin-*` under
|
||||
`~/.config/punktfunk/plugins`. A plugin auto-wires to the host's mgmt token + identity cert.
|
||||
'';
|
||||
};
|
||||
@@ -283,11 +312,21 @@ in
|
||||
|
||||
autoStart = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
default = cfg.scripting.enable;
|
||||
defaultText = literalExpression "config.services.punktfunk.scripting.enable";
|
||||
description = ''
|
||||
Start the runner automatically in every user's graphical session (adds it to the user
|
||||
`default.target`). Off by default even when the host auto-starts — running arbitrary
|
||||
operator scripts/plugins is a deliberate opt-in; enable it once you have automation to run.
|
||||
`default.target`).
|
||||
|
||||
ON by default, matching every other channel: the deb postinst and the RPM `%post` both
|
||||
run `systemctl --global enable punktfunk-scripting.service`, and the sysext image bakes
|
||||
in the `default.target.wants` symlink. It used to be opt-in here, on the reasoning that
|
||||
the runner does nothing until you add scripts or plugins — that stopped being true when
|
||||
the game-library scanners became plugins. A host whose runner is off now comes up with an
|
||||
empty library and no obvious reason why (design/library-scanner-plugins.md D9).
|
||||
|
||||
It remains opt-OUT: set this to `false`, or per user
|
||||
`systemctl --user mask punktfunk-scripting`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
@@ -305,6 +344,23 @@ in
|
||||
# The GPU driver libs the binaries dlopen at runtime (libcuda / libnvidia-encode / libEGL /
|
||||
# the Vulkan ICD) live under /run/opengl-driver/lib — provided by hardware.graphics.
|
||||
hardware.graphics.enable = mkDefault true;
|
||||
|
||||
# A WARNING, not `xdg.portal.enable = mkDefault true`: enabling the portal service without an
|
||||
# `extraPortals` backend is its own broken state, and only the operator knows which backend
|
||||
# their compositor needs. The desktop-manager modules (plasma6, gnome) already wire theirs, so
|
||||
# this fires exactly where it should — a headless/appliance or sway/Hyprland box assembled by
|
||||
# hand. It matters because the host reaches the desktop through portals on several backends:
|
||||
# Mutter's virtual output is ashpd ScreenCast/RemoteDesktop, and the libei input path's own
|
||||
# error message is "is xdg-desktop-portal-kde/gnome running and XDG_CURRENT_DESKTOP set?".
|
||||
warnings = optional (cfg.host.enable && !config.xdg.portal.enable) ''
|
||||
services.punktfunk.host is enabled but xdg.portal.enable is false. The host drives the
|
||||
compositor through xdg-desktop-portal on several backends (Mutter's ScreenCast/RemoteDesktop
|
||||
and the libei input path), so capture or input will fail there with a portal error. Set
|
||||
xdg.portal.enable = true and add the backend for your compositor, e.g.
|
||||
xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-kde ] # or -gnome / -hyprland / -wlr
|
||||
(the KDE and GNOME desktop-manager modules already do this for you). The KWin backend's own
|
||||
virtual output uses the privileged zkde_screencast protocol and needs no portal.
|
||||
'';
|
||||
# 32 MB UDP socket buffers — without this the kernel clamps the host's SO_SNDBUF / client's
|
||||
# SO_RCVBUF and high-bitrate frames overflow (measured: 4 MB cap = 31.6 % loss at 2 Gbps).
|
||||
boot.kernel.sysctl = {
|
||||
@@ -409,9 +465,17 @@ in
|
||||
description = "punktfunk GameStream + punktfunk/1 streaming host";
|
||||
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||
# Soft ordering: the host listens immediately and only touches the compositor per session.
|
||||
after = [ "pipewire.service" ];
|
||||
after = [ "pipewire.service" ] ++ optional cfg.host.desktopSession "graphical-session.target";
|
||||
wants = [ "pipewire.service" ];
|
||||
wantedBy = optional cfg.host.autoStart "default.target";
|
||||
# `graphical-session.target` is IN ADDITION to `default.target`, never instead of it: the
|
||||
# host still comes up at login before the graphical session is ready — it listens without
|
||||
# touching the compositor and only opens one per client connect, so an early start costs
|
||||
# nothing. `partOf` is the half that matters, taking the host down with the session so the
|
||||
# next one gets a fresh compositor connection (see `desktopSession`).
|
||||
partOf = optional cfg.host.desktopSession "graphical-session.target";
|
||||
wantedBy =
|
||||
optional cfg.host.autoStart "default.target"
|
||||
++ optional cfg.host.desktopSession "graphical-session.target";
|
||||
# The host may exec external helpers (pw-dump, sh, and — for the gamescope/kwin backends —
|
||||
# the compositor). Extend this in your config for a headless gamescope/KWin appliance.
|
||||
path = [
|
||||
@@ -477,7 +541,10 @@ in
|
||||
# policy keeps a plugin from acting as the logged-in operator. Leaving it closed does not
|
||||
# degrade gracefully — every plugin interface is simply an empty panel from any other device.
|
||||
# Keep in step with packaging/linux/punktfunk-web.xml and punktfunk.ufw.
|
||||
allowedTCPPorts = [ 47992 47993 ];
|
||||
allowedTCPPorts = [
|
||||
47992
|
||||
47993
|
||||
];
|
||||
};
|
||||
|
||||
# First-run setup: generate the console login password once, in the user's config dir, and
|
||||
@@ -508,6 +575,15 @@ in
|
||||
];
|
||||
wants = [ "punktfunk-web-init.service" ];
|
||||
wantedBy = optional cfg.web.autoStart "default.target";
|
||||
# Retry INDEFINITELY while the host is still writing the mgmt token + identity cert. The
|
||||
# EnvironmentFile below is mandatory on purpose, so the unit genuinely fails until those
|
||||
# exist — and systemd's default rate limit (5 starts / 10 s) against `RestartSec = 2` gives
|
||||
# up permanently after ~10 s, which on an appliance is exactly the window before the host's
|
||||
# first `serve` completes. A console enabled before the host's first run then stayed dead
|
||||
# until someone restarted it by hand. The shipped unit (scripts/punktfunk-web.service) has
|
||||
# carried this since that defect was found; it was missed in the port, while the comment
|
||||
# below went on promising the behaviour it removes.
|
||||
unitConfig.StartLimitIntervalSec = 0;
|
||||
environment = {
|
||||
PUNKTFUNK_MGMT_URL = "https://127.0.0.1:47990";
|
||||
PORT = "47992";
|
||||
@@ -525,7 +601,11 @@ in
|
||||
"-%h/.config/punktfunk/web-password"
|
||||
];
|
||||
ExecStart = "${cfg.web.package}/bin/punktfunk-web-server";
|
||||
Restart = "on-failure";
|
||||
# `always`, not `on-failure`: a console that exits 0 has still stopped serving, and
|
||||
# `on-failure` would leave it down. An explicit `systemctl --user stop` is still honoured
|
||||
# (Restart= never fights that). Matches scripts/punktfunk-web.service and the Windows
|
||||
# web-run.cmd, both of which relaunch bun on ANY exit.
|
||||
Restart = "always";
|
||||
RestartSec = 2;
|
||||
};
|
||||
};
|
||||
@@ -554,6 +634,38 @@ in
|
||||
KillMode = "mixed";
|
||||
KillSignal = "SIGTERM";
|
||||
TimeoutStopSec = 30;
|
||||
|
||||
# Sandbox — the same confinement scripts/punktfunk-scripting.service gives the deb/rpm
|
||||
# installs. The runner `import()`s the operator's own `.ts` files, so this is the one unit
|
||||
# here that executes arbitrary code by design; without these it ran strictly LESS confined
|
||||
# on NixOS than on every other channel. Read-only outside $HOME, no setuid re-escalation,
|
||||
# and only the address families automation actually uses (loopback mgmt API, LAN/IPv6
|
||||
# webhooks, unix sockets).
|
||||
NoNewPrivileges = true;
|
||||
# PrivateTmp deliberately OFF (field report 2026-08-03, the VirtualHere plugin). A
|
||||
# plugin's whole job is integrating with things already running on this box, and on Linux
|
||||
# those talk over /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient +
|
||||
# /tmp/vhclient_response, X11 is /tmp/.X11-unix. A private /tmp hides all of it — the
|
||||
# plugin launches the vendor binary fine and then cannot reach the daemon behind it,
|
||||
# which presents as an error no amount of config fixes.
|
||||
PrivateTmp = false;
|
||||
ProtectSystem = "strict";
|
||||
# ReadWritePaths puts back the write bit ProtectSystem=strict takes away: plugin state and
|
||||
# ~/.config/punktfunk under $HOME, plus the /tmp above. A plugin that must write OUTSIDE
|
||||
# $HOME (a game library on another mount) gets it with
|
||||
# systemctl --user edit punktfunk-scripting → [Service] ReadWritePaths=/mnt/games
|
||||
# ⚠ ProtectSystem is a MOUNT-NAMESPACE option, and for a *user* unit that needs
|
||||
# unprivileged user namespaces. On a kernel/config that restricts those it fails the unit
|
||||
# rather than degrading — drop it via the same drop-in if this box is one of them.
|
||||
ReadWritePaths = [
|
||||
"%h"
|
||||
"/tmp"
|
||||
];
|
||||
RestrictAddressFamilies = [
|
||||
"AF_UNIX"
|
||||
"AF_INET"
|
||||
"AF_INET6"
|
||||
];
|
||||
};
|
||||
};
|
||||
})
|
||||
|
||||
@@ -65,10 +65,6 @@ export interface LibraryPluginDef<S extends Schema.Top> {
|
||||
* every reconcile, so toggling one in config takes effect on the next sync. Emit them with
|
||||
* `role: "launcher"`; the kit does not stamp it for you, because a plugin may legitimately want
|
||||
* an entry that opens a launcher but still lists as an ordinary game.
|
||||
*
|
||||
* Give each one an `icon` too — the token for its brand mark (`ProviderEntry.icon`). Without it
|
||||
* the tile falls back to the launcher's name on a flat accent face, which is legible but is the
|
||||
* blandest thing in the grid.
|
||||
*/
|
||||
readonly launchers?: (cfg: S["Type"]) => ReadonlyArray<ProviderEntry>;
|
||||
/** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */
|
||||
|
||||
@@ -60,26 +60,6 @@ export type LaunchSpec = typeof LaunchSpec.Type;
|
||||
export const GameRole = Schema.Literals(["game", "launcher"]);
|
||||
export type GameRole = typeof GameRole.Type;
|
||||
|
||||
/**
|
||||
* The brand marks the shipped clients draw for a launcher tile. A plugin puts one of these in an
|
||||
* entry's `icon` and every client resolves it against the art it bundles
|
||||
* (`assets/launcher-icons` — provenance and licensing in that directory's README).
|
||||
*
|
||||
* Not a union type on purpose, exactly like {@link LaunchSpec}'s `kind`: a client that has never
|
||||
* heard of a token falls back to naming the launcher on an accent face — which is what every
|
||||
* launcher tile looked like before icons existed — so a plugin naming a mark a *newer* client
|
||||
* ships must not fail to typecheck against an older kit.
|
||||
*/
|
||||
export const LAUNCHER_ICONS = [
|
||||
"steam",
|
||||
"lutris",
|
||||
"heroic",
|
||||
"playnite",
|
||||
"epic",
|
||||
"gog",
|
||||
"xbox",
|
||||
] as const;
|
||||
|
||||
export const PrepStep = Schema.Struct({
|
||||
do: Schema.String,
|
||||
undo: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||
@@ -162,27 +142,6 @@ export const ProviderEntry = Schema.Struct({
|
||||
detect: Schema.optionalKey(DetectHint),
|
||||
/** `"game"` (default) or `"launcher"` — see {@link GameRole}. */
|
||||
role: Schema.optionalKey(GameRole),
|
||||
/**
|
||||
* Which brand mark a client should draw for this entry — a **token** ({@link LAUNCHER_ICONS}),
|
||||
* never image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`; the host rejects anything else.
|
||||
*
|
||||
* This is what makes a launcher tile look like its launcher. Launcher entries ship no cover art
|
||||
* by design — a launcher's own icon is square, clients cover-crop a 2:3 poster, and the crop
|
||||
* turns a mark into a strip — so before this they were the launcher's name on a flat accent
|
||||
* face. Naming the mark instead of sending it keeps the glyph vector at any tile size, lets it
|
||||
* take the tile's ink, and adds nothing to a reconcile payload that is already body-limited.
|
||||
*
|
||||
* Sending art instead is not an option the host leaves open: its art proxy serves raster
|
||||
* containers only and refuses SVG outright, because SVG is script-capable XML and the web
|
||||
* console renders library art in a browser.
|
||||
*
|
||||
* A token no client bundles is not an error — that tile just falls back to its name. To get a
|
||||
* new launcher's mark shipped, open a PR adding the master to `assets/launcher-icons`.
|
||||
*
|
||||
* Set it on your `launchers(cfg)` entries. Ordinary titles may carry one, but shouldn't: a game
|
||||
* has real cover art, which beats a brand mark every time.
|
||||
*/
|
||||
icon: Schema.optionalKey(Schema.String),
|
||||
...GameMeta.fields,
|
||||
});
|
||||
export type ProviderEntry = typeof ProviderEntry.Type;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Derive the per-client launcher-tile brand marks from the assets/launcher-icons masters.
|
||||
#
|
||||
# The sibling of gen-os-icons.sh, and deliberately a separate script rather than a flag on it:
|
||||
# the two registries answer different questions (which OS is this host / which launcher does
|
||||
# this tile open), are keyed by different vocabularies, and bake to different sizes. What they
|
||||
# share is the discipline — monochrome `fill="currentColor"` masters, original viewBoxes, one
|
||||
# file per token, provenance in the README.
|
||||
#
|
||||
# Four clients need a baked derivative because they cannot consume the master directly:
|
||||
#
|
||||
# GTK shell symbolic SVG, black fill -> clients/linux/data/icons/scalable/actions/
|
||||
# Windows shell PNG, h=128, mid-grey -> clients/windows/assets/launchers/
|
||||
# Apple clients vector PDF, black fill -> clients/apple/.../LauncherIcons.xcassets/
|
||||
#
|
||||
# The web console, the Android client and the in-session console UI transcribe the master's
|
||||
# path data inline instead — those are hand-kept, and this script prints them at the end so a
|
||||
# new token can be pasted straight in.
|
||||
#
|
||||
# Idempotent. Usage: bash scripts/gen-launcher-icons.sh [token ...] (default: every master)
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
MASTERS=assets/launcher-icons
|
||||
GTK=clients/linux/data/icons/scalable/actions
|
||||
WIN=clients/windows/assets/launchers
|
||||
APPLE=clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets
|
||||
|
||||
# Same mid-grey as the OS marks, for the same reason: the Windows shell has no vector element
|
||||
# and no theme-aware tint, so one colour has to stay legible on both the light and dark WinUI
|
||||
# theme. Taller than the OS marks (32) because this one fills a poster tile, not a status row.
|
||||
WIN_GREY='#8A8F98'
|
||||
WIN_HEIGHT=128
|
||||
|
||||
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||
|
||||
command -v rsvg-convert >/dev/null 2>&1 || {
|
||||
echo "rsvg-convert not found (brew install librsvg / apt install librsvg2-bin)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
tokens=("$@")
|
||||
if [ ${#tokens[@]} -eq 0 ]; then
|
||||
for f in "$MASTERS"/*.svg; do tokens+=("$(basename "$f" .svg)"); done
|
||||
fi
|
||||
|
||||
mkdir -p "$GTK" "$WIN" "$APPLE"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
for t in "${tokens[@]}"; do
|
||||
src="$MASTERS/$t.svg"
|
||||
[ -f "$src" ] || { echo "no master for token '$t' ($src)" >&2; exit 1; }
|
||||
log "$t"
|
||||
|
||||
# GTK: the master with the fill resolved to black — Adwaita recolors a `-symbolic` icon
|
||||
# from the fill it finds, so the value only has to be a real colour, not the final one.
|
||||
sed 's/currentColor/#000000/' "$src" > "$GTK/pf-launcher-$t-symbolic.svg"
|
||||
|
||||
# Windows: black-to-grey substitution, rasterized at a fixed height so every mark shares an
|
||||
# optical size and keeps its own aspect ratio.
|
||||
sed "s/currentColor/$WIN_GREY/" "$src" > "$tmp/$t.grey.svg"
|
||||
rsvg-convert -h "$WIN_HEIGHT" -f png -o "$WIN/$t.png" "$tmp/$t.grey.svg"
|
||||
|
||||
# Apple: a vector PDF at the master's natural size, in a template imageset — SwiftUI tints
|
||||
# it from foregroundStyle, so the baked colour is irrelevant.
|
||||
sed 's/currentColor/#000000/' "$src" > "$tmp/$t.black.svg"
|
||||
mkdir -p "$APPLE/launcher-$t.imageset"
|
||||
rsvg-convert -f pdf -o "$APPLE/launcher-$t.imageset/$t.pdf" "$tmp/$t.black.svg"
|
||||
cat > "$APPLE/launcher-$t.imageset/Contents.json" <<JSON
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "$t.pdf", "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
done
|
||||
|
||||
echo
|
||||
log "Inline registries (web console, Android, in-session console UI)"
|
||||
# Generated outright rather than printed for pasting, unlike gen-os-icons.sh: three clients x
|
||||
# seven paths of up to 3 kB is a transcription error waiting to happen, and a mangled character
|
||||
# is a silently wrong logo rather than a build failure.
|
||||
python3 scripts/gen_launcher_icon_tables.py
|
||||
|
||||
# The Rust registry goes through rustfmt: `cargo fmt --all --check` is a CI gate, and a
|
||||
# GENERATED file that fails it would fail the build every time someone re-ran this script.
|
||||
if command -v rustfmt >/dev/null 2>&1; then
|
||||
rustfmt --edition 2021 crates/pf-console-ui/src/launcher_icons.rs
|
||||
log " rustfmt'd crates/pf-console-ui/src/launcher_icons.rs"
|
||||
else
|
||||
log " rustfmt not found — run 'cargo fmt' before committing"
|
||||
fi
|
||||
|
||||
echo
|
||||
log "Remember: a NEW token also has to be added to each client's shipped-token list —"
|
||||
log " clients/linux/src/ui_library.rs, clients/linux/data/resources.gresource.xml,"
|
||||
log " clients/windows/src/app/launcher_icons.rs,"
|
||||
log " clients/apple/.../PunktfunkKit/LauncherIcon.swift"
|
||||
log " (the three inline registries above pick it up automatically)"
|
||||
log " — and to the plugin that emits the tile."
|
||||
@@ -88,18 +88,6 @@ VENDORED_TREES = [
|
||||
("Bazzite logo (vendored, assets/os-icons)",
|
||||
"assets/os-icons/LICENSES/bazzite.txt",
|
||||
"https://github.com/ublue-os/bazzite"),
|
||||
# Launcher brand marks for the library's launcher tiles (assets/launcher-icons/, CC BY 4.0 /
|
||||
# CC0 / MIT — see assets/launcher-icons/README.md). A separate registry from the OS marks
|
||||
# above, with its own sources, so it carries its own notices even where a vendor overlaps.
|
||||
("Font Awesome Free brand icons (vendored, assets/launcher-icons)",
|
||||
"assets/launcher-icons/LICENSES/font-awesome-brands.txt",
|
||||
"https://fontawesome.com"),
|
||||
("Simple Icons (vendored, assets/launcher-icons)",
|
||||
"assets/launcher-icons/LICENSES/simple-icons.txt",
|
||||
"https://simpleicons.org"),
|
||||
("Playnite logo (vendored, assets/launcher-icons)",
|
||||
"assets/launcher-icons/LICENSES/playnite.txt",
|
||||
"https://github.com/JosefNemec/Playnite"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit the three INLINE launcher-icon registries from the assets/launcher-icons masters.
|
||||
|
||||
The OS-icon pipeline prints its path data for a human to paste into each client. That is fine
|
||||
for a mark you add once a year; it is not fine here, where three clients each need seven paths
|
||||
of up to 3 kB and a single mangled character is a silently wrong logo. So these three files are
|
||||
generated outright, with their commentary baked in below:
|
||||
|
||||
web/src/components/launcher-icon.tsx web console, inline SVG
|
||||
clients/android/.../components/LauncherIcons.kt Android, Compose ImageVector via PathParser
|
||||
crates/pf-console-ui/src/launcher_icons.rs in-session console UI, Skia Path::from_svg
|
||||
|
||||
The baked derivatives (GTK / Windows / Apple) come from gen-launcher-icons.sh, which calls this.
|
||||
|
||||
Usage: python3 scripts/gen_launcher_icon_tables.py (from anywhere; paths are repo-relative)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
MASTERS = ROOT / "assets" / "launcher-icons"
|
||||
|
||||
# Registry order — the order a reader of any of the three files sees. Live tiles first, then the
|
||||
# marks that ship dormant (see the masters' README), so "what actually renders today" reads off
|
||||
# the top of the list.
|
||||
TOKENS = ["steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox"]
|
||||
|
||||
BANNER = (
|
||||
"GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.\n"
|
||||
"Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.\n"
|
||||
"Per-mark provenance and licensing: assets/launcher-icons/README.md."
|
||||
)
|
||||
|
||||
|
||||
def mark(token: str) -> tuple[str, str, float, float]:
|
||||
"""(token, path data, viewport width, viewport height) for one master."""
|
||||
svg = (MASTERS / f"{token}.svg").read_text()
|
||||
box = re.search(r'viewBox="([^"]+)"', svg).group(1)
|
||||
paths = re.findall(r'<path[^>]*\sd="([^"]+)"', svg)
|
||||
if len(paths) != 1:
|
||||
sys.exit(f"{token}: expected exactly one <path>, found {len(paths)}")
|
||||
d = paths[0]
|
||||
if any(c in d for c in "\n\t\"\\"):
|
||||
sys.exit(f"{token}: path data must be single-line and free of quotes/backslashes")
|
||||
_, _, w, h = box.split()
|
||||
return token, d, float(w), float(h)
|
||||
|
||||
|
||||
MARKS = [mark(t) for t in TOKENS]
|
||||
|
||||
|
||||
def comment(prefix: str) -> str:
|
||||
return "\n".join(f"{prefix} {line}".rstrip() for line in BANNER.splitlines())
|
||||
|
||||
|
||||
def write(rel: str, body: str) -> None:
|
||||
p = ROOT / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(body)
|
||||
print(f" {rel} ({len(body):,} bytes)")
|
||||
|
||||
|
||||
# --- web console -----------------------------------------------------------------------------
|
||||
|
||||
rows = "\n".join(
|
||||
f'\t{t}: {{\n\t\tviewBox: "0 0 {w:g} {h:g}",\n\t\td: "{d}",\n\t}},'
|
||||
for t, d, w, h in MARKS
|
||||
)
|
||||
write(
|
||||
"web/src/components/launcher-icon.tsx",
|
||||
f"""{comment("//")}
|
||||
//
|
||||
// The mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token. lucide
|
||||
// deliberately ships no brand marks, so this is a curated registry — the same shape as
|
||||
// os-icon.tsx, which does the equivalent job for the host cards' OS marks.
|
||||
import type {{ FC }} from "react";
|
||||
|
||||
/** One monochrome brand mark: original per-icon viewBox, drawn in currentColor. */
|
||||
const LAUNCHER_ICONS: Record<string, {{ viewBox: string; d: string }}> = {{
|
||||
{rows}
|
||||
}};
|
||||
|
||||
/**
|
||||
* The mark for an entry's `icon` token, or null — render nothing — when the entry carries no
|
||||
* token or names one this console ships no art for. Callers fall back to the title, which is
|
||||
* what every launcher tile looked like before the token existed.
|
||||
*
|
||||
* The token is looked up in the shipped set, never interpolated into anything, so a host
|
||||
* sending something unexpected can only ever produce "no icon".
|
||||
*/
|
||||
export const LauncherIcon: FC<{{
|
||||
\ticon?: string | null;
|
||||
\tclassName?: string;
|
||||
\tlabel?: string;
|
||||
}}> = ({{ icon, className, label }}) => {{
|
||||
\tconst m = icon ? LAUNCHER_ICONS[icon] : undefined;
|
||||
\tif (!m) return null;
|
||||
\treturn (
|
||||
\t\t<svg
|
||||
\t\t\txmlns="http://www.w3.org/2000/svg"
|
||||
\t\t\tviewBox={{m.viewBox}}
|
||||
\t\t\tfill="currentColor"
|
||||
\t\t\tclassName={{className}}
|
||||
\t\t\trole={{label ? "img" : undefined}}
|
||||
\t\t\taria-label={{label}}
|
||||
\t\t\taria-hidden={{label ? undefined : true}}
|
||||
\t\t>
|
||||
\t\t\t{{label && <title>{{label}}</title>}}
|
||||
\t\t\t<path d={{m.d}} />
|
||||
\t\t</svg>
|
||||
\t);
|
||||
}};
|
||||
|
||||
export default LauncherIcon;
|
||||
""",
|
||||
)
|
||||
|
||||
# --- Android ---------------------------------------------------------------------------------
|
||||
|
||||
rows = "\n".join(
|
||||
f' "{t}" to LauncherGlyph(\n'
|
||||
f" viewportWidth = {w:g}f,\n"
|
||||
f" viewportHeight = {h:g}f,\n"
|
||||
f' d = "{d}",\n'
|
||||
f" ),"
|
||||
for t, d, w, h in MARKS
|
||||
)
|
||||
write(
|
||||
"clients/android/app/src/main/kotlin/io/unom/punktfunk/components/LauncherIcons.kt",
|
||||
f"""package io.unom.punktfunk.components
|
||||
|
||||
{comment("//")}
|
||||
|
||||
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(
|
||||
{rows}
|
||||
)
|
||||
|
||||
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()
|
||||
}}
|
||||
}}
|
||||
""",
|
||||
)
|
||||
|
||||
# --- in-session console UI (Skia) --------------------------------------------------------------
|
||||
|
||||
rows = "\n".join(
|
||||
f' ("{t}", {w:g}.0, {h:g}.0, "{d}"),' for t, d, w, h in MARKS
|
||||
)
|
||||
write(
|
||||
"crates/pf-console-ui/src/launcher_icons.rs",
|
||||
f"""{comment("//!")}
|
||||
//!
|
||||
//! The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
|
||||
//! Skia parses SVG path data directly, so the masters need no transcription into a drawing
|
||||
//! DSL — the path string is the asset.
|
||||
|
||||
use skia_safe::{{Matrix, Path, Rect}};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{{Mutex, OnceLock}};
|
||||
|
||||
/// A parsed mark and the viewport its coordinates are in.
|
||||
type Glyph = (Path, f32, f32);
|
||||
|
||||
/// Token → parsed mark, with `None` memoizing "no such token / did not parse" so a miss is not
|
||||
/// re-attempted every frame. Named because `clippy::type_complexity` rejects it inline, and this
|
||||
/// file is generated — an inline type would fail the `-D warnings` gate on every regeneration.
|
||||
type GlyphCache = HashMap<String, Option<Glyph>>;
|
||||
|
||||
/// `(token, viewport width, viewport height, path data)` — the masters, verbatim.
|
||||
const GLYPHS: &[(&str, f32, f32, &str)] = &[
|
||||
{rows}
|
||||
];
|
||||
|
||||
/// The parsed path for a token plus the viewport it was authored in, or `None` when the token is
|
||||
/// absent, unknown, or (defensively) unparseable — the tile then names its launcher instead,
|
||||
/// which is exactly how every launcher tile looked before icons existed.
|
||||
///
|
||||
/// Parsed once per token and cached: `Path::from_svg` on a 3 kB string is not free, and the
|
||||
/// library shelf re-renders every frame while the cursor springs.
|
||||
fn glyph(token: &str) -> Option<Glyph> {{
|
||||
static CACHE: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache.lock().ok()?;
|
||||
if let Some(hit) = cache.get(token) {{
|
||||
return hit.clone();
|
||||
}}
|
||||
let built = GLYPHS
|
||||
.iter()
|
||||
.find(|(t, ..)| *t == token)
|
||||
.and_then(|(_, w, h, d)| Path::from_svg(d).map(|p| (p, *w, *h)));
|
||||
cache.insert(token.to_string(), built.clone());
|
||||
built
|
||||
}}
|
||||
|
||||
/// The mark for `token`, scaled to fit `dst` and centred in it — aspect ratio preserved, because
|
||||
/// the masters' viewports are not all square. `None` when there is no mark to draw.
|
||||
pub(crate) fn launcher_mark(token: &str, dst: Rect) -> Option<Path> {{
|
||||
let (path, vw, vh) = glyph(token)?;
|
||||
let scale = (dst.width() / vw).min(dst.height() / vh);
|
||||
let mut m = Matrix::new_identity();
|
||||
m.set_scale((scale, scale), None);
|
||||
m.post_translate((
|
||||
dst.left + (dst.width() - vw * scale) / 2.0,
|
||||
dst.top + (dst.height() - vh * scale) / 2.0,
|
||||
));
|
||||
Some(path.with_transform(&m))
|
||||
}}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {{
|
||||
use super::*;
|
||||
|
||||
/// Every shipped master parses. A mark that silently fails to parse is a tile that silently
|
||||
/// loses its icon, which no other test in this crate would notice.
|
||||
#[test]
|
||||
fn every_glyph_parses() {{
|
||||
for (token, ..) in GLYPHS {{
|
||||
assert!(glyph(token).is_some(), "{{token}} failed to parse");
|
||||
}}
|
||||
}}
|
||||
|
||||
#[test]
|
||||
fn unknown_token_draws_nothing() {{
|
||||
assert!(launcher_mark("not-a-launcher", Rect::from_wh(64.0, 64.0)).is_none());
|
||||
}}
|
||||
|
||||
/// The mark is letterboxed into the destination, never stretched past it — the guarantee the
|
||||
/// non-square viewports (playnite is 1024x1024, steam 496x512) depend on.
|
||||
#[test]
|
||||
fn mark_is_contained_and_centred() {{
|
||||
let dst = Rect::from_xywh(10.0, 20.0, 80.0, 40.0);
|
||||
let b = launcher_mark("steam", dst).unwrap().compute_tight_bounds();
|
||||
assert!(b.width() <= dst.width() + 0.5 && b.height() <= dst.height() + 0.5);
|
||||
let (cx, cy) = (b.center_x(), b.center_y());
|
||||
assert!((cx - dst.center_x()).abs() < 1.0, "off-centre horizontally: {{cx}}");
|
||||
assert!((cy - dst.center_y()).abs() < 1.0, "off-centre vertically: {{cy}}");
|
||||
}}
|
||||
}}
|
||||
""",
|
||||
)
|
||||
@@ -1,73 +0,0 @@
|
||||
// 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.
|
||||
//
|
||||
// The mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token. lucide
|
||||
// deliberately ships no brand marks, so this is a curated registry — the same shape as
|
||||
// os-icon.tsx, which does the equivalent job for the host cards' OS marks.
|
||||
import type { FC } from "react";
|
||||
|
||||
/** One monochrome brand mark: original per-icon viewBox, drawn in currentColor. */
|
||||
const LAUNCHER_ICONS: Record<string, { viewBox: string; d: string }> = {
|
||||
steam: {
|
||||
viewBox: "0 0 496 512",
|
||||
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: {
|
||||
viewBox: "0 0 24 24",
|
||||
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: {
|
||||
viewBox: "0 0 24 24",
|
||||
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: {
|
||||
viewBox: "0 0 1024 1024",
|
||||
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: {
|
||||
viewBox: "0 0 24 24",
|
||||
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: {
|
||||
viewBox: "0 0 24 24",
|
||||
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: {
|
||||
viewBox: "0 0 512 512",
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The mark for an entry's `icon` token, or null — render nothing — when the entry carries no
|
||||
* token or names one this console ships no art for. Callers fall back to the title, which is
|
||||
* what every launcher tile looked like before the token existed.
|
||||
*
|
||||
* The token is looked up in the shipped set, never interpolated into anything, so a host
|
||||
* sending something unexpected can only ever produce "no icon".
|
||||
*/
|
||||
export const LauncherIcon: FC<{
|
||||
icon?: string | null;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}> = ({ icon, className, label }) => {
|
||||
const m = icon ? LAUNCHER_ICONS[icon] : undefined;
|
||||
if (!m) return null;
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={m.viewBox}
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
>
|
||||
{label && <title>{label}</title>}
|
||||
<path d={m.d} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default LauncherIcon;
|
||||