Compare commits

..
Author SHA1 Message Date
enricobuehler c817e4bec0 fix(pf-vdisplay): a CAPPED compositor refuses /proc/exe — NixOS+KDE still undetectable
apple / swift (pull_request) Successful in 1m55s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m34s
android / android (pull_request) Successful in 4m27s
ci / rust-arm64 (pull_request) Successful in 1m57s
ci / web (pull_request) Successful in 1m4s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-site (pull_request) Successful in 1m18s
v0.27.0 resolved the nixpkgs wrapper decoration (`.kwin_wayland-w`) through `/proc/<pid>/exe`.
On the box that fix was written for, the kernel refuses that link.

Reading `/proc/<pid>/exe` is not gated on owning the process: `cap_ptrace_access_check` 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"; }`, and our
host must stay uncapped — a capability is precisely what makes it unidentifiable to KWin (#136).
So the two traps compose: the name NEEDS `exe` because nixpkgs wrapped the binary, and `exe` is
DENIED because NixOS capped it. `detect_active_session` went back to `ActiveKind::None`, and every
connect died `no usable compositor` with `wayland="-"` — the identical signature to the bug #158
fixed, which is why a box worked around with a decoy process broke again when the decoy went away.

`match_name` now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when `exe` is unreadable. It
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. It 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.

MEASURED (Linux 6.x, same-uid reader, target holding cap_sys_nice), for a file capability and for
the ambient form `security.wrappers` actually uses — identically:

  /proc/<pid> owner   real uid   (so the uid filter upstream was never the problem)
  comm                readable   (decorated + truncated, unusable alone)
  exe                 EACCES
  cmdline (argv[0])   readable

End-to-end against a LIVE capped, `.kwin_wayland-wrapped` process, old resolver vs new, both
extracted verbatim from their own sources: v0.27.0 answers `.kwin_wayland-w` (no match) in both
capped scenarios and `kwin_wayland` uncapped; the fixed one answers `kwin_wayland` in all three.

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.

Tests are fixture-driven (a temp dir with `comm`/`exe`/`cmdline`), for the reason #158 documented:
a renamed `/bin/sleep` stand-in dies instantly on multi-call coreutils and reads exactly like the
resolver being broken. 11 tests extracted verbatim and executed on Linux as a non-root user —
0 failures. `cargo fmt --check` and `scripts/xcheck.sh linux clippy` (`--all-targets`) clean.

Refs #158, #136.
2026-08-10 23:23:59 +02:00
enricobuehler bde4276632 Merge pull request '0.27.0' (#159) from worktree-release-0270 into main
audit / bun-audit (sdk) (push) Successful in 24s
audit / bun-audit (plugin-kit) (push) Successful in 25s
audit / bun-audit (web) (push) Failing after 20s
audit / docs-site-audit (push) Successful in 1m2s
audit / cargo-audit (push) Successful in 35s
audit / pnpm-audit (push) Successful in 58s
apple / swift (push) Successful in 1m48s
ci / web (push) Successful in 1m8s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m58s
ci / rust-arm64 (push) Successful in 2m40s
audit / license-gate (push) Successful in 4m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m11s
apple / screenshots (push) Successful in 3m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m44s
decky / build-publish (push) Successful in 49s
ci / rust (push) Successful in 17m17s
deb / build-publish (push) Successful in 16m21s
deb / build-publish-host (push) Failing after 6m51s
deb / build-publish-client-arm64 (push) Successful in 2m15s
docker / deploy-docs (push) Successful in 1m53s
arch / build-publish (push) Successful in 11m28s
sbom / sbom (push) Successful in 52s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m59s
web-screenshots / screenshots (push) Successful in 5m19s
flatpak / build-publish (push) Successful in 13m27s
windows-host / package (push) Successful in 12m40s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m29s
docker / builders-arm64cross (push) Successful in 9s
linux-client-screenshots / screenshots (push) Successful in 2m32s
android / android (push) Successful in 6m47s
release / apple (push) Successful in 11m47s
android-screenshots / screenshots (push) Successful in 1m30s
nix / flake (push) Failing after 12m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m37s
chore(release): bump workspace version to 0.27.0

87 commits since v0.26.0. C ABI 17 -> 18 (additive), so a minor rather than a 0.26.x patch.

The one red check is nix.yml's `flake` job: `bun run build` for punktfunk-web was OOM-killed
(exit 137) inside the nix sandbox, twice, on two different runner hosts. It is not this diff --
main built the identical web sources in 32s, and this commit touches no file under web/ or
packaging/nix/. nix.yml does not run on tags, so it cannot affect the release. Tracked as a
memory-headroom problem in the gate #158 added.
2026-08-10 19:36:43 +00:00
enricobuehler 147bc82130 chore(release): bump workspace version to 0.27.0
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 23s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m56s
android / android (pull_request) Successful in 8m13s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 8m39s
ci / rust (pull_request) Successful in 18m11s
nix / flake (pull_request) Successful in 17m53s
87 commits since v0.26.0. The number is forced rather than chosen: the C ABI moves
17 -> 18, and `scripts/ci/pf-version.sh` already reports PF_BASE=0.27.0.

Fills in what `docs/releases/v0.27.0.md` and the `## v0.27.0 — in development`
CHANGELOG section deferred to the bump -- the lead-in, the TL;DR, "Before you update",
the version table and the commit count -- and merges in the work that landed without
notes: the Windows Xbox HID pad, the Apple console-UI batch, and the v0.26.0 field
repairs. Everything already accumulated on main is kept as written.

Version table, all re-derived at HEAD rather than read off the diff:
  * wire 2 (unchanged), C ABI 17 -> 18 (ADDED symbol, not widened)
  * crate dirs 26 -> 27 (`punktfunk-encode-worker`), 39 members
  * gamescope `+pfhdr4` -> `+pfhdr5` (6 patches -> 7, the PipeWire UAF), pkgrel resets to 1
  * driver proto 6, gamepad channel 3, plugin index 1, SDK 0.1.4, plugin-kit 0.4.0 -- all unchanged
  * `api/openapi.json` stays 0.25.0: it moves only on an API EDIT, and the last one
    (`6cffe29b`) predates the v0.26.0 tag. It is not a mechanical per-release bump.

⚠ `crates/pf-driver-proto` is NO LONGER byte-identical to the previous release -- it was
through both v0.25.0 and v0.26.0, and the last two CHANGELOGs told packagers so. Three
additive `device_type`s. Called out explicitly so a packager who has been skipping it stops.

The Play whatsnew names the one real Android change (the OLED palette is Eclipse now;
display name only, the stored `oled` id is unchanged so saved choices carry over) and is
otherwise explicit that the release is host-side.

`docs-site/.../running-as-a-service.md` claimed "0.26.0-2 revoked it everywhere". No such
version exists -- the Arch registry goes 0.26.0-1 straight to 0.27.0 canaries -- and the
corrected 0.26.0 packages shipped under the SAME version string, which is exactly why a
machine that installed an early one was never offered the fix. Reworded to say that, since
it is the reason this release matters to those users.

Gates, all green on this commit:
  * `cargo fmt --all --check` clean
  * `cargo metadata --locked` resolves
  * `Cargo.lock` diff versions-only -- 72/72 changed lines, 36 crates
  * Play gate's own python3 arithmetic: 400/500 CHARACTERS, not byte-identical to any other
    release (`wc -c` misleads -- `•` is 3 bytes)
  * notes voice check: 1 hit, reviewed and kept -- `getcap /usr/bin/punktfunk-host` is a
    command the reader runs, the same class as the `usermod` line v0.26.0's notes carried
  * `docs-site` builds

NOT DONE: no tag yet.
2026-08-10 20:51:45 +02:00
enricobuehler 30c47eb691 Merge pull request 'NixOS could not detect a graphical session at all — and the module was never checked by CI' (#158) from worktree-nixos-wrapped-comm-detection into main
apple / swift (push) Successful in 1m43s
ci / docs-site (push) Successful in 1m19s
ci / web (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m47s
deb / build-publish-client-arm64 (push) Successful in 1m28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m33s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 26s
apple / screenshots (push) Successful in 3m1s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m26s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
ci / bun-nix (push) Successful in 35s
deb / build-publish (push) Successful in 8m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
deb / build-publish-host (push) Successful in 8m47s
arch / build-publish (push) Successful in 9m25s
docker / deploy-docs (push) Successful in 48s
docker / builders-arm64cross (push) Successful in 13s
android / android (push) Successful in 6m57s
ci / rust (push) Successful in 6m38s
windows-host / package (push) Successful in 16m38s
windows-host / winget-source (push) Skipped
nix / flake (push) Successful in 14m42s
windows-host / canary-manifest (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m12s
Reviewed-on: #158
2026-08-10 18:37:32 +00:00
enricobuehler bd26949e0a Merge pull request 'The library's hide button was invisible AND clickable — a corner nobody could see dropped games' (#157) from worktree-library-hide-affordance into main
arch / build-publish (push) Canceled after 40s
ci / rust (push) Canceled after 41s
ci / docs-site (push) Canceled after 42s
ci / web (push) Canceled after 42s
ci / rust-arm64 (push) Canceled after 41s
ci / bun-nix (push) Canceled after 40s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 24s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 4s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m10s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #157
2026-08-10 18:37:03 +00:00
enricobuehler 61dfc3dadc Merge remote-tracking branch 'origin/main' into worktree-nixos-wrapped-comm-detection
ci / bun-nix (pull_request) Successful in 40s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m22s
ci / rust (pull_request) Successful in 6m47s
apple / swift (pull_request) Failing after 13m41s
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Successful in 13m48s
2026-08-10 20:26:23 +02:00
enricobuehler 1befa8a2c4 docs(nix): bring the Nix docs in line with the module, and fix a stale claim they shared
There are three places Nix is documented — the public docs-site, packaging/nix/
README.md, and packaging/README.md — plus the changelog. All had drifted.

STALE CLAIM, and not only for Nix. install.md said the plugin runner's "user unit
ships **disabled** — enable it once you have" something to run. That is true only
of Arch and source installs: the deb postinst and RPM %post both
`systemctl --global enable punktfunk-scripting.service`, and the Bazzite sysext
bakes in a default.target.wants symlink (build-sysext.sh:113). bazzite.md carried
the same claim about its own image. Both corrected, per channel, with the reason
the default flipped — the library scanners are plugins, so a host without the
runner can come up with an empty library — and the `mask`-not-`disable` opt-out
the sysext's own comment documents.

docs-site:
  * install.md NixOS — `desktopSession` in the example and explained, the runner
    no longer needs enabling, and the host/console line says what autoStart does.
  * running-as-a-service.md — "Restart the host with your desktop" documented the
    drop-in for packaged installs only; NixOS gets its one-liner beside it.
  * bazzite.md — the runner is started for you, not "isn't started".

packaging/nix/README.md:
  * option tables gain `desktopSession`, `gamescopeHdr`, `gamescopePackage`, and
    the `punktfunk` group next to `input` (both are required — the udev rule
    chgrp's the vhci nodes and fails outright if the group was never created).
  * "what the module configures" gains the security.wrappers entry, and a note on
    why the capability sits on the encode worker and never on the host: a wrapper
    raises it into the ambient set, which lands it in the permitted set and fails
    KWin's /proc/<pid>/exe readlink identically to a file capability.
  * the appliance snippet no longer tells you to put pkgs.gamescope on PATH —
    gamescopeHdr does that with the patched build, and desktopSession is called
    out as the thing to leave off there.
  * a caveat recording that `nix flake check` does not check the module, and the
    two rules for editing module-check.nix (assertions stay pure Nix; assert
    list-valued unit fields on the lists, not the rendered text).

packaging/README.md: the flake ships five packages, not "host + client".

CHANGELOG.md v0.27.0: a NixOS section covering the comm/session-detection fix, the
module changes including the scripting default flip as an explicit behaviour
change, and the flake-check gap — plus the documentation bullets above.
2026-08-10 20:24:21 +02:00
enricobuehler f8cde0adaf feat(nix): actually check the NixOS module in CI, and close the sweep's open issues
THE CI GAP. `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 module sets a nonexistent OPTION, references a
nonexistent `pkgs` attribute AND calls a nonexistent `lib` function passes clean,
printing `checking NixOS module 'nixosModules.default'... all checks passed!`.
nix.yml's header claimed that leg covered the module; it never did, for the
module's whole life — on a flake whose history is Nix regressions reaching main
invisibly.

Closed with `checks.<system>.nixos-module` (packaging/nix/module-check.nix): it
evaluates the module against real nixpkgs in four scenarios (desktop, appliance,
native-only, client-only) and asserts on the rendered systemd units. The
assertions are PURE NIX so instantiating the check runs them — which means the
eval-only `--no-build` leg CI already runs is sufficient, and no Rust is built.
Stub fake-derivation packages keep it independent of punktfunk-host/-client and
the from-source gamescope; crane and bun2nix are provably not needed (they are
`throw`s in the wiring test and it still instantiates).

17 checks, including regression guards for every divergence the sweep found and
for the KWin identification trap (host ExecStart must stay on the plain store
path, never a capability wrapper, while the encode worker points AT the wrapper).
Mutation-tested: 8 mutants, each re-introducing one real defect, all 8 rejected,
baseline green. The suite already earned it once — its first run failed a correct
module because systemd renders `After=` as one space-separated line, so those
assertions now read the evaluated lists instead of the text.

Also closed from the sweep:

  * 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 it then serves 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, matching the deb postinst and RPM %post,
    which both `systemctl --global enable` the runner. It was opt-in here on the
    reasoning that the runner is inert until you add automation — which stopped
    being true when the game-library scanners became plugins. A NixOS host came up
    with an empty library and no obvious reason why. The module and README carried
    the superseded rationale verbatim; both updated.

  * A warning when the host is enabled and xdg.portal is not. A warning rather
    than `xdg.portal.enable = mkDefault true`, because enabling the portal service
    with no `extraPortals` backend is its own broken state and only the operator
    knows which backend their compositor needs.

  * punktfunk-gamescope gets a `build-gamescope` dispatch input. It is on the
    critical path of every host build (`gamescopeHdr` defaults true) yet nothing
    compiled it; it tracks nixpkgs' gamescope, so a flake.lock bump — not a change
    of ours — is what breaks it, and the first to find out would be an operator
    whose system rebuild fails.

All .nix files reformatted with the flake's own declared formatter
(nixfmt-rfc-style from the PINNED nixpkgs, not a channel's).
2026-08-10 19:57:10 +02:00
enricobuehler e93947969f fix(console): the hide button was invisible AND clickable — a corner nobody could see dropped games
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m0s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / rust (pull_request) Successful in 4m39s
Hiding a library title shipped in 0.26.0 and was, in practice, unusable: the operator
reported there was simply no button, then found it by CLICKING the empty top-right corner
of a poster.

Both halves of that are the same defect. The card's control row was `opacity-0` until
`group-hover`, and `opacity-0` paints nothing while still HIT-TESTING. So the corner of
every tile in the grid was a live hide button with no visual presence at all: a stray
click there removed that title from every play surface — the client grid, the native
clients, the GameStream app list — with nothing on screen having suggested a control was
under the cursor. What read as "the button finally appeared" was the hide taking effect,
since `hidden` is the one state that drops the `opacity-0`. The feature announced itself
by firing.

And the reveal rested on hover ALONE. `:hover` never fires on a touch screen, so on a
tablet the hide control was unreachable by construction and discoverable only by the blind
click above. The original commit spotted this hazard for UN-hide — it kept those controls
always-visible so nobody could be stranded in the hidden state — but left the hide side
hover-gated, which is the same trap one step earlier.

So opacity and `pointer-events` now move together, always: whatever cannot be seen cannot
be clicked. `pointer-coarse:` shows the row outright wherever the device has no hover to
give, rather than making touch a second-class path. Keyboard reach is unchanged —
`pointer-events: none` does not block focus, so tabbing in still trips `focus-within`,
which now restores interactivity along with opacity.

The eye icon also gains a `title`. On a scanned entry it is the ONLY control on the card,
with no edit/delete beside it to read as a toolbar, and an unlabelled eye-with-slash is
not a promise that a game is about to leave the library.

Verified in the built CSS rather than by eye, because a variant that does not compile
fails exactly like the bug being fixed: `@media(pointer:coarse)` emits both
`pointer-coarse:opacity-100` and `pointer-coarse:pointer-events-auto`, and it lands at the
END of the sheet — media queries add no specificity, so this tie against the base
`.pointer-events-none` / `.opacity-0` is won on source order, not by accident. The
`group-hover:` and `focus-within:` forms compile to `:is(:where(.group):hover *)` and
`:focus-within`, carrying a pseudo-class each, so they win on specificity outright.

Console: tsc clean, production build clean, biome clean on the touched file, i18n 633
messages across en+de.
2026-08-10 19:55:13 +02:00
enricobuehler 159bbdbfc2 fix(nix): port three NixOS-module divergences from the shipped systemd units
A sweep of the Nix packaging against the units the deb/rpm actually install
found three decisions that were made, documented and deliberate everywhere
else, and simply not carried into packaging/nix/nixos-module.nix.

punktfunk-web — StartLimitIntervalSec=0.
  The unit's EnvironmentFile for the mgmt token is mandatory ON PURPOSE, so the
  console genuinely fails until the host's first `serve` writes it. systemd's
  default rate limit (5 starts / 10 s) against RestartSec=2 then gives up
  permanently after ~10 s — which on an appliance is exactly the window before
  the host is ready, so a console enabled before the host's first run stayed
  dead until someone restarted it by hand. scripts/punktfunk-web.service has
  carried the override since that defect was found; the Nix module omitted it
  while its own comment went on promising "Restart retries until the host has
  created it".

punktfunk-web — Restart=always, not on-failure.
  A console that exits 0 has still stopped serving, and on-failure leaves it
  down. Matches the shipped unit and web-run.cmd on Windows, both of which
  relaunch bun on ANY exit. An explicit `systemctl --user stop` is unaffected.

punktfunk-scripting — the sandbox was missing entirely.
  The shipped unit confines the runner with NoNewPrivileges, ProtectSystem=
  strict, ReadWritePaths=%h /tmp and an AF_UNIX/AF_INET/AF_INET6 address-family
  restriction, plus PrivateTmp=no (a field report: a private /tmp hides
  /tmp/vhclient and /tmp/.X11-unix, so a plugin launches its vendor binary and
  then cannot reach the daemon behind it). The NixOS unit had none of it — so
  the one unit here that executes arbitrary operator TypeScript by design ran
  strictly LESS confined on NixOS than on every other channel.

Verified by evaluating the module against the pinned nixpkgs and rendering the
units: assertions clean, cap_sys_nice=ep on the encode-worker wrapper, firewall
47984/47989/47990/47992/47993/48010, and each unit carrying exactly the
directives above. That evaluation is NOT something CI does — measured: `nix
flake check` passes a nixosModule containing a nonexistent option, a nonexistent
pkgs attribute and a nonexistent lib function, printing "checking NixOS module
... all checks passed!" while never evaluating it against nixpkgs. nix.yml's
header claims that leg covers the module. It does not; tracked separately.
2026-08-10 19:27:14 +02:00
enricobuehler 002702bcec fix(pf-vdisplay): NixOS sessions were undetectable — comm is the WRAPPER's name
The session probe decided "is a desktop live?" by reading /proc/<pid>/comm for
every process of our uid and exact-matching it against "kwin_wayland" /
"gamescope" / "gnome-shell" / "Hyprland". comm is the kernel's name for the
executed FILE, truncated to 15 bytes — not argv[0].

nixpkgs wraps essentially every graphical binary: wrapProgram moves the real
ELF aside to `.<name>-wrapped` and installs a wrapper under the original name,
which then `exec -a "$0"`s the hidden file. So on NixOS the kernel reports
`.kwin_wayland-w` (15 bytes of `.kwin_wayland-wrapped`) while ps/pgrep -a show
a perfectly ordinary `kwin_wayland`, because they read argv. Measured against a
live kernel: `.kwin_wayland-w`, `.kwin_wayland_w` (KWin's own
kwin_wayland_wrapper), `.gamescope-wrap`, all 15 bytes.

Nothing downstream could recover from that one string comparison:

  - detect_active_session returned ActiveKind::None on a *running* KDE desktop;
  - wayland_display is only resolved for a detected kind, so the connect log
    reported wayland="-" even though WAYLAND_DISPLAY was correct;
  - pick_compositor's Auto arm returns the DETECTED backend, so a live, fully
    working KWin sitting in available() was never chosen — every connect died
    "no usable compositor";
  - and PUNKTFUNK_COMPOSITOR could not rescue it: pinned_at_a_dead_session
    consults the same probe, turning the miss into a hard error instead.

No environment variable reached the comparison — the XDG_CURRENT_DESKTOP
fallback in detect() is only on the pinned path. Capture itself was never at
fault: a decoy process merely NAMED kwin_wayland satisfied the probe and the
stream came up against the real KWin.

Resolve the name through /proc/<pid>/exe (the full, untruncated file name) and
strip the nixpkgs decoration. Both the leading `.` and the trailing `-wrapped`
are required before anything is stripped, so KWin's own real
`kwin_wayland_wrapper` binary keeps its name rather than collapsing into
`kwin_wayland` and handing the probe the parent's PID. The comm fast path is
kept for every ordinary distro — one read, no readlink, and no name that
matched before can stop matching.

Also applied to foreign_gamescope_running, which had the same defect: nixpkgs
wraps gamescope too, so the attach-vs-spawn ladder saw no foreign session.

Tests are fixture-driven rather than spawn-driven on purpose: a stand-in has to
be a real ELF that tolerates being renamed, and /bin/sleep is not one — modern
coreutils is a multi-call binary that dispatches on the executable's own name,
so a copy called `.kwin_wayland-wrapped` exits instantly and /proc/<pid>/exe is
gone before it can be read. That failure looks exactly like this resolver being
broken; it cost one debugging round here and the same trap is already recorded
in punktfunk-host's /proc matcher.
2026-08-10 18:38:14 +02:00
enricobuehler 88c1e94d94 Merge pull request 'A takeover's mask no longer bars the box's own way back into Game Mode' (#156) from worktree-worktree-gamescope-unmask-midstream into main
apple / swift (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 3m29s
ci / web (push) Successful in 2m41s
ci / bun-nix (push) Successful in 44s
ci / docs-site (push) Successful in 1m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
apple / screenshots (push) Successful in 3m4s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 49s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 1m43s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
android / android (push) Successful in 7m9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 52s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 45s
ci / rust (push) Successful in 9m31s
arch / build-publish (push) Successful in 10m50s
deb / build-publish (push) Successful in 7m41s
deb / build-publish-host (push) Successful in 8m52s
windows-host / package (push) Successful in 14m7s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m44s
Reviewed-on: #156
2026-08-10 16:23:44 +00:00
enricobuehler 657e82cd29 fix(gamescope): a takeover's mask no longer bars the box's own way back into Game Mode
ci / bun-nix (pull_request) Successful in 17s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m29s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m47s
android / android (pull_request) Successful in 4m24s
ci / rust (pull_request) Successful in 6m24s
A managed takeover runtime-masks the box's `gamescope-session-plus@*` unit so its
session supervisor cannot restart it underneath our Steam. The only unmask ran in
`do_restore_tv_session`, on client disconnect — so for the whole stream the mask
stayed on, and it silently barred the door the user was most likely to walk
through next.

`mask_unit`'s own doc said the mask "blocks nothing" on images whose sddm helper
execs the session script directly. That is half right, and the half it gets wrong
is this bug: on f43 bazzite-deck the script's last act is

    systemctl --user --wait start gamescope-session-plus@${CLIENT}.service

(verified on the .41 VM). What the mask fails to stop is the RELOGIN LOOP — sddm
keeps trying regardless, which is why stopping the DM is the real defense. What it
very much does stop is the unit, and with it every entry into game mode, including
the user's own deliberate "Return to Gaming Mode" after a mid-stream switch to the
desktop. Steam then sits on its "Switch to Desktop…" modal forever. `--runtime`
lives in tmpfs, so a reboot cleared it — hence "it works right after a reboot" —
and a plain `unmask` does not (measured: still `masked-runtime`).

So the mask's sound lifetime is shorter than the takeover's: it ends the moment the
box stops being ours. The mid-stream session watcher already detects exactly that,
so it now lifts the mask on a confirmed switch to a desktop session — ahead of the
`compositor_for_kind` arm, because a switch we cannot follow still has to unbar the
return. `Gaming` and `None` deliberately do not lift: a takeover's own managed
session reads as `Gaming` and one momentarily down between relaunches reads as
`None`, and lifting on either would void the mask for the whole stream, in exactly
the SDDM-storm window it exists for.

Fixes a second, worse leak on the way: `honor_session_select_switch` consumed
`STOPPED_AUTOLOGIN` — the only record of what carries a mask — without unmasking,
so under a DM-stop takeover the disconnect restore found an empty list and lifted
nothing. That mask outlived not just the stream but the boot. It is also what let
that path's own step 1 work at all, since the DM's autologin heads back into game
mode through precisely this unit.

The lift is idempotent, keeps the restart list intact (the disconnect restore still
owes those units a `start`), and every hand-back path now routes through it.

Verified on Linux: `switch_ends_mask_window` decision table, plus an ignored
end-to-end test driving real `systemctl --user` (masked → survives Gaming/None →
lifted by a desktop switch → restart list intact → idempotent). Proven non-vacuous
by planting "Gaming also lifts", which fails it on the during-stream assert.
2026-08-10 17:16:29 +02:00
101 changed files with 1689 additions and 1714 deletions
+38 -4
View File
@@ -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
+285 -3
View File
@@ -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`.
---
Generated
+36 -36
View File
@@ -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",
+1 -1
View File
@@ -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"
+1 -30
View File
@@ -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.
-62
View File
@@ -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.
-2
View File
@@ -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

-2
View File
@@ -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

-2
View File
@@ -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

-2
View File
@@ -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

-2
View File
@@ -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

-2
View File
@@ -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

-2
View File
@@ -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"),
),
)
}
-4
View File
@@ -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 -9
View File
@@ -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>
-3
View 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); }
";
-1
View File
@@ -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"),
+15 -49
View File
@@ -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);
-2
View File
@@ -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(),
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

-71
View File
@@ -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('\\', "/")))
}
+17 -37
View File
@@ -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,
+1 -4
View File
@@ -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()),
-35
View File
@@ -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").
-102
View File
@@ -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}"
);
}
}
-2
View File
@@ -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;
-6
View File
@@ -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(),
);
+15 -37
View File
@@ -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.
-1
View File
@@ -394,7 +394,6 @@ fn dump_console_screens() {
title: (*t).to_string(),
store: "steam".into(),
launcher: false,
icon: String::new(),
})
.collect(),
);
+1 -1
View File
@@ -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()));
+404
View File
@@ -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::*;
+11 -4
View File
@@ -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),
-54
View File
@@ -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(),
-1
View File
@@ -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(),
+7 -24
View File
@@ -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(),
+4 -2
View File
@@ -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:
-5
View File
@@ -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
+26 -5
View File
@@ -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
+11 -3
View File
@@ -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
+60 -14
View File
@@ -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.
+2
View File
@@ -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.
+12
View File
@@ -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;
};
}
);
+2 -2
View File
@@ -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
+96 -15
View File
@@ -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.
+263
View File
@@ -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"
''
+127 -15
View File
@@ -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"
];
};
};
})
-4
View File
@@ -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. */
-41
View File
@@ -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;
-108
View File
@@ -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."
-12
View File
@@ -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"),
]
-296
View File
@@ -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}}");
}}
}}
""",
)
File diff suppressed because one or more lines are too long
-73
View File
@@ -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;

Some files were not shown because too many files have changed in this diff Show More