Compare commits

...
6 Commits
Author SHA1 Message Date
enricobuehler d715225960 Merge pull request 'Launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client' (#162) from worktree-launcher-icons into main
apple / swift (push) Successful in 1m33s
android / android (push) Successful in 7m5s
ci / rust (push) Successful in 7m54s
arch / build-publish (push) Successful in 8m5s
ci / rust-arm64 (push) Successful in 1m43s
ci / web (push) Successful in 58s
ci / bun-nix (push) Successful in 16s
ci / docs-site (push) Successful in 1m13s
release / apple (push) Successful in 10m37s
deb / build-publish (push) Successful in 3m57s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
deb / build-publish-client-arm64 (push) Successful in 3m46s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish-host (push) Successful in 7m0s
docker / builders-arm64cross (push) Successful in 30s
docker / deploy-docs (push) Successful in 51s
apple / screenshots (push) Successful in 5m48s
windows-host / package (push) Successful in 17m50s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m5s
flatpak / build-publish (push) Successful in 9m30s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows-host / canary-manifest (push) Successful in 22s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
Reviewed-on: #162
2026-08-11 04:58:36 +00:00
enricobuehler e1198495e0 Merge pull request 'NixOS+KDE still has no compositor on 0.27.0 — a capped KWin refuses the /proc/exe rung that #158's fix depends on' (#161) from worktree-nixos-capped-compositor-exe-denied into main
apple / swift (push) Successful in 1m32s
ci / web (push) Successful in 1m17s
ci / rust-arm64 (push) Successful in 1m42s
apple / screenshots (push) Successful in 6m3s
ci / bun-nix (push) Successful in 41s
ci / docs-site (push) Successful in 1m26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 31s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 1m43s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 52s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m24s
windows-host / package (push) Successful in 16m37s
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m52s
android / android (push) Successful in 5m46s
deb / build-publish (push) Successful in 4m40s
ci / rust (push) Successful in 7m59s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
arch / build-publish (push) Successful in 9m11s
docker / deploy-docs (push) Successful in 41s
windows-host / canary-manifest (push) Successful in 22s
docker / builders-arm64cross (push) Successful in 18s
deb / build-publish-host (push) Successful in 7m50s
Reviewed-on: #161
2026-08-10 21:28:10 +00:00
enricobuehler 6336e16350 Merge pull request 'bun audit went red on the console — three findings had fixes, the other two have no patched version anywhere' (#160) from worktree-bun-audit-web into main
arch / build-publish (push) Canceled after 32s
audit / cargo-audit (push) Successful in 30s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Successful in 19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
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
audit / bun-audit (sdk) (push) Successful in 19s
audit / bun-audit (web) (push) Successful in 18s
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 1m11s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
audit / docs-site-audit (push) Successful in 20s
audit / pnpm-audit (push) Successful in 12s
audit / license-gate (push) Successful in 4m42s
nix / flake (push) Successful in 15m20s
Reviewed-on: #160
2026-08-10 21:27:42 +00:00
enricobuehler f62a48d4a9 feat(library): launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 5m40s
A launcher tile (role: "launcher", design D4) shipped no art on purpose:
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 the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.

Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).

A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload 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, exactly as before, and the fix is a PR
adding the master.

assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.

scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). 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. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.

All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.

Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.

api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).

Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
2026-08-10 23:26:47 +02:00
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 ad806be70a fix(deps): close the dompurify, js-yaml and nanoid advisories in web
ci / rust-arm64 (pull_request) Failing after 5s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Failing after 1m19s
ci / bun-nix (pull_request) Successful in 25s
ci / rust (pull_request) Successful in 6m47s
nix / flake (pull_request) Successful in 13m37s
The audit workflow's blocking `bun audit` leg went red on web with five
findings; sdk and plugin-kit stayed clean. Three had published fixes and are
bumped through the existing overrides block:

  * dompurify  3.4.12 → 3.4.13  GHSA-55q2-fjhq-7xh7 (IN_PLACE hook removal
    leaves a detached subtree executable). The override was already pinned at
    ^3.4.12, which the new advisory range swallowed.
  * js-yaml    4.3.0  → 4.3.1   GHSA-5p4m-2wfm-xmqj (quadratic CPU in !!omap).
  * nanoid     3.3.16 → 3.3.18  GHSA-2v37-7h3g-55p8 (custom generators loop
    forever at size zero). New override — it arrives under postcss, which was
    already pinned, but the pin does not reach postcss's own dependency.

The remaining two are the image-size infinite-loop DoS pair
(GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq), and they are ignored in the
workflow rather than fixed, because they are both unfixable and unreachable:
the vulnerable range is `<= 2.0.2` and 2.0.2 is the latest published version,
so no override can clear them; and image-size rides in under
@unom/ui > @payloadcms/richtext-lexical > ... > payload, where
@payloadcms/richtext-lexical is a peer that only @unom/ui's ./richtext export
needs. The console imports section, toast, button, card, dialog, form/*,
material and tabs — never ./richtext — so payload is auto-installed peer weight
that no bundle and no request path touches. The ignores are scoped to the web
matrix leg so sdk and plugin-kit stay strictly fail-on-any, and the reasoning
is recorded at the step so they can be dropped once image-size ships a fix or
@unom/ui marks that peer optional.

bun.nix is regenerated by the bun2nix postinstall; scripts/ci/check-bun-nix.sh
reports both bun packages in sync. web build, typecheck and server tests pass.
2026-08-10 23:09:57 +02:00
88 changed files with 1817 additions and 117 deletions
+20 -1
View File
@@ -91,8 +91,27 @@ jobs:
# advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by # advisory, the same fail-on-vulnerability stance as cargo-audit above; triage a finding by
# bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and # bumping the dep (or, if genuinely unfixable + inapplicable, pinning a resolution and
# noting why here). # noting why here).
#
# web carries two ignores, the ONLY ones in a blocking tree — both image-size advisories
# (GHSA-w3rx-r6r6-pgpr ICNS, GHSA-5p2g-fcmc-qvqq JXL/HEIF infinite-loop DoS). They are
# unfixable AND unreachable:
# * unfixable — the vulnerable range is `<= 2.0.2` and 2.0.2 IS latest; upstream has
# published no patched release, so no override can clear them.
# * unreachable — image-size rides in under `@unom/ui @payloadcms/richtext-lexical
# … payload`, and @payloadcms/richtext-lexical is a PEER of @unom/ui that only its
# `./richtext` export needs. The console imports section/toast/button/card/dialog/
# form/*/material/tabs — never `./richtext` — so payload is auto-installed peer weight
# that no bundle, and no request path, ever touches.
# Drop these the moment image-size ships a fix, or @unom/ui marks that peer optional
# (peerDependenciesMeta) and the chain leaves web/bun.lock entirely — either one makes the
# bare `bun audit` green again. Scoped per-tree so sdk/plugin-kit stay strictly fail-on-any.
- name: bun audit - name: bun audit
run: bun audit run: |
if [ "${{ matrix.tree }}" = "web" ]; then
bun audit --ignore=GHSA-w3rx-r6r6-pgpr --ignore=GHSA-5p2g-fcmc-qvqq
else
bun audit
fi
# Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure # Kept OUT of the bun-audit matrix so this tree's known-advisory state can't normalize failure
# in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which # in a shipping tree. Non-blocking via a step-level `||` (NOT job-level continue-on-error, which
+30
View File
@@ -12,6 +12,36 @@ with the version table of the release you are moving to, then read **Breaking ch
--- ---
## v0.27.1 — in development
### NixOS + KDE — session detection, the other half
🛑 **v0.27.0's NixOS session-detection fix did not reach a stock NixOS + Plasma 6 box.** It resolved
the nixpkgs wrapper decoration through `/proc/<pid>/exe` (below) — and on that exact box the kernel
refuses to let us read that link. Reading `/proc/<pid>/exe` is not gated on owning the process: it
goes through `cap_ptrace_access_check`, which requires the reader's effective set to be a superset
of the target's **permitted** set. NixOS's own Plasma module ships
`security.wrappers.kwin_wayland = { capabilities = "cap_sys_nice+ep"; }`, so KWin holds a capability
and the host — which must stay uncapped, because a capability is exactly what makes it
unidentifiable to KWin (v0.27.0, above) — gets `EACCES`. The two traps compose: the name *needs*
`exe` because nixpkgs wrapped the binary, and `exe` is *denied* because NixOS capped it. Detection
went straight back to `ActiveKind::None`, `wayland` to `-`, and every connect to
`no usable compositor`. It presents identically to the v0.27.0 bug, which is why a box that had been
worked around with a decoy process broke again the moment the decoy was removed.
Name resolution now falls through to `argv[0]` (`/proc/<pid>/cmdline`) when the kernel refuses `exe`.
That reads correctly for the same reason `ps` does: make-wrapper's wrapper `exec -a "$0"`s the hidden
binary, so `argv[0]` survives the decoration `comm` does not. Measured on Linux 6.x against a capped
target, for a file capability and for the ambient form `security.wrappers` uses, identically: the
`/proc/<pid>` directory keeps its real owner (so the uid filter was never the problem), `comm` and
`cmdline` stay readable, and only `exe` fails. `argv[0]` is consulted **last** and never overrides a
readable `exe` — it is the process's own claim about itself, and a same-uid process can set it to
anything; the worst a spoof achieves is aiming detection at a backend that then fails its own
availability probe. The `comm` fast path is still one read for every ordinary distro.
Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a
*wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe.
## v0.27.0 ## v0.27.0
87 commits since v0.26.0. 87 commits since v0.26.0.
+30 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.25.0" "version": "0.26.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -4489,6 +4489,13 @@
], ],
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
}, },
"icon": {
"type": [
"string",
"null"
],
"description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL."
},
"id": { "id": {
"type": "string", "type": "string",
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
@@ -4555,6 +4562,13 @@
"$ref": "#/components/schemas/DetectHint", "$ref": "#/components/schemas/DetectHint",
"description": "How to recognize this title's process — see [`CustomEntry::detect`]." "description": "How to recognize this title's process — see [`CustomEntry::detect`]."
}, },
"icon": {
"type": [
"string",
"null"
],
"description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one."
},
"launch": { "launch": {
"oneOf": [ "oneOf": [
{ {
@@ -5350,6 +5364,14 @@
"art": { "art": {
"$ref": "#/components/schemas/Artwork" "$ref": "#/components/schemas/Artwork"
}, },
"icon": {
"type": [
"string",
"null"
],
"description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.",
"example": "steam"
},
"id": { "id": {
"type": "string", "type": "string",
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.", "description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
@@ -6869,6 +6891,13 @@
"type": "string", "type": "string",
"description": "The provider's stable id for this title (the reconcile diff key)." "description": "The provider's stable id for this title (the reconcile diff key)."
}, },
"icon": {
"type": [
"string",
"null"
],
"description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists."
},
"launch": { "launch": {
"oneOf": [ "oneOf": [
{ {
@@ -0,0 +1,17 @@
Font Awesome Free — brand icons (steam, xbox in assets/launcher-icons/) are from
Font Awesome Free.
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
The icons are redistributed here as monochrome SVG path data with no
modifications beyond color normalization (fill="currentColor").
Per the Font Awesome Free license (https://fontawesome.com/license/free):
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Attribution is required by MIT, SIL OFL, and CC BY licenses."
Brand icons are trademarks of their respective owners and are used for
identification purposes only; their use does not imply endorsement.
@@ -0,0 +1,29 @@
Playnite — the `playnite` mark in assets/launcher-icons/ is the Playnite logo from the
Playnite source repository (media/playnite-logo-black.svg).
Copyright (c) 2020 Josef Nemec (https://github.com/JosefNemec/Playnite)
Licensed under the MIT License:
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Modifications: the colour was normalized to fill="currentColor"; the original viewBox
(0 0 1024 1024) and path geometry are unchanged.
Brand icons are trademarks of their respective owners and are used for identification
purposes only; their use does not imply endorsement.
@@ -0,0 +1,13 @@
Simple Icons — brand icons (lutris, heroic, epic, gog in assets/launcher-icons/) are
from Simple Icons
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
Upstream slugs: lutris, heroicgameslauncher, epicgames, gogdotcom.
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
required; this notice is provided for provenance.
Brand icons are trademarks of their respective owners and are used for
identification purposes only; their use does not imply endorsement. See
https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md.
+62
View File
@@ -0,0 +1,62 @@
# Launcher icon masters
The brand marks a **launcher tile** draws — the entries a library plugin publishes with
`role: "launcher"` (design D4), which open Steam Big Picture or Heroic or Playnite rather
than a game. One file per **icon token**, the value a plugin puts in an entry's `icon`
field and every client resolves against the set it ships.
| token | mark | emitted by | source |
|---|---|---|---|
| `steam` | Steam | punktfunk-plugin-steam (Big Picture + desktop) | Font Awesome Free brands (CC BY 4.0) |
| `lutris` | Lutris | punktfunk-plugin-lutris | Simple Icons (CC0 1.0) |
| `heroic` | Heroic Games Launcher | punktfunk-plugin-heroic | Simple Icons (CC0 1.0, slug `heroicgameslauncher`) |
| `playnite` | Playnite | punktfunk-plugin-playnite | JosefNemec/Playnite (MIT) |
| `epic` | Epic Games | punktfunk-plugin-epic — **dormant** | Simple Icons (CC0 1.0, slug `epicgames`) |
| `gog` | GOG.com | punktfunk-plugin-gog — **dormant** | Simple Icons (CC0 1.0, slug `gogdotcom`) |
| `xbox` | Xbox | punktfunk-plugin-xbox — **dormant** | Font Awesome Free brands (CC BY 4.0) |
The last three are **dormant on purpose**: those plugins carry a `launcher` config switch that
is off by default and whose `launcherEntries` returns nothing, because the host has no verified
`launcher_ui` activation for them yet — a tile would be a card that does nothing. Their marks
ship anyway so that turning one on stays the one-line plugin change those plugins promise,
instead of also needing a release of all six clients.
`steam` is the same mark as `assets/os-icons/steam.svg`, generated from that file rather than
re-sourced, so the SteamOS host badge and the Steam launcher tile can never drift apart.
## Why a token and not the icon itself
A plugin sends the **name** of a mark, never its bytes, and never a URL.
The obvious alternative — a plugin ships its own `icon.svg` and the host's art proxy serves it —
is closed by construction, and deliberately: `local_art_bytes` serves what the bytes *are*
(`sniff_image_type`, `crates/punktfunk-host/src/library/art.rs`), and SVG is not on that list
because it is script-capable XML and the web console renders library art in a browser. Widening
that sniff to admit SVG would trade a rendering nicety for a stored-XSS surface.
Sending a token instead keeps that refusal intact and buys three things a proxied image could
not have given us anyway: the glyph stays vector at every tile size a client picks, it takes the
tile's own ink instead of arriving pre-coloured, and it costs no fetch, no cache and no bytes on
a reconcile that is already body-limited.
The cost is that a **third-party** plugin cannot ship a mark no client bundles. Its tile falls
back to the launcher's name on an accent face — exactly what every launcher tile looked like
before this existed — and the fix is a pull request adding the master here.
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Those
viewBoxes are not all square (`0 0 24 24`, `0 0 496 512`, `0 0 1024 1024`), so **a client must
letterbox rather than stretch** — a mark drawn to a square box is a squashed mark.
## Regenerating the per-client derivatives
`bash scripts/gen-launcher-icons.sh [token ...]` turns a master into the three baked forms (GTK
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
that inline it (web console, Android, the in-session console UI). Adding a **new** token also
means adding it to each client's shipped-token list — the script prints that checklist too.
## Licensing
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
are used here nominatively — to *identify* the launcher a tile opens, the standard practice in
this ecosystem — and imply no affiliation or endorsement.
+2
View File
@@ -0,0 +1,2 @@
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>

After

Width:  |  Height:  |  Size: 957 B

+2
View File
@@ -0,0 +1,2 @@
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+2
View File
@@ -0,0 +1,2 @@
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>

After

Width:  |  Height:  |  Size: 956 B

+2
View File
@@ -0,0 +1,2 @@
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -22,6 +22,7 @@ import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -54,6 +55,7 @@ import androidx.compose.ui.unit.sp
import coil.ImageLoader import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import io.unom.punktfunk.components.launcherIcon
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
import io.unom.punktfunk.kit.library.GameEntry import io.unom.punktfunk.kit.library.GameEntry
import io.unom.punktfunk.kit.library.LibraryClient import io.unom.punktfunk.kit.library.LibraryClient
@@ -367,16 +369,28 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
) )
} else { } else {
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title // A launcher ships no poster by design, so its brand mark IS the poster — drawn big and
// would read as "a game whose cover failed to load". // centred, tinted like the text it replaces. Falling back to the launcher's name says
Text( // "opens Steam" for a mark we don't ship; the title would read as "a game whose cover
if (game.isLauncher) game.storeLabel else game.title, // failed to load".
style = MaterialTheme.typography.titleMedium, val mark = launcherIcon(game.iconToken)
fontWeight = FontWeight.SemiBold, if (mark != null) {
color = ink.fg(0.75f), Icon(
textAlign = TextAlign.Center, imageVector = mark,
modifier = Modifier.padding(12.dp), contentDescription = game.title,
) tint = ink.fg(0.75f),
modifier = Modifier.fillMaxSize(0.45f),
)
} else {
Text(
if (game.isLauncher) game.storeLabel else game.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = ink.fg(0.75f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(12.dp),
)
}
} }
// Store badge, top-start — brand-filled for a launcher entry (design D4). // Store badge, top-start — brand-filled for a launcher entry (design D4).
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) { Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
@@ -0,0 +1,99 @@
package io.unom.punktfunk.components
// GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.
// Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.
// Per-mark provenance and licensing: assets/launcher-icons/README.md.
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.dp
import kotlin.math.max
/**
* The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
* Material ships no brand icons, so this is a curated registry the sibling of [OsIcons],
* which does the equivalent job for the host cards.
*
* Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds
* the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT
* all square, so the builder letterboxes a mark forced into a square box is a squashed mark.
*/
private class LauncherGlyph(
val viewportWidth: Float,
val viewportHeight: Float,
val d: String,
)
private val GLYPHS: Map<String, LauncherGlyph> = mapOf(
"steam" to LauncherGlyph(
viewportWidth = 496f,
viewportHeight = 512f,
d = "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
),
"lutris" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z",
),
"heroic" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z",
),
"playnite" to LauncherGlyph(
viewportWidth = 1024f,
viewportHeight = 1024f,
d = "M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z",
),
"epic" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z",
),
"gog" to LauncherGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z",
),
"xbox" to LauncherGlyph(
viewportWidth = 512f,
viewportHeight = 512f,
d = "M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z",
),
)
private val CACHE = HashMap<String, ImageVector>()
/**
* The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark
* this build ships no art for the caller then falls back to naming the launcher, which is
* what every launcher tile looked like before the token existed.
*
* Tinted by the caller via `tint`, so one mark serves every palette.
*/
fun launcherIcon(token: String?): ImageVector? {
val glyph = GLYPHS[token ?: return null] ?: return null
return CACHE.getOrPut(token) {
// Square the box and centre the mark in it, so a wide or tall master keeps its aspect
// ratio instead of being stretched to the tile.
val side = max(glyph.viewportWidth, glyph.viewportHeight)
val dx = (side - glyph.viewportWidth) / 2f
val dy = (side - glyph.viewportHeight) / 2f
ImageVector.Builder(
name = "launcher_$token",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = side,
viewportHeight = side,
).apply {
addGroup(translationX = dx, translationY = dy)
addPath(
pathData = PathParser().parsePathString(glyph.d).toNodes(),
fill = SolidColor(Color.White),
)
clearGroup()
}.build()
}
}
@@ -44,6 +44,9 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable * opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a * String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
* game rather than break the decode (design D4). * game rather than break the decode (design D4).
*
* [icon] is the token for the entry's brand mark (`"steam"`, `"heroic"`) never art, never a URL.
* Null on every older host and on every ordinary title.
*/ */
data class GameEntry( data class GameEntry(
val id: String, val id: String,
@@ -51,12 +54,25 @@ data class GameEntry(
val title: String, val title: String,
val art: Artwork, val art: Artwork,
val role: String? = null, val role: String? = null,
val icon: String? = null,
) { ) {
val isCustom: Boolean get() = store == "custom" val isCustom: Boolean get() = store == "custom"
/** Whether this entry opens a launcher rather than a game. */ /** Whether this entry opens a launcher rather than a game. */
val isLauncher: Boolean get() = role == "launcher" val isLauncher: Boolean get() = role == "launcher"
/**
* The brand-icon token, re-validated rather than taken on trust.
*
* The host checks the shape on the way in, so this only fires for a host older than that
* check or one that isn't ours. It costs a scan of a short string and means no consumer has
* to wonder what it is about to look up.
*/
val iconToken: String? get() = icon?.takeIf { t ->
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
}
/** /**
* Display name for the store badge the same table the other clients use * Display name for the store badge the same table the other clients use
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom * (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
@@ -151,6 +167,7 @@ object LibraryClient {
hero = resolveArt(str(art, "hero"), base), hero = resolveArt(str(art, "hero"), base),
), ),
role = str(o, "role"), role = str(o, "role"),
icon = str(o, "icon"),
), ),
) )
} }
+4
View File
@@ -50,6 +50,10 @@ let package = Package(
// licensing in that README). `.process` compiles the catalog; loaded via // licensing in that README). `.process` compiles the catalog; loaded via
// OsIcon.swift. // OsIcon.swift.
.process("Resources/OsIcons.xcassets"), .process("Resources/OsIcons.xcassets"),
// The launcher tiles' brand marks (template vector imagesets generated from the
// assets/launcher-icons masters by scripts/gen-launcher-icons.sh per-mark
// provenance and licensing in that README). Loaded via LauncherIcon.swift.
.process("Resources/LauncherIcons.xcassets"),
], ],
linkerSettings: [ linkerSettings: [
// Rust staticlib system deps. // Rust staticlib system deps.
@@ -125,7 +125,7 @@ struct LibraryCoverflowView: View {
) -> some View { ) -> some View {
PosterImage( PosterImage(
candidates: game.art.posterCandidates, title: game.title, loader: artLoader, candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
onLoaded: { artSettled += 1 }) icon: game.iconToken, onLoaded: { artSettled += 1 })
.frame(width: width, height: height) .frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
@@ -346,7 +346,9 @@ private struct GameCard: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader) PosterImage(
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
icon: game.iconToken)
.aspectRatio(2.0 / 3.0, contentMode: .fit) .aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
@@ -71,6 +71,9 @@ struct PosterImage: View {
let candidates: [URL] let candidates: [URL]
let title: String let title: String
let loader: LibraryArtLoader? let loader: LibraryArtLoader?
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
/// no cover art by design, so for those the mark IS the poster see `placeholder`.
var icon: String?
/// Fires once this poster has settled art loaded, or every candidate exhausted and the /// Fires once this poster has settled art loaded, or every candidate exhausted and the
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before /// placeholder is what it will be. The gamepad coverflow waits on a few of these before
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles. /// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
@@ -121,11 +124,26 @@ struct PosterImage: View {
private var placeholder: some View { private var placeholder: some View {
ZStack { ZStack {
Rectangle().fill(.quaternary) Rectangle().fill(.quaternary)
Text(title) // A launcher's brand mark, drawn at poster size and tinted like the text it replaces.
.font(.geist(17, .semibold, relativeTo: .headline)) // `scaledToFit` inside a fraction of the card keeps a non-square master (the Steam mark
.multilineTextAlignment(.center) // is 496×512, Playnite's 1024×1024) in its own aspect ratio rather than stretched.
.foregroundStyle(.secondary) // Falling back to the title is the pre-icon design, so an unshipped mark loses nothing.
.padding(8) if let mark = launcherIconImage(for: icon) {
GeometryReader { geo in
mark
.resizable()
.scaledToFit()
.foregroundStyle(.secondary)
.frame(width: geo.size.width * 0.44, height: geo.size.height * 0.44)
.frame(width: geo.size.width, height: geo.size.height)
}
} else {
Text(title)
.font(.geist(17, .semibold, relativeTo: .headline))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(8)
}
} }
} }
} }
@@ -47,12 +47,28 @@ public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
/// optional String: the host owns the vocabulary, and an unknown future value must never fail /// optional String: the host owns the vocabulary, and an unknown future value must never fail
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4). /// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
public var role: String? public var role: String?
/// The token for this entry's brand mark (`"steam"`, `"heroic"`) never art, never a URL.
/// `nil` on every older host and on every ordinary title. See `launcherIconImage`.
public var icon: String?
public var isCustom: Bool { store == "custom" } public var isCustom: Bool { store == "custom" }
/// Whether this entry opens a launcher rather than a game. /// Whether this entry opens a launcher rather than a game.
public var isLauncher: Bool { role == "launcher" } public var isLauncher: Bool { role == "launcher" }
/// The brand-icon token, re-validated rather than taken on trust.
///
/// The host checks the shape on the way in, so this can only fire for a host older than that
/// check or one that isn't ours. The value reaches `Image(named:)`, and "the peer promised" is
/// not the standard a name lookup deserves.
public var iconToken: String? {
guard let t = icon, !t.isEmpty, t.count <= 32,
let first = t.first, first.isASCII, first.isLowercase,
t.allSatisfy({ $0.isASCII && ($0.isLowercase || $0.isNumber || $0 == "-") })
else { return nil }
return t
}
/// Display name for the store badge the same table the Rust clients use /// Display name for the store badge the same table the Rust clients use
/// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for /// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for
/// every non-custom entry, which a Lutris or GOG title made a lie. /// every non-custom entry, which a Lutris or GOG title made a lie.
@@ -0,0 +1,26 @@
// The launcher tiles' brand marks: template vector imagesets in Resources/LauncherIcons.xcassets
// (generated from the repo's assets/launcher-icons masters by scripts/gen-launcher-icons.sh
// per-mark provenance and licensing in that directory's README), resolved from a library entry's
// `icon` token. Template rendering means they tint with `foregroundStyle` like an SF Symbol.
//
// The sibling of OsIcon.swift, which does the equivalent job for the host cards' OS marks. SF
// Symbols ships no third-party brand glyphs, so a curated registry is the only route.
import SwiftUI
/// The icon tokens this client ships art for. A token outside this set draws nothing and the tile
/// falls back to naming its launcher which is what every launcher tile looked like before icons
/// existed, so an unknown mark degrades to the old design rather than to a hole.
///
/// Checked against rather than interpolated: `Image(named:)` is a name lookup, and the set is the
/// only thing that decides which names it can ever see.
private let launcherIconTokensShipped: Set<String> = [
"steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox",
]
/// The brand mark for a library entry's `icon` token, or nil no view at all when the entry
/// carries no token or names one this client ships no art for.
public func launcherIconImage(for token: String?) -> Image? {
guard let token, launcherIconTokensShipped.contains(token) else { return nil }
return Image("launcher-\(token)", bundle: .module)
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "epic.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "gog.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "heroic.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "lutris.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "playnite.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "steam.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "xbox.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,2 @@
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,2 @@
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,2 @@
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>

After

Width:  |  Height:  |  Size: 952 B

@@ -0,0 +1,2 @@
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,2 @@
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="#000000"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,2 @@
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>

After

Width:  |  Height:  |  Size: 951 B

@@ -0,0 +1,2 @@
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#000000"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+9 -1
View File
@@ -1,9 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- The shell's embedded icon assets: the host-card OS marks (derived from the <!-- The shell's embedded icon assets: the host-card OS marks (derived from the
assets/os-icons masters; see that directory's README for provenance/licensing). assets/os-icons masters) and the library's launcher-tile brand marks (from the
assets/launcher-icons masters). See each directory's README for provenance/licensing.
Registered under the hicolor-style layout IconTheme::add_resource_path expects. --> Registered under the hicolor-style layout IconTheme::add_resource_path expects. -->
<gresources> <gresources>
<gresource prefix="/io/unom/Punktfunk"> <gresource prefix="/io/unom/Punktfunk">
<file>icons/scalable/actions/pf-launcher-steam-symbolic.svg</file>
<file>icons/scalable/actions/pf-launcher-lutris-symbolic.svg</file>
<file>icons/scalable/actions/pf-launcher-heroic-symbolic.svg</file>
<file>icons/scalable/actions/pf-launcher-playnite-symbolic.svg</file>
<file>icons/scalable/actions/pf-launcher-epic-symbolic.svg</file>
<file>icons/scalable/actions/pf-launcher-gog-symbolic.svg</file>
<file>icons/scalable/actions/pf-launcher-xbox-symbolic.svg</file>
<file>icons/scalable/actions/pf-os-windows-symbolic.svg</file> <file>icons/scalable/actions/pf-os-windows-symbolic.svg</file>
<file>icons/scalable/actions/pf-os-apple-symbolic.svg</file> <file>icons/scalable/actions/pf-os-apple-symbolic.svg</file>
<file>icons/scalable/actions/pf-os-linux-symbolic.svg</file> <file>icons/scalable/actions/pf-os-linux-symbolic.svg</file>
+3
View File
@@ -66,6 +66,9 @@ const CSS: &str = "
named instead of a title monogram, and an accent badge. */ named instead of a title monogram, and an accent badge. */
.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); } .pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); }
.pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); } .pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); }
/* The brand mark when we ship one for this launcher — same ink as the name it replaces, so the
two fallback rungs read as one design rather than two. */
.pf-poster-launcher-mark { color: alpha(currentColor, 0.85); }
.pf-store-badge.pf-launcher { color: white; background: @accent_color; } .pf-store-badge.pf-launcher { color: white; background: @accent_color; }
.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); } .pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); }
"; ";
+1
View File
@@ -782,6 +782,7 @@ fn mock_library() -> (
art: crate::library::Artwork::default(), art: crate::library::Artwork::default(),
platform: None, platform: None,
role: None, role: None,
icon: None,
}; };
let games = vec![ let games = vec![
game("steam:570", "steam", "Dota 2"), game("steam:570", "steam", "Dota 2"),
+49 -15
View File
@@ -285,27 +285,61 @@ fn render(state: &Rc<State>, games: &[GameEntry]) {
.set_visible(!launchers.is_empty() && !titles.is_empty()); .set_visible(!launchers.is_empty() && !titles.is_empty());
} }
/// The launcher-tile brand marks this shell ships symbolic art for
/// (`data/icons/.../pf-launcher-<t>-symbolic.svg`, embedded via gresource). A plugin may name a
/// mark a newer build carries; an entry whose token isn't here falls back to the launcher's name,
/// which is exactly how every launcher tile looked before icons existed.
const LAUNCHER_ICON_TOKENS: &[&str] = &[
"steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox",
];
/// The poster-sized brand mark for an entry, or `None` when it carries no token, names one we
/// don't ship, or already has real artwork (a plugin that sent a cover has out-voted the token).
///
/// Symbolic, so it recolors with the Adwaita theme like every other glyph in the shell.
fn launcher_icon_image(game: &GameEntry) -> Option<gtk::Image> {
if !game.art.is_empty() {
return None;
}
let token = game.icon_token()?;
if !LAUNCHER_ICON_TOKENS.contains(&token) {
return None;
}
let img = gtk::Image::from_icon_name(&format!("pf-launcher-{token}-symbolic"));
img.set_pixel_size(72);
img.add_css_class("pf-poster-launcher-mark");
img.set_halign(gtk::Align::Center);
img.set_valign(gtk::Align::Center);
img.set_vexpand(true);
Some(img)
}
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a /// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
/// monogram placeholder underneath the async art. Activation starts a session launching /// monogram placeholder underneath the async art. Activation starts a session launching
/// this title (silent on a pinned host — the normal trust gate applies). /// this title (silent on a pinned host — the normal trust gate applies).
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild { fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens // A launcher usually ships no poster. Its brand mark, when we ship one, IS the poster; failing
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load". // that, naming the launcher on an accent face says "opens Steam". A title monogram on the
// neutral face would say "a game whose cover didn't load", which is why games keep it.
let launcher = game.is_launcher(); let launcher = game.is_launcher();
let monogram = if launcher {
let l = gtk::Label::new(Some(store_label(&game.store)));
l.add_css_class("pf-poster-launcher-name");
l
} else {
let l = gtk::Label::new(Some(&initials(&game.title)));
l.add_css_class("pf-poster-monogram");
l
};
monogram.set_halign(gtk::Align::Center);
monogram.set_valign(gtk::Align::Center);
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0); let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
placeholder.append(&monogram); if let Some(mark) = launcher_icon_image(game) {
monogram.set_vexpand(true); placeholder.append(&mark);
} else {
let monogram = if launcher {
let l = gtk::Label::new(Some(store_label(&game.store)));
l.add_css_class("pf-poster-launcher-name");
l
} else {
let l = gtk::Label::new(Some(&initials(&game.title)));
l.add_css_class("pf-poster-monogram");
l
};
monogram.set_halign(gtk::Align::Center);
monogram.set_valign(gtk::Align::Center);
monogram.set_vexpand(true);
placeholder.append(&monogram);
}
let pic = gtk::Picture::new(); let pic = gtk::Picture::new();
pic.set_content_fit(gtk::ContentFit::Cover); pic.set_content_fit(gtk::ContentFit::Cover);
+2
View File
@@ -859,6 +859,7 @@ fn spawn_fetch(
title: g.title.clone(), title: g.title.clone(),
store: g.store.clone(), store: g.store.clone(),
launcher: g.is_launcher(), launcher: g.is_launcher(),
icon: g.icon_token().unwrap_or_default().to_string(),
}) })
.collect(), .collect(),
); );
@@ -900,6 +901,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
title: g.title.clone(), title: g.title.clone(),
store: g.store.clone(), store: g.store.clone(),
launcher: g.is_launcher(), launcher: g.is_launcher(),
icon: g.icon_token().unwrap_or_default().to_string(),
}) })
.collect(), .collect(),
); );
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+71
View File
@@ -0,0 +1,71 @@
//! 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('\\', "/")))
}
+37 -17
View File
@@ -43,6 +43,10 @@ pub(crate) struct Game {
/// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that /// 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. /// isn't `launcher` is a game" is decided in one place for every client.
pub(crate) launcher: bool, 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)] #[derive(Clone, PartialEq, Default)]
@@ -140,6 +144,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
title: g.title.clone(), title: g.title.clone(),
store: g.store.clone(), store: g.store.clone(),
launcher: g.is_launcher(), launcher: g.is_launcher(),
icon_uri: super::launcher_icons::uri(g.icon_token()),
}) })
.collect(), .collect(),
); );
@@ -245,23 +250,38 @@ fn poster_tile(
.height(poster_h) .height(poster_h)
.into(), .into(),
// A launcher rarely has poster art, and an art-less launcher drawn like an art-less game // A launcher rarely has poster art, and an art-less launcher drawn like an art-less game
// reads as "a game whose cover failed to load". So it names its launcher instead of // reads as "a game whose cover failed to load". Its brand mark, when we ship one, IS the
// showing a title monogram, and the frame below picks up the accent stroke. // poster; failing that it names its launcher. Either way the frame below picks up the
None => border( // accent stroke.
text_block(if game.launcher { //
store_label(&game.store).to_string() // `Uniform`, not `UniformToFill`: the marks keep their masters' aspect ratios (Steam is
} else { // 496x512, Playnite 1024x1024), and filling a 2:3 frame would crop them to a strip — the
initials(&game.title) // very thing that kept launcher tiles art-less in the first place.
}) None => match game.icon_uri.as_deref() {
.font_size(if game.launcher { 18.0 } else { 28.0 }) Some(uri) => border(
.semibold() Image::new_with_uri(uri)
.foreground(ThemeRef::SecondaryText) .stretch(Stretch::Uniform)
.horizontal_alignment(HorizontalAlignment::Center) .margin(uniform(poster_h * 0.28)),
.vertical_alignment(VerticalAlignment::Center), )
) .background(ThemeRef::SubtleFill)
.background(ThemeRef::SubtleFill) .height(poster_h)
.height(poster_h) .into(),
.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(),
},
}; };
let framed = border(grid(vec![ let framed = border(grid(vec![
poster, poster,
+4 -1
View File
@@ -44,6 +44,7 @@
mod connect; mod connect;
mod help; mod help;
mod hosts; mod hosts;
mod launcher_icons;
mod library; mod library;
mod licenses; mod licenses;
mod os_icons; mod os_icons;
@@ -186,8 +187,10 @@ pub struct AppCtx {
} }
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> { pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
// The host tiles' OS marks load as file:/// URIs — put the embedded PNGs on disk first. // The host tiles' OS marks and the library's launcher marks load as file:/// URIs — put the
// embedded PNGs on disk first.
os_icons::install(); os_icons::install();
launcher_icons::install();
let ctx = Arc::new(AppCtx { let ctx = Arc::new(AppCtx {
identity, identity,
settings: Mutex::new(Settings::load()), settings: Mutex::new(Settings::load()),
+35
View File
@@ -47,6 +47,13 @@ impl Artwork {
}) })
.collect() .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>`, /// One title in the host's unified library. `id` is store-qualified (`steam:<appid>`,
@@ -73,6 +80,17 @@ pub struct GameEntry {
/// future value must never fail the whole library decode. /// future value must never fail the whole library decode.
#[serde(default)] #[serde(default)]
pub role: Option<String>, 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 { impl GameEntry {
@@ -80,6 +98,23 @@ impl GameEntry {
pub fn is_launcher(&self) -> bool { pub fn is_launcher(&self) -> bool {
self.role.as_deref() == Some("launcher") 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"). /// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
+102
View File
@@ -0,0 +1,102 @@
//! 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,6 +18,8 @@ mod anim;
#[cfg(any(target_os = "linux", windows))] #[cfg(any(target_os = "linux", windows))]
mod glyphs; mod glyphs;
#[cfg(any(target_os = "linux", windows))] #[cfg(any(target_os = "linux", windows))]
mod launcher_icons;
#[cfg(any(target_os = "linux", windows))]
pub mod library; pub mod library;
#[cfg(any(target_os = "linux", windows))] #[cfg(any(target_os = "linux", windows))]
pub mod model; pub mod model;
+6
View File
@@ -569,6 +569,10 @@ pub struct LibraryGame {
/// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't /// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't
/// `launcher` is a game" rule lives in exactly one place. /// `launcher` is a game" rule lives in exactly one place.
pub launcher: bool, 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 { struct Shared {
@@ -689,6 +693,7 @@ mod tests {
title: title.to_string(), title: title.to_string(),
store: "steam".into(), store: "steam".into(),
launcher, launcher,
icon: String::new(),
}; };
let shared = LibraryShared::default(); let shared = LibraryShared::default();
shared.set_games(vec![ shared.set_games(vec![
@@ -717,6 +722,7 @@ mod tests {
title: (*t).to_string(), title: (*t).to_string(),
store: "steam".into(), store: "steam".into(),
launcher: false, launcher: false,
icon: String::new(),
}) })
.collect(), .collect(),
); );
+37 -15
View File
@@ -431,22 +431,44 @@ impl LibraryScreen {
Color4f::new(0.118, 0.118, 0.145, 1.0) Color4f::new(0.118, 0.118, 0.145, 1.0)
}; };
canvas.draw_rect(crect, &Paint::new(face, None)); canvas.draw_rect(crect, &Paint::new(face, None));
let (glyph, size, ink) = if game.launcher { // The launcher's brand mark IS the poster when we ship one for it. Inset to
(store_label(&game.store).to_string(), 22.0 * k, fg(0.85)) // ~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));
} else { } else {
(initials(&game.title), 38.0 * k, fg(0.45)) let (glyph, size, ink) = if game.launcher {
}; (store_label(&game.store).to_string(), 22.0 * k, fg(0.85))
let font = fonts.font(W::Bold, size); } else {
let tw = font.measure_str(&glyph, None).0; (initials(&game.title), 38.0 * k, fg(0.45))
canvas.draw_str( };
&glyph, let font = fonts.font(W::Bold, size);
Point::new( let tw = font.measure_str(&glyph, None).0;
(card_w as f32 - tw) / 2.0, canvas.draw_str(
card_h as f32 / 2.0 + 13.0 * k as f32, &glyph,
), Point::new(
&font, (card_w as f32 - tw) / 2.0,
&Paint::new(ink, None), card_h as f32 / 2.0 + 13.0 * k as f32,
); ),
&font,
&Paint::new(ink, None),
);
}
} }
} }
// Store badge, top-left. // Store badge, top-left.
+1
View File
@@ -394,6 +394,7 @@ fn dump_console_screens() {
title: (*t).to_string(), title: (*t).to_string(),
store: "steam".into(), store: "steam".into(),
launcher: false, launcher: false,
icon: String::new(),
}) })
.collect(), .collect(),
); );
+159 -13
View File
@@ -135,7 +135,37 @@ const COMM_MAX: usize = 15;
/// ///
/// The `comm` fast path is kept for every ordinary distro: one read, no readlink. Only a name that /// 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 — /// *could* be decorated or truncated — it starts with `.`, or it is exactly [`COMM_MAX`] bytes —
/// is re-resolved through `/proc/<pid>/exe`, which carries the full, untruncated file name. /// 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. /// `pid_path` is a `/proc/<pid>` directory. `None` when the process vanished mid-scan.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -146,20 +176,48 @@ pub(crate) fn match_name(pid_path: &std::path::Path) -> Option<String> {
if !comm.starts_with('.') && comm.len() < COMM_MAX { if !comm.starts_with('.') && comm.len() < COMM_MAX {
return Some(comm.to_string()); return Some(comm.to_string());
} }
// Reading our OWN uid's `/proc/<pid>/exe` needs no privilege (every caller filters on uid // The authoritative rung: the kernel's own record of the executed file, untruncated. Absent for
// first), but it is still absent for a kernel thread and for a process exiting under us // a kernel thread and for a process exiting under us, and REFUSED for a capability-holding one.
// in which case the truncated `comm` is the best that exists. let exe = std::fs::read_link(pid_path.join("exe")).ok();
match std::fs::read_link(pid_path.join("exe")) if let Some(full) = exe
.ok()
.as_deref() .as_deref()
.and_then(|p| p.file_name()) .and_then(|p| p.file_name())
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
{ {
Some(full) => Some(undecorate(full).to_string()), 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()), 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 /// 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). /// appends when that hidden name is already taken (a doubly-wrapped app — Qt *and* GApps).
/// ///
@@ -366,8 +424,12 @@ mod name_tests {
use super::*; use super::*;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// A fake `/proc/<pid>` directory: a `comm` file and, optionally, the `exe` symlink. Removed on /// A fake `/proc/<pid>` directory: a `comm` file and, optionally, the `exe` symlink and a
/// drop. /// `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 { struct FakePid {
dir: PathBuf, dir: PathBuf,
} }
@@ -375,6 +437,12 @@ mod name_tests {
impl FakePid { impl FakePid {
/// `comm` is written exactly as the kernel would report it — i.e. already truncated. /// `comm` is written exactly as the kernel would report it — i.e. already truncated.
fn new(tag: &str, comm: &str, exe: Option<&str>) -> FakePid { 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 dir = std::env::temp_dir().join(format!("pf-vd-name-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("fixture dir"); std::fs::create_dir_all(&dir).expect("fixture dir");
@@ -388,6 +456,9 @@ mod name_tests {
) )
.expect("exe symlink"); .expect("exe symlink");
} }
if let Some(argv0) = argv0 {
std::fs::write(dir.join("cmdline"), format!("{argv0}\0--session\0")).expect("cmd");
}
FakePid { dir } FakePid { dir }
} }
fn path(&self) -> &Path { fn path(&self) -> &Path {
@@ -505,14 +576,87 @@ mod name_tests {
assert_eq!(match_name(p.path()).as_deref(), Some("kwin_wayland")); assert_eq!(match_name(p.path()).as_deref(), Some("kwin_wayland"));
} }
/// A decorated-or-truncated name whose `exe` cannot be read (a kernel thread, or a process /// A decorated-or-truncated name with neither `exe` nor `cmdline` to fall back on (a kernel
/// exiting under the scan) degrades to the truncated `comm` instead of failing the whole entry. /// thread, or a process exiting under the scan) degrades to the truncated `comm` instead of
/// failing the whole entry.
#[test] #[test]
fn an_unreadable_exe_falls_back_to_comm() { fn an_unreadable_exe_falls_back_to_comm() {
let p = FakePid::new("noexe", ".kwin_wayland-w", None); let p = FakePid::new("noexe", ".kwin_wayland-w", None);
assert_eq!(match_name(p.path()).as_deref(), Some(".kwin_wayland-w")); 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`. /// A pid directory that does not exist yields `None`, not a bogus name — the scans `continue`.
#[test] #[test]
fn a_vanished_process_yields_none() { fn a_vanished_process_yields_none() {
@@ -520,8 +664,10 @@ mod name_tests {
} }
/// The one thing a fixture cannot establish: that reading `/proc/<pid>/exe` is actually /// The one thing a fixture cannot establish: that reading `/proc/<pid>/exe` is actually
/// *permitted* for a process of our own uid, which the whole resolver depends on. Checked /// *permitted* for a process of our own uid. Checked against the only such process guaranteed
/// against the only such process guaranteed to be running — this one. /// 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] #[test]
fn our_own_exe_link_is_readable() { fn our_own_exe_link_is_readable() {
let me = Path::new("/proc/self"); let me = Path::new("/proc/self");
+5 -1
View File
@@ -315,7 +315,11 @@ pub fn detect_active_session() -> ActiveSession {
// exactly, `pkill -x` style — but resolved through [`crate::proc::match_name`], NOT a raw // 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 // `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 // `.<name>-wrapped`, so a raw `comm` says `.kwin_wayland-w` and this whole probe answered
// `None` on a running KDE desktop. // `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 kind = ActiveKind::None;
let mut best = 0u8; let mut best = 0u8;
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the // The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
+54
View File
@@ -163,6 +163,40 @@ 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. /// One title in the unified library, regardless of which store it came from.
#[derive(Clone, Debug, Serialize, ToSchema)] #[derive(Clone, Debug, Serialize, ToSchema)]
pub struct GameEntry { pub struct GameEntry {
@@ -177,6 +211,25 @@ pub struct GameEntry {
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. /// Whether this entry is a game or the launcher itself — see [`GameRole`].
#[serde(default, skip_serializing_if = "GameRole::is_game")] #[serde(default, skip_serializing_if = "GameRole::is_game")]
pub role: GameRole, 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. /// How the host would launch it, when known.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>, pub launch: Option<LaunchSpec>,
@@ -388,6 +441,7 @@ mod tests {
title: title.into(), title: title.into(),
art: Artwork::default(), art: Artwork::default(),
role: GameRole::default(), role: GameRole::default(),
icon: None,
launch: None, launch: None,
provider: None, provider: None,
detect: DetectSpec::default(), detect: DetectSpec::default(),
@@ -39,6 +39,10 @@ pub struct CustomEntry {
/// Whether this entry is a game or the launcher itself — see [`GameRole`]. /// Whether this entry is a game or the launcher itself — see [`GameRole`].
#[serde(default, skip_serializing_if = "GameRole::is_game")] #[serde(default, skip_serializing_if = "GameRole::is_game")]
pub role: GameRole, 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 /// 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. /// provider knows that the host cannot work out for itself.
/// ///
@@ -68,6 +72,10 @@ pub struct CustomInput {
/// entry is legal (an operator may want a "Steam" tile without installing the steam plugin). /// entry is legal (an operator may want a "Steam" tile without installing the steam plugin).
#[serde(default)] #[serde(default)]
pub role: GameRole, 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`]. /// How to recognize this title's process — see [`CustomEntry::detect`].
#[serde(default)] #[serde(default)]
pub detect: DetectHint, pub detect: DetectHint,
@@ -95,6 +103,10 @@ pub struct ProviderEntryInput {
/// emits its `launchers(cfg)` entries with `role: "launcher"`. /// emits its `launchers(cfg)` entries with `role: "launcher"`.
#[serde(default)] #[serde(default)]
pub role: GameRole, 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 /// 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 /// 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. /// through the provider's own client still end its session when the player quits.
@@ -127,6 +139,7 @@ impl From<CustomEntry> for GameEntry {
title: c.title, title: c.title,
art: c.art, art: c.art,
role: c.role, role: c.role,
icon: c.icon,
launch: c.launch, launch: c.launch,
provider: c.provider, provider: c.provider,
detect, detect,
@@ -310,6 +323,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
external_id: None, external_id: None,
store: None, store: None,
role: input.role, role: input.role,
icon: input.icon,
detect: input.detect, detect: input.detect,
meta: input.meta, meta: input.meta,
}; };
@@ -334,6 +348,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
slot.launch = input.launch; slot.launch = input.launch;
slot.prep = input.prep; slot.prep = input.prep;
slot.role = input.role; slot.role = input.role;
slot.icon = input.icon;
slot.detect = input.detect; slot.detect = input.detect;
slot.meta = input.meta; slot.meta = input.meta;
let updated = slot.clone(); let updated = slot.clone();
@@ -542,6 +557,7 @@ fn reconcile_entries(
// (`<store>:<external_id>`) — see `library_id_for`. // (`<store>:<external_id>`) — see `library_id_for`.
store: store.map(str::to_string), store: store.map(str::to_string),
role: input.role, role: input.role,
icon: input.icon,
detect: input.detect, detect: input.detect,
meta: input.meta, meta: input.meta,
}); });
@@ -660,6 +676,7 @@ mod tests {
external_id: None, external_id: None,
store: None, store: None,
role: GameRole::Game, role: GameRole::Game,
icon: None,
detect: DetectHint::default(), detect: DetectHint::default(),
meta: GameMeta::default(), meta: GameMeta::default(),
} }
@@ -673,6 +690,7 @@ mod tests {
launch: None, launch: None,
prep: Vec::new(), prep: Vec::new(),
role: GameRole::Game, role: GameRole::Game,
icon: None,
detect: DetectHint::default(), detect: DetectHint::default(),
meta: GameMeta::default(), meta: GameMeta::default(),
} }
@@ -808,6 +826,60 @@ 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 — /// 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 /// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
/// pre-metadata `library.json` / payload still parses (all-optional). /// pre-metadata `library.json` / payload still parses (all-optional).
@@ -101,6 +101,7 @@ fn epic_entry(
Some(GameEntry { Some(GameEntry {
provider: None, provider: None,
role: GameRole::Game, role: GameRole::Game,
icon: None,
meta: GameMeta::pc(), meta: GameMeta::pc(),
id: format!("epic:{app_name}"), id: format!("epic:{app_name}"),
store: "epic".into(), store: "epic".into(),
+1
View File
@@ -58,6 +58,7 @@ fn gog_games() -> Vec<GameEntry> {
out.push(GameEntry { out.push(GameEntry {
provider: None, provider: None,
role: GameRole::Game, role: GameRole::Game,
icon: None,
meta: GameMeta::pc(), meta: GameMeta::pc(),
id, id,
store: "gog".into(), store: "gog".into(),
@@ -110,6 +110,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
games.push(GameEntry { games.push(GameEntry {
provider: None, provider: None,
role: GameRole::Game, role: GameRole::Game,
icon: None,
meta: GameMeta::pc(), meta: GameMeta::pc(),
id: format!("heroic:{runner}:{app_name}"), id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(), store: "heroic".into(),
@@ -85,6 +85,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
games.push(GameEntry { games.push(GameEntry {
provider: None, provider: None,
role: GameRole::Game, role: GameRole::Game,
icon: None,
meta: GameMeta::pc(), meta: GameMeta::pc(),
id: format!("lutris:{id}"), id: format!("lutris:{id}"),
store: "lutris".into(), store: "lutris".into(),
@@ -30,6 +30,7 @@ impl LibraryProvider for SteamProvider {
.map(|app| GameEntry { .map(|app| GameEntry {
provider: None, provider: None,
role: GameRole::Game, role: GameRole::Game,
icon: None,
meta: GameMeta::pc(), meta: GameMeta::pc(),
id: format!("steam:{}", app.appid), id: format!("steam:{}", app.appid),
store: "steam".into(), store: "steam".into(),
@@ -385,6 +386,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
Some(GameEntry { Some(GameEntry {
provider: None, provider: None,
role: GameRole::Game, role: GameRole::Game,
icon: None,
meta: GameMeta::pc(), meta: GameMeta::pc(),
id: format!("steam:{}", sc.appid), id: format!("steam:{}", sc.appid),
store: "steam".into(), store: "steam".into(),
@@ -71,6 +71,7 @@ fn xbox_games() -> Vec<GameEntry> {
games.push(GameEntry { games.push(GameEntry {
provider: None, provider: None,
role: GameRole::Game, role: GameRole::Game,
icon: None,
meta: GameMeta::pc(), meta: GameMeta::pc(),
id, id,
store: "xbox".into(), store: "xbox".into(),
+24 -7
View File
@@ -29,6 +29,7 @@ fn check_entry_fields(
art: &crate::library::Artwork, art: &crate::library::Artwork,
launch: Option<&crate::library::LaunchSpec>, launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd], prep: &[crate::hooks::PrepCmd],
icon: Option<&str>,
) -> Option<(String, Response)> { ) -> Option<(String, Response)> {
if !lane.may_set_privileged_fields() { if !lane.may_set_privileged_fields() {
if let Some(field) = crate::library::privileged_field(launch, prep) { if let Some(field) = crate::library::privileged_field(launch, prep) {
@@ -47,6 +48,13 @@ 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) crate::library::validate_art_paths(art)
.err() .err()
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e))) .map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
@@ -301,9 +309,13 @@ pub(crate) async fn create_custom_game(
if input.title.trim().is_empty() { if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty"); return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
} }
if let Some((_, denied)) = if let Some((_, denied)) = check_entry_fields(
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) lane,
{ &input.art,
input.launch.as_ref(),
&input.prep,
input.icon.as_deref(),
) {
return denied; return denied;
} }
match crate::library::add_custom(input) { match crate::library::add_custom(input) {
@@ -336,9 +348,13 @@ pub(crate) async fn update_custom_game(
if input.title.trim().is_empty() { if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty"); return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
} }
if let Some((_, denied)) = if let Some((_, denied)) = check_entry_fields(
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep) lane,
{ &input.art,
input.launch.as_ref(),
&input.prep,
input.icon.as_deref(),
) {
return denied; return denied;
} }
use crate::library::MutateOutcome; use crate::library::MutateOutcome;
@@ -464,7 +480,8 @@ pub(crate) async fn reconcile_provider_entries(
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so // 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. // one privileged field anywhere in it is one command execution.
for (i, e) in inputs.iter().enumerate() { for (i, e) in inputs.iter().enumerate() {
if let Some((reason, denied)) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep) if let Some((reason, denied)) =
check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep, e.icon.as_deref())
{ {
tracing::warn!( tracing::warn!(
provider, provider,
+5
View File
@@ -109,6 +109,11 @@ 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 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. 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 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 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 again. Only the plugin can remove its own entries, and it removes every one of them at once. Your
+4
View File
@@ -65,6 +65,10 @@ export interface LibraryPluginDef<S extends Schema.Top> {
* every reconcile, so toggling one in config takes effect on the next sync. Emit them with * 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 * `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. * 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>; readonly launchers?: (cfg: S["Type"]) => ReadonlyArray<ProviderEntry>;
/** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */ /** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */
+41
View File
@@ -60,6 +60,26 @@ export type LaunchSpec = typeof LaunchSpec.Type;
export const GameRole = Schema.Literals(["game", "launcher"]); export const GameRole = Schema.Literals(["game", "launcher"]);
export type GameRole = typeof GameRole.Type; 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({ export const PrepStep = Schema.Struct({
do: Schema.String, do: Schema.String,
undo: Schema.optionalKey(Schema.NullOr(Schema.String)), undo: Schema.optionalKey(Schema.NullOr(Schema.String)),
@@ -142,6 +162,27 @@ export const ProviderEntry = Schema.Struct({
detect: Schema.optionalKey(DetectHint), detect: Schema.optionalKey(DetectHint),
/** `"game"` (default) or `"launcher"` — see {@link GameRole}. */ /** `"game"` (default) or `"launcher"` — see {@link GameRole}. */
role: Schema.optionalKey(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, ...GameMeta.fields,
}); });
export type ProviderEntry = typeof ProviderEntry.Type; export type ProviderEntry = typeof ProviderEntry.Type;
+108
View File
@@ -0,0 +1,108 @@
#!/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,6 +88,18 @@ VENDORED_TREES = [
("Bazzite logo (vendored, assets/os-icons)", ("Bazzite logo (vendored, assets/os-icons)",
"assets/os-icons/LICENSES/bazzite.txt", "assets/os-icons/LICENSES/bazzite.txt",
"https://github.com/ublue-os/bazzite"), "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
@@ -0,0 +1,296 @@
#!/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
+6 -5
View File
@@ -47,11 +47,12 @@
}, },
"overrides": { "overrides": {
"brace-expansion": "^5.0.9", "brace-expansion": "^5.0.9",
"dompurify": "^3.4.12", "dompurify": "^3.4.13",
"fast-uri": "^3.1.5", "fast-uri": "^3.1.5",
"immutable": "^4.3.9", "immutable": "^4.3.9",
"js-yaml": "^4.3.0", "js-yaml": "^4.3.1",
"linkify-it": "^5.0.2", "linkify-it": "^5.0.2",
"nanoid": "^3.3.18",
"postcss": "^8.5.25", "postcss": "^8.5.25",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"tar": "^7.5.21", "tar": "^7.5.21",
@@ -1304,7 +1305,7 @@
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="], "dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="],
"dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="], "dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
@@ -1580,7 +1581,7 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -1774,7 +1775,7 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"next": ["next@16.2.11", "", { "dependencies": { "@next/env": "16.2.11", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.11", "@next/swc-darwin-x64": "16.2.11", "@next/swc-linux-arm64-gnu": "16.2.11", "@next/swc-linux-arm64-musl": "16.2.11", "@next/swc-linux-x64-gnu": "16.2.11", "@next/swc-linux-x64-musl": "16.2.11", "@next/swc-win32-arm64-msvc": "16.2.11", "@next/swc-win32-x64-msvc": "16.2.11", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ=="], "next": ["next@16.2.11", "", { "dependencies": { "@next/env": "16.2.11", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.11", "@next/swc-darwin-x64": "16.2.11", "@next/swc-linux-arm64-gnu": "16.2.11", "@next/swc-linux-arm64-musl": "16.2.11", "@next/swc-linux-x64-gnu": "16.2.11", "@next/swc-linux-x64-musl": "16.2.11", "@next/swc-win32-arm64-msvc": "16.2.11", "@next/swc-win32-x64-msvc": "16.2.11", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ=="],
+9 -9
View File
@@ -2619,9 +2619,9 @@
url = "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz"; url = "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz";
hash = "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="; hash = "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==";
}; };
"dompurify@3.4.12" = fetchurl { "dompurify@3.4.13" = fetchurl {
url = "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz"; url = "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz";
hash = "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="; hash = "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==";
}; };
"dot-prop@10.2.0" = fetchurl { "dot-prop@10.2.0" = fetchurl {
url = "https://registry.npmjs.org/dot-prop/-/dot-prop-10.2.0.tgz"; url = "https://registry.npmjs.org/dot-prop/-/dot-prop-10.2.0.tgz";
@@ -3219,9 +3219,9 @@
url = "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz"; url = "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz";
hash = "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="; hash = "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==";
}; };
"js-yaml@4.3.0" = fetchurl { "js-yaml@4.3.1" = fetchurl {
url = "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz"; url = "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz";
hash = "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="; hash = "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==";
}; };
"jsesc@3.1.0" = fetchurl { "jsesc@3.1.0" = fetchurl {
url = "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"; url = "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz";
@@ -3671,9 +3671,9 @@
url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"; url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz";
hash = "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="; hash = "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==";
}; };
"nanoid@3.3.16" = fetchurl { "nanoid@3.3.18" = fetchurl {
url = "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz"; url = "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz";
hash = "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="; hash = "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==";
}; };
"napi-wasm@1.1.3" = fetchurl { "napi-wasm@1.1.3" = fetchurl {
url = "https://registry.npmjs.org/napi-wasm/-/napi-wasm-1.1.3.tgz"; url = "https://registry.npmjs.org/napi-wasm/-/napi-wasm-1.1.3.tgz";
+3 -2
View File
@@ -62,14 +62,15 @@
}, },
"overrides": { "overrides": {
"tar": "^7.5.21", "tar": "^7.5.21",
"dompurify": "^3.4.12", "dompurify": "^3.4.13",
"linkify-it": "^5.0.2", "linkify-it": "^5.0.2",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"fast-uri": "^3.1.5", "fast-uri": "^3.1.5",
"immutable": "^4.3.9", "immutable": "^4.3.9",
"undici": "^7.29.0", "undici": "^7.29.0",
"postcss": "^8.5.25", "postcss": "^8.5.25",
"js-yaml": "^4.3.0", "nanoid": "^3.3.18",
"js-yaml": "^4.3.1",
"brace-expansion": "^5.0.9" "brace-expansion": "^5.0.9"
} }
} }
+73
View File
@@ -0,0 +1,73 @@
// 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;
+15
View File
@@ -1,6 +1,7 @@
import { Eye, EyeOff, Pencil, Trash2 } from "lucide-react"; import { Eye, EyeOff, Pencil, Trash2 } from "lucide-react";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry"; import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
import { LauncherIcon } from "@/components/launcher-icon";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -63,6 +64,10 @@ export const GameCard: FC<GameCardProps> = ({
(u): u is string => !!u && !failed[u], (u): u is string => !!u && !failed[u],
); );
const src = candidates[0]; const src = candidates[0];
// A launcher tile ships no cover art by design (its own icon is square and this frame is 2:3),
// so the brand mark IS its poster. Only when there is no artwork at all: a plugin that does send
// a cover has out-voted the token.
const mark = !src && game.icon ? <LauncherIcon icon={game.icon} /> : null;
return ( return (
<Card className="group relative overflow-hidden"> <Card className="group relative overflow-hidden">
@@ -78,6 +83,16 @@ export const GameCard: FC<GameCardProps> = ({
className={`size-full object-cover${hidden ? " opacity-30" : ""}`} className={`size-full object-cover${hidden ? " opacity-30" : ""}`}
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))} onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
/> />
) : mark ? (
// The mark carries the tile on its own — the launcher's name is already
// directly below the frame, so repeating it here would just crowd the glyph.
<div
className={`flex size-full items-center justify-center p-8 text-muted-foreground${
hidden ? " opacity-30" : ""
}`}
>
<div className="w-full max-w-24 [&>svg]:size-full">{mark}</div>
</div>
) : ( ) : (
<div <div
className={`flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground${ className={`flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground${