Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f62a48d4a9 |
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.25.0"
|
||||
"version": "0.26.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -4489,6 +4489,13 @@
|
||||
],
|
||||
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token\n(`steam`, `heroic`), never bytes and never a URL."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||
@@ -4555,6 +4562,13 @@
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`\nis: an operator's own \"Steam\" tile should be able to look like one."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -5350,6 +5364,14 @@
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —\nnever image bytes and never a URL. See [`is_icon_token`].\n\nIt exists for launcher tiles, which by design ship no cover art: a launcher's own icon is\nsquare, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so\nuntil now those tiles were the launcher's name on a flat accent face. The token lets a client\ndraw the real mark from art it already ships, at whatever size its tile happens to be.\n\nA token rather than art on the wire because the host's art proxy serves *raster* bytes only\n([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much\nincluded — it is script-capable XML and the console renders art in a browser). Sending the\nname of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every\ntile size, and lets it take the tile's ink.\n\nOrdinary titles may carry one too — nothing here is launcher-specific — but nothing sets it\nfor them: a game has real cover art, which is strictly better than a brand mark.",
|
||||
"example": "steam"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||
@@ -6869,6 +6891,13 @@
|
||||
"type": "string",
|
||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets\non its `launchers(cfg)` tiles, and the whole reason the token exists."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Font Awesome Free — brand icons (steam, xbox in assets/launcher-icons/) are from
|
||||
Font Awesome Free.
|
||||
|
||||
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
Font Awesome Free icons are licensed under the Creative Commons Attribution 4.0
|
||||
International license (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/.
|
||||
The icons are redistributed here as monochrome SVG path data with no
|
||||
modifications beyond color normalization (fill="currentColor").
|
||||
|
||||
Per the Font Awesome Free license (https://fontawesome.com/license/free):
|
||||
"Font Awesome Free is free, open source, and GPL friendly. You can use it for
|
||||
commercial projects, open source projects, or really almost whatever you want.
|
||||
Attribution is required by MIT, SIL OFL, and CC BY licenses."
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for
|
||||
identification purposes only; their use does not imply endorsement.
|
||||
@@ -0,0 +1,29 @@
|
||||
Playnite — the `playnite` mark in assets/launcher-icons/ is the Playnite logo from the
|
||||
Playnite source repository (media/playnite-logo-black.svg).
|
||||
|
||||
Copyright (c) 2020 Josef Nemec (https://github.com/JosefNemec/Playnite)
|
||||
|
||||
Licensed under the MIT License:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Modifications: the colour was normalized to fill="currentColor"; the original viewBox
|
||||
(0 0 1024 1024) and path geometry are unchanged.
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for identification
|
||||
purposes only; their use does not imply endorsement.
|
||||
@@ -0,0 +1,13 @@
|
||||
Simple Icons — brand icons (lutris, heroic, epic, gog in assets/launcher-icons/) are
|
||||
from Simple Icons
|
||||
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
|
||||
|
||||
Upstream slugs: lutris, heroicgameslauncher, epicgames, gogdotcom.
|
||||
|
||||
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
|
||||
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
|
||||
required; this notice is provided for provenance.
|
||||
|
||||
Brand icons are trademarks of their respective owners and are used for
|
||||
identification purposes only; their use does not imply endorsement. See
|
||||
https://github.com/simple-icons/simple-icons/blob/develop/DISCLAIMER.md.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Launcher icon masters
|
||||
|
||||
The brand marks a **launcher tile** draws — the entries a library plugin publishes with
|
||||
`role: "launcher"` (design D4), which open Steam Big Picture or Heroic or Playnite rather
|
||||
than a game. One file per **icon token**, the value a plugin puts in an entry's `icon`
|
||||
field and every client resolves against the set it ships.
|
||||
|
||||
| token | mark | emitted by | source |
|
||||
|---|---|---|---|
|
||||
| `steam` | Steam | punktfunk-plugin-steam (Big Picture + desktop) | Font Awesome Free brands (CC BY 4.0) |
|
||||
| `lutris` | Lutris | punktfunk-plugin-lutris | Simple Icons (CC0 1.0) |
|
||||
| `heroic` | Heroic Games Launcher | punktfunk-plugin-heroic | Simple Icons (CC0 1.0, slug `heroicgameslauncher`) |
|
||||
| `playnite` | Playnite | punktfunk-plugin-playnite | JosefNemec/Playnite (MIT) |
|
||||
| `epic` | Epic Games | punktfunk-plugin-epic — **dormant** | Simple Icons (CC0 1.0, slug `epicgames`) |
|
||||
| `gog` | GOG.com | punktfunk-plugin-gog — **dormant** | Simple Icons (CC0 1.0, slug `gogdotcom`) |
|
||||
| `xbox` | Xbox | punktfunk-plugin-xbox — **dormant** | Font Awesome Free brands (CC BY 4.0) |
|
||||
|
||||
The last three are **dormant on purpose**: those plugins carry a `launcher` config switch that
|
||||
is off by default and whose `launcherEntries` returns nothing, because the host has no verified
|
||||
`launcher_ui` activation for them yet — a tile would be a card that does nothing. Their marks
|
||||
ship anyway so that turning one on stays the one-line plugin change those plugins promise,
|
||||
instead of also needing a release of all six clients.
|
||||
|
||||
`steam` is the same mark as `assets/os-icons/steam.svg`, generated from that file rather than
|
||||
re-sourced, so the SteamOS host badge and the Steam launcher tile can never drift apart.
|
||||
|
||||
## Why a token and not the icon itself
|
||||
|
||||
A plugin sends the **name** of a mark, never its bytes, and never a URL.
|
||||
|
||||
The obvious alternative — a plugin ships its own `icon.svg` and the host's art proxy serves it —
|
||||
is closed by construction, and deliberately: `local_art_bytes` serves what the bytes *are*
|
||||
(`sniff_image_type`, `crates/punktfunk-host/src/library/art.rs`), and SVG is not on that list
|
||||
because it is script-capable XML and the web console renders library art in a browser. Widening
|
||||
that sniff to admit SVG would trade a rendering nicety for a stored-XSS surface.
|
||||
|
||||
Sending a token instead keeps that refusal intact and buys three things a proxied image could
|
||||
not have given us anyway: the glyph stays vector at every tile size a client picks, it takes the
|
||||
tile's own ink instead of arriving pre-coloured, and it costs no fetch, no cache and no bytes on
|
||||
a reconcile that is already body-limited.
|
||||
|
||||
The cost is that a **third-party** plugin cannot ship a mark no client bundles. Its tile falls
|
||||
back to the launcher's name on an accent face — exactly what every launcher tile looked like
|
||||
before this existed — and the fix is a pull request adding the master here.
|
||||
|
||||
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Those
|
||||
viewBoxes are not all square (`0 0 24 24`, `0 0 496 512`, `0 0 1024 1024`), so **a client must
|
||||
letterbox rather than stretch** — a mark drawn to a square box is a squashed mark.
|
||||
|
||||
## Regenerating the per-client derivatives
|
||||
|
||||
`bash scripts/gen-launcher-icons.sh [token ...]` turns a master into the three baked forms (GTK
|
||||
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
|
||||
that inline it (web console, Android, the in-session console UI). Adding a **new** token also
|
||||
means adding it to each client's shipped-token list — the script prints that checklist too.
|
||||
|
||||
## Licensing
|
||||
|
||||
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
|
||||
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
|
||||
are used here nominatively — to *identify* the launcher a tile opens, the standard practice in
|
||||
this ecosystem — and imply no affiliation or endorsement.
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- epic — from Simple Icons (CC0 1.0), slug `epicgames`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- gog — from Simple Icons (CC0 1.0), slug `gogdotcom`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- heroic — from Simple Icons (CC0 1.0), slug `heroicgameslauncher`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z"/></svg>
|
||||
|
After Width: | Height: | Size: 957 B |
@@ -0,0 +1,2 @@
|
||||
<!-- lutris — from Simple Icons (CC0 1.0), slug `lutris`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- playnite — from JosefNemec/Playnite media/playnite-logo-black.svg (MIT). See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor"><path d="M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,2 @@
|
||||
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0); the same mark as assets/os-icons/steam.svg. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="currentColor"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 956 B |
@@ -0,0 +1,2 @@
|
||||
<!-- xbox — from Font Awesome Free 6 brands (CC BY 4.0), `fa-xbox`. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -22,6 +22,7 @@ import androidx.compose.foundation.pager.PageSize
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -54,6 +55,7 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import io.unom.punktfunk.components.launcherIcon
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.library.LibraryClient
|
||||
@@ -367,16 +369,28 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
|
||||
)
|
||||
} else {
|
||||
// A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title
|
||||
// would read as "a game whose cover failed to load".
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
// A launcher ships no poster by design, so its brand mark IS the poster — drawn big and
|
||||
// centred, tinted like the text it replaces. Falling back to the launcher's name says
|
||||
// "opens Steam" for a mark we don't ship; the title would read as "a game whose cover
|
||||
// failed to load".
|
||||
val mark = launcherIcon(game.iconToken)
|
||||
if (mark != null) {
|
||||
Icon(
|
||||
imageVector = mark,
|
||||
contentDescription = game.title,
|
||||
tint = ink.fg(0.75f),
|
||||
modifier = Modifier.fillMaxSize(0.45f),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = ink.fg(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package io.unom.punktfunk.components
|
||||
|
||||
// GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.
|
||||
// Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.
|
||||
// Per-mark provenance and licensing: assets/launcher-icons/README.md.
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
|
||||
* Material ships no brand icons, so this is a curated registry — the sibling of [OsIcons],
|
||||
* which does the equivalent job for the host cards.
|
||||
*
|
||||
* Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds
|
||||
* the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT
|
||||
* all square, so the builder letterboxes — a mark forced into a square box is a squashed mark.
|
||||
*/
|
||||
private class LauncherGlyph(
|
||||
val viewportWidth: Float,
|
||||
val viewportHeight: Float,
|
||||
val d: String,
|
||||
)
|
||||
|
||||
private val GLYPHS: Map<String, LauncherGlyph> = mapOf(
|
||||
"steam" to LauncherGlyph(
|
||||
viewportWidth = 496f,
|
||||
viewportHeight = 512f,
|
||||
d = "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
|
||||
),
|
||||
"lutris" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "m21.231 18.89.001-.002c-1.293 3.243-5.218 5.232-9.447 5.105C5.3 23.993 0 18.48 0 11.906S5.276.001 11.785.001c1.793 0 3.493.406 5.015 1.13.081-.177.271-.544.451-.557.238-.017.374.137.526.309.154.172.46.429.46.429s1.393-.481 2.955.377c1.563.858 1.783 1.116 2.09 1.716.152.301.195.829.2 1.282a.796.796 0 0 0-.07-.003c-.496 0-.96.455-.96 1.08 0 .263.082.496.215.678l-.01.007a1.505 1.505 0 0 0-.132.01 18.704 18.704 0 0 0-.389-.142 2.53 2.53 0 0 1-.82-.472 1.402 1.402 0 0 0-1.196-2.112c-.383 0-.73.156-.982.41-.472-.271-1.174-.482-2.527-.565l-.407-.011c-2.282.012-3.611.279-5.979 1.301-.603.283-1.206.615-1.785 1.001-.423.3-.639.67-.709 1.137a1.326 1.326 0 0 0 1.23 1.373h.042c1.27.06 2.039 1.99 2.063 2.497.004.05.004.023.003.08-.032.727-.37 1.267-1.088 1.246a1.231 1.231 0 0 1-.976-.494c-.063-.077-.103-.172-.159-.254-.666-1.081-1.732-1.36-2.771-1.523-.438-.068-1.073-.122-1.31.25a8.28 8.28 0 0 0-.577 3.063c-.02 5.036 4.041 9.118 9.026 9.118 2.575 0 5.349-.952 6.993-2.7l-.035.03c-1.772 1.473-4.66 1.941-6.027 1.941-4.302 0-7.818-3.232-7.818-7.578 0-1.276.288-2.396.814-3.36.495.183.947.483 1.28 1.022a.24.24 0 0 0 .013.021c.064.092.111.197.182.284.424.524.881.658 1.342.68h.01c.43.013.768-.12 1.024-.342.347-.3.55-.79.577-1.382v-.014c.002-.085 0-.053-.004-.112-.024-.376-.333-1.318-.906-2.027-.266-.331-.587-.607-.95-.774l.12-.074c.756-.457 2.364-.977 4.592-.638 1.13.173 2.055.419 3.483.879 1.657.534 2.579 1.279 3.854 1.427.15.017.301.018.45.003.41 1.129.634 2.35.634 3.621 0 2.068-.59 3.995-1.611 5.62zm1.947-12.274s-.115.201-.364.322c-.103.05-.282-.075-.45.1-.359.726.516 1.332.923 1.315.408-.017.73-.432.712-.793-.017-.558-.82-.944-.82-.944zm.234-1.432c.255 0 .462.26.462.58 0 .32-.207.58-.462.58-.254 0-.46-.26-.46-.58 0-.32.206-.58.46-.58zm-3.292-.951c.492 0 .89.403.89.9a.895.895 0 0 1-.89.898.895.895 0 0 1-.89-.899c0-.496.399-.899.89-.899z",
|
||||
),
|
||||
"heroic" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M11.999 0 11.997 0a.891.891 0 0 0-.36.075C8.964 1.253 6.29 2.434 3.618 3.613A.893.893 0 0 0 3.1 4.619l3.146 14.646c.043.197.15.375.307.504l4.88 4.027a.895.895 0 0 0 1.131.006l5-4.031a.895.895 0 0 0 .315-.516L20.9 4.614a.895.895 0 0 0-.515-1L12.358.074A.892.892 0 0 0 12 0zm0 .35v.003c.114 0 .228.023.334.07l7.42 3.27a.827.827 0 0 1 .476.924l-2.793 13.535a.83.83 0 0 1-.289.478l-4.623 3.725a.826.826 0 0 1-1.045-.006l-4.513-3.723a.829.829 0 0 1-.281-.465L3.775 4.622a.83.83 0 0 1 .476-.931L11.665.42a.832.832 0 0 1 .334-.07zm-.045 1.954L10.28 5.202h-.002l1.211 11.301.512.409.512-.409 1.117-11.3zM9.003 16.261l-.584 1.068.584 1.07 2.295-.38.47-.69-.47-.671zm5.996 0-2.295.397-.47.671.47.69 2.295.38.584-1.07zm-2.998 1.488-.51.444-.281 2.168.789.55.793-.55-.295-2.168z",
|
||||
),
|
||||
"playnite" to LauncherGlyph(
|
||||
viewportWidth = 1024f,
|
||||
viewportHeight = 1024f,
|
||||
d = "M966.686,623.899c-9.773-81.666-29.323-161.25-54.514-239.447c-13.759-42.709-30.419-84.189-56.091-121.452 c-31.701-46.014-74.789-72.958-130.812-78.579c-29.631-2.973-57.785,4.118-85.677,12.35 c-61.172,18.056-123.359,25.124-186.493,14.903c-30.919-5.006-61.308-13.526-91.743-21.225 c-76.445-19.338-145.323,4.995-191.165,69.261c-11.441,16.04-21.194,33.543-29.78,51.312 c-25.091,51.925-40.443,107.249-54.53,162.924c-18.822,74.393-33.019,149.491-33.664,226.571c0,7.184-0.342,14.386,0.061,21.547 c1.557,27.727,4.354,55.289,16.045,80.97c15.334,33.68,45.905,46.725,79.471,31.198c18.291-8.461,36.293-19.857,50.766-33.743 c24.597-23.598,46.616-49.934,69.125-75.64c17.934-20.481,39.086-35.301,66.115-40.203c15.779-2.862,31.802-6.006,47.736-6.118 c87.888-0.62,175.783-0.602,263.673-0.278c51.4,0.189,93.314,19.382,124.091,62.134c12.518,17.388,27.83,32.889,42.78,48.371 c18.598,19.259,38.974,36.431,64.412,46.39c32.967,12.907,62.547,1.677,77.882-30.198c3.965-8.242,6.963-17.122,9.155-26.017 C976.198,727.534,972.874,675.607,966.686,623.899z M315.471,527.643c-44.289,0.213-80.733-36.32-80.847-81.045 c-0.115-45.048,35.472-81.194,80.197-81.458c44.521-0.263,80.718,35.897,80.884,80.801 C395.871,490.671,359.773,527.429,315.471,527.643z M708.857,319.301c21.859,0.06,39.486,17.884,39.471,39.91 c-0.015,22.133-17.489,39.677-39.523,39.682c-22.045,0.005-39.456-17.53-39.444-39.724 C669.372,337.125,687.089,319.241,708.857,319.301z M622.269,486.36c-21.542,0.085-39.7-18.08-39.808-39.822 c-0.108-21.888,17.617-39.622,39.62-39.641c22.066-0.018,39.759,17.552,39.718,39.442 C661.758,468.205,643.909,486.275,622.269,486.36z M708.967,573.333c-21.823,0.096-39.537-17.668-39.611-39.721 c-0.074-22.079,17.523-39.992,39.338-40.044c21.715-0.052,39.597,17.908,39.645,39.816 C748.386,555.477,730.883,573.237,708.967,573.333z M795.752,486.362c-21.764,0.155-39.671-17.882-39.651-39.938 c0.021-22.15,17.628-39.639,39.793-39.525c22.091,0.114,39.527,17.993,39.155,40.152 C834.686,468.733,817.216,486.209,795.752,486.362z",
|
||||
),
|
||||
"epic" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z",
|
||||
),
|
||||
"gog" to LauncherGlyph(
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
d = "M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z",
|
||||
),
|
||||
"xbox" to LauncherGlyph(
|
||||
viewportWidth = 512f,
|
||||
viewportHeight = 512f,
|
||||
d = "M369.9 318.2c44.3 54.3 64.7 98.8 54.4 118.7-7.9 15.1-56.7 44.6-92.6 55.9-29.6 9.3-68.4 13.3-100.4 10.2-38.2-3.7-76.9-17.4-110.1-39-27.9-18.2-34.2-25.7-34.2-40.6 0-29.9 32.9-82.3 89.2-142.1 32-33.9 76.5-73.7 81.4-72.6 9.4 2.1 84.3 75.1 112.3 109.5zM188.6 143.8c-29.7-26.9-58.1-53.9-86.4-63.4-15.2-5.1-16.3-4.8-28.7 8.1-29.2 30.4-53.5 79.7-60.3 122.4-5.4 34.2-6.1 43.8-4.2 60.5 5.6 50.5 17.3 85.4 40.5 120.9 9.5 14.6 12.1 17.3 9.3 9.9-4.2-11-.3-37.5 9.5-64 14.3-39 53.9-112.9 120.3-194.4zm311.6 63.5c-16.9-80-67.5-130.3-74.6-130.3-7.3 0-24.2 6.5-36 13.9-23.3 14.5-41 31.4-64.3 52.8 42.4 53.3 102.2 139.4 122.9 202.3 6.8 20.7 9.7 41.1 7.4 52.3-1.7 8.5-1.7 8.5 1.4 4.6 6.1-7.7 19.9-31.3 25.4-43.5 7.4-16.2 15-40.2 18.6-58.7 4.3-22.5 3.9-70.8-.8-93.4zM141.3 43c47.7-2.5 109.7 34.5 114.3 35.4 .7 .1 10.4-4.2 21.6-9.7 63.9-31.1 94-25.8 107.4-25.2-63.9-39.3-152.7-50-233.9-11.7-23.4 11.1-24 11.9-9.4 11.2z",
|
||||
),
|
||||
)
|
||||
|
||||
private val CACHE = HashMap<String, ImageVector>()
|
||||
|
||||
/**
|
||||
* The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark
|
||||
* this build ships no art for — the caller then falls back to naming the launcher, which is
|
||||
* what every launcher tile looked like before the token existed.
|
||||
*
|
||||
* Tinted by the caller via `tint`, so one mark serves every palette.
|
||||
*/
|
||||
fun launcherIcon(token: String?): ImageVector? {
|
||||
val glyph = GLYPHS[token ?: return null] ?: return null
|
||||
return CACHE.getOrPut(token) {
|
||||
// Square the box and centre the mark in it, so a wide or tall master keeps its aspect
|
||||
// ratio instead of being stretched to the tile.
|
||||
val side = max(glyph.viewportWidth, glyph.viewportHeight)
|
||||
val dx = (side - glyph.viewportWidth) / 2f
|
||||
val dy = (side - glyph.viewportHeight) / 2f
|
||||
ImageVector.Builder(
|
||||
name = "launcher_$token",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = side,
|
||||
viewportHeight = side,
|
||||
).apply {
|
||||
addGroup(translationX = dx, translationY = dy)
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(glyph.d).toNodes(),
|
||||
fill = SolidColor(Color.White),
|
||||
)
|
||||
clearGroup()
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
||||
* opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable
|
||||
* String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a
|
||||
* game rather than break the decode (design D4).
|
||||
*
|
||||
* [icon] is the token for the entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||
* Null on every older host and on every ordinary title.
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
@@ -51,12 +54,25 @@ data class GameEntry(
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
val icon: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* The brand-icon token, re-validated rather than taken on trust.
|
||||
*
|
||||
* The host checks the shape on the way in, so this only fires for a host older than that
|
||||
* check or one that isn't ours. It costs a scan of a short string and means no consumer has
|
||||
* to wonder what it is about to look up.
|
||||
*/
|
||||
val iconToken: String? get() = icon?.takeIf { t ->
|
||||
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
|
||||
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for the store badge — the same table the other clients use
|
||||
* (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom
|
||||
@@ -151,6 +167,7 @@ object LibraryClient {
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
role = str(o, "role"),
|
||||
icon = str(o, "icon"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ let package = Package(
|
||||
// licensing in that README). `.process` compiles the catalog; loaded via
|
||||
// OsIcon.swift.
|
||||
.process("Resources/OsIcons.xcassets"),
|
||||
// The launcher tiles' brand marks (template vector imagesets generated from the
|
||||
// assets/launcher-icons masters by scripts/gen-launcher-icons.sh — per-mark
|
||||
// provenance and licensing in that README). Loaded via LauncherIcon.swift.
|
||||
.process("Resources/LauncherIcons.xcassets"),
|
||||
],
|
||||
linkerSettings: [
|
||||
// Rust staticlib system deps.
|
||||
|
||||
@@ -125,7 +125,7 @@ struct LibraryCoverflowView: View {
|
||||
) -> some View {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
onLoaded: { artSettled += 1 })
|
||||
icon: game.iconToken, onLoaded: { artSettled += 1 })
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) {
|
||||
|
||||
@@ -346,7 +346,9 @@ private struct GameCard: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
icon: game.iconToken)
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
|
||||
@@ -71,6 +71,9 @@ struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let loader: LibraryArtLoader?
|
||||
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
|
||||
/// no cover art by design, so for those the mark IS the poster — see `placeholder`.
|
||||
var icon: String?
|
||||
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
||||
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
||||
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
||||
@@ -121,11 +124,26 @@ struct PosterImage: View {
|
||||
private var placeholder: some View {
|
||||
ZStack {
|
||||
Rectangle().fill(.quaternary)
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
// A launcher's brand mark, drawn at poster size and tinted like the text it replaces.
|
||||
// `scaledToFit` inside a fraction of the card keeps a non-square master (the Steam mark
|
||||
// is 496×512, Playnite's 1024×1024) in its own aspect ratio rather than stretched.
|
||||
// Falling back to the title is the pre-icon design, so an unshipped mark loses nothing.
|
||||
if let mark = launcherIconImage(for: icon) {
|
||||
GeometryReader { geo in
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: geo.size.width * 0.44, height: geo.size.height * 0.44)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
} else {
|
||||
Text(title)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,28 @@ public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
|
||||
/// optional String: the host owns the vocabulary, and an unknown future value must never fail
|
||||
/// the whole library decode. Anything that isn't `"launcher"` is a game (design D4).
|
||||
public var role: String?
|
||||
/// The token for this entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||
/// `nil` on every older host and on every ordinary title. See `launcherIconImage`.
|
||||
public var icon: String?
|
||||
|
||||
public var isCustom: Bool { store == "custom" }
|
||||
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
public var isLauncher: Bool { role == "launcher" }
|
||||
|
||||
/// The brand-icon token, re-validated rather than taken on trust.
|
||||
///
|
||||
/// The host checks the shape on the way in, so this can only fire for a host older than that
|
||||
/// check or one that isn't ours. The value reaches `Image(named:)`, and "the peer promised" is
|
||||
/// not the standard a name lookup deserves.
|
||||
public var iconToken: String? {
|
||||
guard let t = icon, !t.isEmpty, t.count <= 32,
|
||||
let first = t.first, first.isASCII, first.isLowercase,
|
||||
t.allSatisfy({ $0.isASCII && ($0.isLowercase || $0.isNumber || $0 == "-") })
|
||||
else { return nil }
|
||||
return t
|
||||
}
|
||||
|
||||
/// Display name for the store badge — the same table the Rust clients use
|
||||
/// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for
|
||||
/// every non-custom entry, which a Lutris or GOG title made a lie.
|
||||
|
||||
@@ -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 |
@@ -1,9 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- The shell's embedded icon assets: the host-card OS marks (derived from the
|
||||
assets/os-icons masters; see that directory's README for provenance/licensing).
|
||||
assets/os-icons masters) and the library's launcher-tile brand marks (from the
|
||||
assets/launcher-icons masters). See each directory's README for provenance/licensing.
|
||||
Registered under the hicolor-style layout IconTheme::add_resource_path expects. -->
|
||||
<gresources>
|
||||
<gresource prefix="/io/unom/Punktfunk">
|
||||
<file>icons/scalable/actions/pf-launcher-steam-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-lutris-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-heroic-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-playnite-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-epic-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-gog-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-launcher-xbox-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-windows-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-apple-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-linux-symbolic.svg</file>
|
||||
|
||||
@@ -66,6 +66,9 @@ const CSS: &str = "
|
||||
named instead of a title monogram, and an accent badge. */
|
||||
.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); }
|
||||
.pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); }
|
||||
/* The brand mark when we ship one for this launcher — same ink as the name it replaces, so the
|
||||
two fallback rungs read as one design rather than two. */
|
||||
.pf-poster-launcher-mark { color: alpha(currentColor, 0.85); }
|
||||
.pf-store-badge.pf-launcher { color: white; background: @accent_color; }
|
||||
.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); }
|
||||
";
|
||||
|
||||
@@ -782,6 +782,7 @@ fn mock_library() -> (
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
role: None,
|
||||
icon: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -285,27 +285,61 @@ fn render(state: &Rc<State>, games: &[GameEntry]) {
|
||||
.set_visible(!launchers.is_empty() && !titles.is_empty());
|
||||
}
|
||||
|
||||
/// The launcher-tile brand marks this shell ships symbolic art for
|
||||
/// (`data/icons/.../pf-launcher-<t>-symbolic.svg`, embedded via gresource). A plugin may name a
|
||||
/// mark a newer build carries; an entry whose token isn't here falls back to the launcher's name,
|
||||
/// which is exactly how every launcher tile looked before icons existed.
|
||||
const LAUNCHER_ICON_TOKENS: &[&str] = &[
|
||||
"steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox",
|
||||
];
|
||||
|
||||
/// The poster-sized brand mark for an entry, or `None` when it carries no token, names one we
|
||||
/// don't ship, or already has real artwork (a plugin that sent a cover has out-voted the token).
|
||||
///
|
||||
/// Symbolic, so it recolors with the Adwaita theme like every other glyph in the shell.
|
||||
fn launcher_icon_image(game: &GameEntry) -> Option<gtk::Image> {
|
||||
if !game.art.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let token = game.icon_token()?;
|
||||
if !LAUNCHER_ICON_TOKENS.contains(&token) {
|
||||
return None;
|
||||
}
|
||||
let img = gtk::Image::from_icon_name(&format!("pf-launcher-{token}-symbolic"));
|
||||
img.set_pixel_size(72);
|
||||
img.add_css_class("pf-poster-launcher-mark");
|
||||
img.set_halign(gtk::Align::Center);
|
||||
img.set_valign(gtk::Align::Center);
|
||||
img.set_vexpand(true);
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
|
||||
/// monogram placeholder underneath the async art. Activation starts a session launching
|
||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
// A launcher usually ships no poster. Naming the launcher on an accent face says "opens
|
||||
// Steam"; a title monogram on the neutral face would say "a game whose cover didn't load".
|
||||
// A launcher usually ships no poster. Its brand mark, when we ship one, IS the poster; failing
|
||||
// that, naming the launcher on an accent face says "opens Steam". A title monogram on the
|
||||
// neutral face would say "a game whose cover didn't load", which is why games keep it.
|
||||
let launcher = game.is_launcher();
|
||||
let monogram = if launcher {
|
||||
let l = gtk::Label::new(Some(store_label(&game.store)));
|
||||
l.add_css_class("pf-poster-launcher-name");
|
||||
l
|
||||
} else {
|
||||
let l = gtk::Label::new(Some(&initials(&game.title)));
|
||||
l.add_css_class("pf-poster-monogram");
|
||||
l
|
||||
};
|
||||
monogram.set_halign(gtk::Align::Center);
|
||||
monogram.set_valign(gtk::Align::Center);
|
||||
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
placeholder.append(&monogram);
|
||||
monogram.set_vexpand(true);
|
||||
if let Some(mark) = launcher_icon_image(game) {
|
||||
placeholder.append(&mark);
|
||||
} else {
|
||||
let monogram = if launcher {
|
||||
let l = gtk::Label::new(Some(store_label(&game.store)));
|
||||
l.add_css_class("pf-poster-launcher-name");
|
||||
l
|
||||
} else {
|
||||
let l = gtk::Label::new(Some(&initials(&game.title)));
|
||||
l.add_css_class("pf-poster-monogram");
|
||||
l
|
||||
};
|
||||
monogram.set_halign(gtk::Align::Center);
|
||||
monogram.set_valign(gtk::Align::Center);
|
||||
monogram.set_vexpand(true);
|
||||
placeholder.append(&monogram);
|
||||
}
|
||||
|
||||
let pic = gtk::Picture::new();
|
||||
pic.set_content_fit(gtk::ContentFit::Cover);
|
||||
|
||||
@@ -859,6 +859,7 @@ fn spawn_fetch(
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon: g.icon_token().unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -900,6 +901,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon: g.icon_token().unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -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('\\', "/")))
|
||||
}
|
||||
@@ -43,6 +43,10 @@ pub(crate) struct Game {
|
||||
/// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that
|
||||
/// isn't `launcher` is a game" is decided in one place for every client.
|
||||
pub(crate) launcher: bool,
|
||||
/// The `file:///` URI of this entry's brand mark, already resolved by
|
||||
/// [`super::launcher_icons::uri`] — `None` when the entry names no mark or one we don't ship.
|
||||
/// Resolved at decode time rather than per render: the shelf re-renders on every art arrival.
|
||||
pub(crate) icon_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
@@ -140,6 +144,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
icon_uri: super::launcher_icons::uri(g.icon_token()),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -245,23 +250,38 @@ fn poster_tile(
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
// A launcher rarely has poster art, and an art-less launcher drawn like an art-less game
|
||||
// reads as "a game whose cover failed to load". So it names its launcher instead of
|
||||
// showing a title monogram, and the frame below picks up the accent stroke.
|
||||
None => border(
|
||||
text_block(if game.launcher {
|
||||
store_label(&game.store).to_string()
|
||||
} else {
|
||||
initials(&game.title)
|
||||
})
|
||||
.font_size(if game.launcher { 18.0 } else { 28.0 })
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
// reads as "a game whose cover failed to load". Its brand mark, when we ship one, IS the
|
||||
// poster; failing that it names its launcher. Either way the frame below picks up the
|
||||
// accent stroke.
|
||||
//
|
||||
// `Uniform`, not `UniformToFill`: the marks keep their masters' aspect ratios (Steam is
|
||||
// 496x512, Playnite 1024x1024), and filling a 2:3 frame would crop them to a strip — the
|
||||
// very thing that kept launcher tiles art-less in the first place.
|
||||
None => match game.icon_uri.as_deref() {
|
||||
Some(uri) => border(
|
||||
Image::new_with_uri(uri)
|
||||
.stretch(Stretch::Uniform)
|
||||
.margin(uniform(poster_h * 0.28)),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
None => border(
|
||||
text_block(if game.launcher {
|
||||
store_label(&game.store).to_string()
|
||||
} else {
|
||||
initials(&game.title)
|
||||
})
|
||||
.font_size(if game.launcher { 18.0 } else { 28.0 })
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
)
|
||||
.background(ThemeRef::SubtleFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
},
|
||||
};
|
||||
let framed = border(grid(vec![
|
||||
poster,
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
mod connect;
|
||||
mod help;
|
||||
mod hosts;
|
||||
mod launcher_icons;
|
||||
mod library;
|
||||
mod licenses;
|
||||
mod os_icons;
|
||||
@@ -186,8 +187,10 @@ pub struct AppCtx {
|
||||
}
|
||||
|
||||
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();
|
||||
launcher_icons::install();
|
||||
let ctx = Arc::new(AppCtx {
|
||||
identity,
|
||||
settings: Mutex::new(Settings::load()),
|
||||
|
||||
@@ -47,6 +47,13 @@ impl Artwork {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether this entry has no poster art at all — the condition a launcher's brand mark stands
|
||||
/// in for. Separate from `poster_candidates` so a caller asking the question doesn't have to
|
||||
/// invent a `base` it has no use for.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.portrait.is_none() && self.header.is_none() && self.hero.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// One title in the host's unified library. `id` is store-qualified (`steam:<appid>`,
|
||||
@@ -73,6 +80,17 @@ pub struct GameEntry {
|
||||
/// future value must never fail the whole library decode.
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
/// Which brand mark to draw for this entry — `"steam"`, `"heroic"`, `"playnite"` — or `None`
|
||||
/// when the host sent none (every older host, and every ordinary title).
|
||||
///
|
||||
/// A **token**, not art: the shell resolves it against the marks it ships
|
||||
/// (`assets/launcher-icons`) and falls back to naming the launcher for one it doesn't have, so
|
||||
/// a plugin can name a mark this client has never heard of without breaking the tile. The host
|
||||
/// guarantees the slug shape (`[a-z][a-z0-9-]{0,31}`), which is what makes it safe to
|
||||
/// interpolate into a resource name or an asset lookup — but see [`GameEntry::icon_token`],
|
||||
/// which re-checks rather than trusting it.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl GameEntry {
|
||||
@@ -80,6 +98,23 @@ impl GameEntry {
|
||||
pub fn is_launcher(&self) -> bool {
|
||||
self.role.as_deref() == Some("launcher")
|
||||
}
|
||||
|
||||
/// The brand-icon token, re-validated here rather than taken on trust.
|
||||
///
|
||||
/// The host validates the shape on the way in, so this can only fire for a host that is older
|
||||
/// than that check, compromised, or simply not ours. Every caller interpolates the result into
|
||||
/// a resource name (`pf-launcher-{t}-symbolic`), an asset-catalog lookup or a file path, and
|
||||
/// "the peer promised" is not the standard those deserve — a client re-checks what it is about
|
||||
/// to concatenate. Cheap enough to do at the call site.
|
||||
pub fn icon_token(&self) -> Option<&str> {
|
||||
let t = self.icon.as_deref()?;
|
||||
let ok = !t.is_empty()
|
||||
&& t.len() <= 32
|
||||
&& t.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& t.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
|
||||
ok.then_some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ mod anim;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod glyphs;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod launcher_icons;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod library;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod model;
|
||||
|
||||
@@ -569,6 +569,10 @@ pub struct LibraryGame {
|
||||
/// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't
|
||||
/// `launcher` is a game" rule lives in exactly one place.
|
||||
pub launcher: bool,
|
||||
/// The token for this entry's brand mark (`"steam"`, `"heroic"`), already validated by
|
||||
/// [`pf_client_core::library::GameEntry::icon_token`]. Empty when the entry names no mark;
|
||||
/// a token we ship no art for simply draws nothing and the tile falls back to its name.
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
@@ -689,6 +693,7 @@ mod tests {
|
||||
title: title.to_string(),
|
||||
store: "steam".into(),
|
||||
launcher,
|
||||
icon: String::new(),
|
||||
};
|
||||
let shared = LibraryShared::default();
|
||||
shared.set_games(vec![
|
||||
@@ -717,6 +722,7 @@ mod tests {
|
||||
title: (*t).to_string(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -431,22 +431,44 @@ impl LibraryScreen {
|
||||
Color4f::new(0.118, 0.118, 0.145, 1.0)
|
||||
};
|
||||
canvas.draw_rect(crect, &Paint::new(face, None));
|
||||
let (glyph, size, ink) = if game.launcher {
|
||||
(store_label(&game.store).to_string(), 22.0 * k, fg(0.85))
|
||||
// The launcher's brand mark IS the poster when we ship one for it. Inset to
|
||||
// ~44% of the card so it reads as a mark on a face rather than a cropped
|
||||
// cover; `launcher_mark` letterboxes inside that box, so a non-square master
|
||||
// (Steam 496x512, Playnite 1024x1024) keeps its proportions.
|
||||
let mark = (!game.icon.is_empty())
|
||||
.then(|| {
|
||||
let side = (card_w.min(card_h) as f32) * 0.44;
|
||||
crate::launcher_icons::launcher_mark(
|
||||
&game.icon,
|
||||
Rect::from_xywh(
|
||||
(card_w as f32 - side) / 2.0,
|
||||
(card_h as f32 - side) / 2.0,
|
||||
side,
|
||||
side,
|
||||
),
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
if let Some(path) = mark {
|
||||
canvas.draw_path(&path, &Paint::new(fg(0.85), None));
|
||||
} else {
|
||||
(initials(&game.title), 38.0 * k, fg(0.45))
|
||||
};
|
||||
let font = fonts.font(W::Bold, size);
|
||||
let tw = font.measure_str(&glyph, None).0;
|
||||
canvas.draw_str(
|
||||
&glyph,
|
||||
Point::new(
|
||||
(card_w as f32 - tw) / 2.0,
|
||||
card_h as f32 / 2.0 + 13.0 * k as f32,
|
||||
),
|
||||
&font,
|
||||
&Paint::new(ink, None),
|
||||
);
|
||||
let (glyph, size, ink) = if game.launcher {
|
||||
(store_label(&game.store).to_string(), 22.0 * k, fg(0.85))
|
||||
} else {
|
||||
(initials(&game.title), 38.0 * k, fg(0.45))
|
||||
};
|
||||
let font = fonts.font(W::Bold, size);
|
||||
let tw = font.measure_str(&glyph, None).0;
|
||||
canvas.draw_str(
|
||||
&glyph,
|
||||
Point::new(
|
||||
(card_w as f32 - tw) / 2.0,
|
||||
card_h as f32 / 2.0 + 13.0 * k as f32,
|
||||
),
|
||||
&font,
|
||||
&Paint::new(ink, None),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Store badge, top-left.
|
||||
|
||||
@@ -394,6 +394,7 @@ fn dump_console_screens() {
|
||||
title: (*t).to_string(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
pub struct GameEntry {
|
||||
@@ -177,6 +211,25 @@ pub struct GameEntry {
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
|
||||
#[serde(default, skip_serializing_if = "GameRole::is_game")]
|
||||
pub role: GameRole,
|
||||
/// Which brand mark to draw for this entry, as a **token** — `steam`, `heroic`, `playnite` —
|
||||
/// never image bytes and never a URL. See [`is_icon_token`].
|
||||
///
|
||||
/// It exists for launcher tiles, which by design ship no cover art: a launcher's own icon is
|
||||
/// square, every client cover-crops a 2:3 poster, and the crop turns a mark into a strip — so
|
||||
/// until now those tiles were the launcher's name on a flat accent face. The token lets a client
|
||||
/// draw the real mark from art it already ships, at whatever size its tile happens to be.
|
||||
///
|
||||
/// A token rather than art on the wire because the host's art proxy serves *raster* bytes only
|
||||
/// ([`art::local_art_bytes`] sniffs the container and refuses anything else, SVG very much
|
||||
/// included — it is script-capable XML and the console renders art in a browser). Sending the
|
||||
/// name of a mark instead of the mark keeps that refusal intact, keeps the glyph vector at every
|
||||
/// tile size, and lets it take the tile's ink.
|
||||
///
|
||||
/// Ordinary titles may carry one too — nothing here is launcher-specific — but nothing sets it
|
||||
/// for them: a game has real cover art, which is strictly better than a brand mark.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(example = "steam")]
|
||||
pub icon: Option<String>,
|
||||
/// How the host would launch it, when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub launch: Option<LaunchSpec>,
|
||||
@@ -388,6 +441,7 @@ mod tests {
|
||||
title: title.into(),
|
||||
art: Artwork::default(),
|
||||
role: GameRole::default(),
|
||||
icon: None,
|
||||
launch: None,
|
||||
provider: None,
|
||||
detect: DetectSpec::default(),
|
||||
|
||||
@@ -39,6 +39,10 @@ pub struct CustomEntry {
|
||||
/// Whether this entry is a game or the launcher itself — see [`GameRole`].
|
||||
#[serde(default, skip_serializing_if = "GameRole::is_game")]
|
||||
pub role: GameRole,
|
||||
/// Which brand mark a client should draw for this entry — see [`GameEntry::icon`]. A token
|
||||
/// (`steam`, `heroic`), never bytes and never a URL.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// How to recognize this title's process once it is running (design §9) — the one thing a
|
||||
/// provider knows that the host cannot work out for itself.
|
||||
///
|
||||
@@ -68,6 +72,10 @@ pub struct CustomInput {
|
||||
/// entry is legal (an operator may want a "Steam" tile without installing the steam plugin).
|
||||
#[serde(default)]
|
||||
pub role: GameRole,
|
||||
/// Which brand mark to draw — see [`GameEntry::icon`]. Hand-settable for the same reason `role`
|
||||
/// is: an operator's own "Steam" tile should be able to look like one.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// How to recognize this title's process — see [`CustomEntry::detect`].
|
||||
#[serde(default)]
|
||||
pub detect: DetectHint,
|
||||
@@ -95,6 +103,10 @@ pub struct ProviderEntryInput {
|
||||
/// emits its `launchers(cfg)` entries with `role: "launcher"`.
|
||||
#[serde(default)]
|
||||
pub role: GameRole,
|
||||
/// Which brand mark to draw — see [`GameEntry::icon`]. This is the field a library plugin sets
|
||||
/// on its `launchers(cfg)` tiles, and the whole reason the token exists.
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
/// How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its
|
||||
/// titles' install directories (Playnite does) should send them: it is what lets a game launched
|
||||
/// through the provider's own client still end its session when the player quits.
|
||||
@@ -127,6 +139,7 @@ impl From<CustomEntry> for GameEntry {
|
||||
title: c.title,
|
||||
art: c.art,
|
||||
role: c.role,
|
||||
icon: c.icon,
|
||||
launch: c.launch,
|
||||
provider: c.provider,
|
||||
detect,
|
||||
@@ -310,6 +323,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
external_id: None,
|
||||
store: None,
|
||||
role: input.role,
|
||||
icon: input.icon,
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
};
|
||||
@@ -334,6 +348,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
|
||||
slot.launch = input.launch;
|
||||
slot.prep = input.prep;
|
||||
slot.role = input.role;
|
||||
slot.icon = input.icon;
|
||||
slot.detect = input.detect;
|
||||
slot.meta = input.meta;
|
||||
let updated = slot.clone();
|
||||
@@ -542,6 +557,7 @@ fn reconcile_entries(
|
||||
// (`<store>:<external_id>`) — see `library_id_for`.
|
||||
store: store.map(str::to_string),
|
||||
role: input.role,
|
||||
icon: input.icon,
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
});
|
||||
@@ -660,6 +676,7 @@ mod tests {
|
||||
external_id: None,
|
||||
store: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
@@ -673,6 +690,7 @@ mod tests {
|
||||
launch: None,
|
||||
prep: Vec::new(),
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
detect: DetectHint::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 —
|
||||
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
|
||||
/// pre-metadata `library.json` / payload still parses (all-optional).
|
||||
|
||||
@@ -101,6 +101,7 @@ fn epic_entry(
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("epic:{app_name}"),
|
||||
store: "epic".into(),
|
||||
|
||||
@@ -58,6 +58,7 @@ fn gog_games() -> Vec<GameEntry> {
|
||||
out.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "gog".into(),
|
||||
|
||||
@@ -110,6 +110,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("heroic:{runner}:{app_name}"),
|
||||
store: "heroic".into(),
|
||||
|
||||
@@ -85,6 +85,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("lutris:{id}"),
|
||||
store: "lutris".into(),
|
||||
|
||||
@@ -30,6 +30,7 @@ impl LibraryProvider for SteamProvider {
|
||||
.map(|app| GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", app.appid),
|
||||
store: "steam".into(),
|
||||
@@ -385,6 +386,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", sc.appid),
|
||||
store: "steam".into(),
|
||||
|
||||
@@ -71,6 +71,7 @@ fn xbox_games() -> Vec<GameEntry> {
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
role: GameRole::Game,
|
||||
icon: None,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "xbox".into(),
|
||||
|
||||
@@ -29,6 +29,7 @@ fn check_entry_fields(
|
||||
art: &crate::library::Artwork,
|
||||
launch: Option<&crate::library::LaunchSpec>,
|
||||
prep: &[crate::hooks::PrepCmd],
|
||||
icon: Option<&str>,
|
||||
) -> Option<(String, Response)> {
|
||||
if !lane.may_set_privileged_fields() {
|
||||
if let Some(field) = crate::library::privileged_field(launch, prep) {
|
||||
@@ -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)
|
||||
.err()
|
||||
.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() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
||||
}
|
||||
if let Some((_, denied)) =
|
||||
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep)
|
||||
{
|
||||
if let Some((_, denied)) = check_entry_fields(
|
||||
lane,
|
||||
&input.art,
|
||||
input.launch.as_ref(),
|
||||
&input.prep,
|
||||
input.icon.as_deref(),
|
||||
) {
|
||||
return denied;
|
||||
}
|
||||
match crate::library::add_custom(input) {
|
||||
@@ -336,9 +348,13 @@ pub(crate) async fn update_custom_game(
|
||||
if input.title.trim().is_empty() {
|
||||
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
|
||||
}
|
||||
if let Some((_, denied)) =
|
||||
check_entry_fields(lane, &input.art, input.launch.as_ref(), &input.prep)
|
||||
{
|
||||
if let Some((_, denied)) = check_entry_fields(
|
||||
lane,
|
||||
&input.art,
|
||||
input.launch.as_ref(),
|
||||
&input.prep,
|
||||
input.icon.as_deref(),
|
||||
) {
|
||||
return denied;
|
||||
}
|
||||
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
|
||||
// one privileged field anywhere in it is one command execution.
|
||||
for (i, e) in inputs.iter().enumerate() {
|
||||
if let Some((reason, denied)) = check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep)
|
||||
if let Some((reason, denied)) =
|
||||
check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep, e.icon.as_deref())
|
||||
{
|
||||
tracing::warn!(
|
||||
provider,
|
||||
|
||||
@@ -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
|
||||
Manager and Playnite plugins get your collection into the grid, box art and all.
|
||||
|
||||
A library plugin can also publish a **launcher tile** — an entry that opens Steam Big Picture,
|
||||
Heroic, Lutris or Playnite itself rather than a game, so you can install or fix something from the
|
||||
couch. Clients group those into their own row above your titles, and each one draws its launcher's
|
||||
logo. A launcher tile you don't want is a switch in that plugin's settings.
|
||||
|
||||
Entries a plugin owns are read-only to you. The host refuses a hand edit or a delete of one, because
|
||||
the next sync would overwrite it anyway — change the title at its source and let the plugin sync
|
||||
again. Only the plugin can remove its own entries, and it removes every one of them at once. Your
|
||||
|
||||
@@ -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
|
||||
* `role: "launcher"`; the kit does not stamp it for you, because a plugin may legitimately want
|
||||
* an entry that opens a launcher but still lists as an ordinary game.
|
||||
*
|
||||
* Give each one an `icon` too — the token for its brand mark (`ProviderEntry.icon`). Without it
|
||||
* the tile falls back to the launcher's name on a flat accent face, which is legible but is the
|
||||
* blandest thing in the grid.
|
||||
*/
|
||||
readonly launchers?: (cfg: S["Type"]) => ReadonlyArray<ProviderEntry>;
|
||||
/** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */
|
||||
|
||||
@@ -60,6 +60,26 @@ export type LaunchSpec = typeof LaunchSpec.Type;
|
||||
export const GameRole = Schema.Literals(["game", "launcher"]);
|
||||
export type GameRole = typeof GameRole.Type;
|
||||
|
||||
/**
|
||||
* The brand marks the shipped clients draw for a launcher tile. A plugin puts one of these in an
|
||||
* entry's `icon` and every client resolves it against the art it bundles
|
||||
* (`assets/launcher-icons` — provenance and licensing in that directory's README).
|
||||
*
|
||||
* Not a union type on purpose, exactly like {@link LaunchSpec}'s `kind`: a client that has never
|
||||
* heard of a token falls back to naming the launcher on an accent face — which is what every
|
||||
* launcher tile looked like before icons existed — so a plugin naming a mark a *newer* client
|
||||
* ships must not fail to typecheck against an older kit.
|
||||
*/
|
||||
export const LAUNCHER_ICONS = [
|
||||
"steam",
|
||||
"lutris",
|
||||
"heroic",
|
||||
"playnite",
|
||||
"epic",
|
||||
"gog",
|
||||
"xbox",
|
||||
] as const;
|
||||
|
||||
export const PrepStep = Schema.Struct({
|
||||
do: Schema.String,
|
||||
undo: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||
@@ -142,6 +162,27 @@ export const ProviderEntry = Schema.Struct({
|
||||
detect: Schema.optionalKey(DetectHint),
|
||||
/** `"game"` (default) or `"launcher"` — see {@link GameRole}. */
|
||||
role: Schema.optionalKey(GameRole),
|
||||
/**
|
||||
* Which brand mark a client should draw for this entry — a **token** ({@link LAUNCHER_ICONS}),
|
||||
* never image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`; the host rejects anything else.
|
||||
*
|
||||
* This is what makes a launcher tile look like its launcher. Launcher entries ship no cover art
|
||||
* by design — a launcher's own icon is square, clients cover-crop a 2:3 poster, and the crop
|
||||
* turns a mark into a strip — so before this they were the launcher's name on a flat accent
|
||||
* face. Naming the mark instead of sending it keeps the glyph vector at any tile size, lets it
|
||||
* take the tile's ink, and adds nothing to a reconcile payload that is already body-limited.
|
||||
*
|
||||
* Sending art instead is not an option the host leaves open: its art proxy serves raster
|
||||
* containers only and refuses SVG outright, because SVG is script-capable XML and the web
|
||||
* console renders library art in a browser.
|
||||
*
|
||||
* A token no client bundles is not an error — that tile just falls back to its name. To get a
|
||||
* new launcher's mark shipped, open a PR adding the master to `assets/launcher-icons`.
|
||||
*
|
||||
* Set it on your `launchers(cfg)` entries. Ordinary titles may carry one, but shouldn't: a game
|
||||
* has real cover art, which beats a brand mark every time.
|
||||
*/
|
||||
icon: Schema.optionalKey(Schema.String),
|
||||
...GameMeta.fields,
|
||||
});
|
||||
export type ProviderEntry = typeof ProviderEntry.Type;
|
||||
|
||||
@@ -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."
|
||||
@@ -88,6 +88,18 @@ VENDORED_TREES = [
|
||||
("Bazzite logo (vendored, assets/os-icons)",
|
||||
"assets/os-icons/LICENSES/bazzite.txt",
|
||||
"https://github.com/ublue-os/bazzite"),
|
||||
# Launcher brand marks for the library's launcher tiles (assets/launcher-icons/, CC BY 4.0 /
|
||||
# CC0 / MIT — see assets/launcher-icons/README.md). A separate registry from the OS marks
|
||||
# above, with its own sources, so it carries its own notices even where a vendor overlaps.
|
||||
("Font Awesome Free brand icons (vendored, assets/launcher-icons)",
|
||||
"assets/launcher-icons/LICENSES/font-awesome-brands.txt",
|
||||
"https://fontawesome.com"),
|
||||
("Simple Icons (vendored, assets/launcher-icons)",
|
||||
"assets/launcher-icons/LICENSES/simple-icons.txt",
|
||||
"https://simpleicons.org"),
|
||||
("Playnite logo (vendored, assets/launcher-icons)",
|
||||
"assets/launcher-icons/LICENSES/playnite.txt",
|
||||
"https://github.com/JosefNemec/Playnite"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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}}");
|
||||
}}
|
||||
}}
|
||||
""",
|
||||
)
|
||||
@@ -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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Eye, EyeOff, Pencil, Trash2 } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
|
||||
import { LauncherIcon } from "@/components/launcher-icon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -63,6 +64,10 @@ export const GameCard: FC<GameCardProps> = ({
|
||||
(u): u is string => !!u && !failed[u],
|
||||
);
|
||||
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 (
|
||||
<Card className="group relative overflow-hidden">
|
||||
@@ -78,6 +83,16 @@ export const GameCard: FC<GameCardProps> = ({
|
||||
className={`size-full object-cover${hidden ? " opacity-30" : ""}`}
|
||||
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
|
||||
className={`flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground${
|
||||
|
||||