diff --git a/api/openapi.json b/api/openapi.json index 7106610a..a613477c 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -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:` or `custom:`.", @@ -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": [ { diff --git a/assets/launcher-icons/LICENSES/font-awesome-brands.txt b/assets/launcher-icons/LICENSES/font-awesome-brands.txt new file mode 100644 index 00000000..e5c579ee --- /dev/null +++ b/assets/launcher-icons/LICENSES/font-awesome-brands.txt @@ -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. diff --git a/assets/launcher-icons/LICENSES/playnite.txt b/assets/launcher-icons/LICENSES/playnite.txt new file mode 100644 index 00000000..94f0ef59 --- /dev/null +++ b/assets/launcher-icons/LICENSES/playnite.txt @@ -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. diff --git a/assets/launcher-icons/LICENSES/simple-icons.txt b/assets/launcher-icons/LICENSES/simple-icons.txt new file mode 100644 index 00000000..229c45e1 --- /dev/null +++ b/assets/launcher-icons/LICENSES/simple-icons.txt @@ -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. diff --git a/assets/launcher-icons/README.md b/assets/launcher-icons/README.md new file mode 100644 index 00000000..df830a53 --- /dev/null +++ b/assets/launcher-icons/README.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. diff --git a/assets/launcher-icons/epic.svg b/assets/launcher-icons/epic.svg new file mode 100644 index 00000000..e71449ee --- /dev/null +++ b/assets/launcher-icons/epic.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/launcher-icons/gog.svg b/assets/launcher-icons/gog.svg new file mode 100644 index 00000000..45ea9d77 --- /dev/null +++ b/assets/launcher-icons/gog.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/launcher-icons/heroic.svg b/assets/launcher-icons/heroic.svg new file mode 100644 index 00000000..e2043c99 --- /dev/null +++ b/assets/launcher-icons/heroic.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/launcher-icons/lutris.svg b/assets/launcher-icons/lutris.svg new file mode 100644 index 00000000..932aa4e9 --- /dev/null +++ b/assets/launcher-icons/lutris.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/launcher-icons/playnite.svg b/assets/launcher-icons/playnite.svg new file mode 100644 index 00000000..ceb7471a --- /dev/null +++ b/assets/launcher-icons/playnite.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/launcher-icons/steam.svg b/assets/launcher-icons/steam.svg new file mode 100644 index 00000000..9675e835 --- /dev/null +++ b/assets/launcher-icons/steam.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/launcher-icons/xbox.svg b/assets/launcher-icons/xbox.svg new file mode 100644 index 00000000..da6554b6 --- /dev/null +++ b/assets/launcher-icons/xbox.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 525ee127..c34d17c0 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -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) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/LauncherIcons.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/LauncherIcons.kt new file mode 100644 index 00000000..e7c67077 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/LauncherIcons.kt @@ -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 = 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() + +/** + * 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() + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt index 3dfbeeb2..b89d3854 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt @@ -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"), ), ) } diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift index 0464dd60..7c11fe88 100644 --- a/clients/apple/Package.swift +++ b/clients/apple/Package.swift @@ -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. diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 51af4f06..89a289f0 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -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) { diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 6e833a1b..4ca9841f 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -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)) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 0048da24..7dc9f271 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -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) + } } } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift index 91e0035c..ba80795b 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift @@ -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. diff --git a/clients/apple/Sources/PunktfunkKit/LauncherIcon.swift b/clients/apple/Sources/PunktfunkKit/LauncherIcon.swift new file mode 100644 index 00000000..58daf462 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/LauncherIcon.swift @@ -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 = [ + "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) +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-epic.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-epic.imageset/Contents.json new file mode 100644 index 00000000..79e1b807 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-epic.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "epic.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-epic.imageset/epic.pdf b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-epic.imageset/epic.pdf new file mode 100644 index 00000000..c177b152 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-epic.imageset/epic.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-gog.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-gog.imageset/Contents.json new file mode 100644 index 00000000..3acc72b9 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-gog.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "gog.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-gog.imageset/gog.pdf b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-gog.imageset/gog.pdf new file mode 100644 index 00000000..c4952ff3 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-gog.imageset/gog.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-heroic.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-heroic.imageset/Contents.json new file mode 100644 index 00000000..8b1fa314 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-heroic.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "heroic.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-heroic.imageset/heroic.pdf b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-heroic.imageset/heroic.pdf new file mode 100644 index 00000000..e285741c Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-heroic.imageset/heroic.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-lutris.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-lutris.imageset/Contents.json new file mode 100644 index 00000000..45fcef50 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-lutris.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "lutris.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-lutris.imageset/lutris.pdf b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-lutris.imageset/lutris.pdf new file mode 100644 index 00000000..e490e0af Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-lutris.imageset/lutris.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-playnite.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-playnite.imageset/Contents.json new file mode 100644 index 00000000..cdbab367 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-playnite.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "playnite.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-playnite.imageset/playnite.pdf b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-playnite.imageset/playnite.pdf new file mode 100644 index 00000000..e2585b7e Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-playnite.imageset/playnite.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-steam.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-steam.imageset/Contents.json new file mode 100644 index 00000000..0efde325 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-steam.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "steam.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-steam.imageset/steam.pdf b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-steam.imageset/steam.pdf new file mode 100644 index 00000000..eaa282cc Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-steam.imageset/steam.pdf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-xbox.imageset/Contents.json b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-xbox.imageset/Contents.json new file mode 100644 index 00000000..c229b946 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-xbox.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { "filename" : "xbox.pdf", "idiom" : "universal" } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-xbox.imageset/xbox.pdf b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-xbox.imageset/xbox.pdf new file mode 100644 index 00000000..47c9e659 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets/launcher-xbox.imageset/xbox.pdf differ diff --git a/clients/linux/data/icons/scalable/actions/pf-launcher-epic-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-launcher-epic-symbolic.svg new file mode 100644 index 00000000..4e07c14f --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-launcher-epic-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-launcher-gog-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-launcher-gog-symbolic.svg new file mode 100644 index 00000000..a26a3983 --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-launcher-gog-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-launcher-heroic-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-launcher-heroic-symbolic.svg new file mode 100644 index 00000000..88343009 --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-launcher-heroic-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-launcher-lutris-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-launcher-lutris-symbolic.svg new file mode 100644 index 00000000..6cc05e15 --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-launcher-lutris-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-launcher-playnite-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-launcher-playnite-symbolic.svg new file mode 100644 index 00000000..519c8811 --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-launcher-playnite-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-launcher-steam-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-launcher-steam-symbolic.svg new file mode 100644 index 00000000..f11bc17d --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-launcher-steam-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/icons/scalable/actions/pf-launcher-xbox-symbolic.svg b/clients/linux/data/icons/scalable/actions/pf-launcher-xbox-symbolic.svg new file mode 100644 index 00000000..2edbce91 --- /dev/null +++ b/clients/linux/data/icons/scalable/actions/pf-launcher-xbox-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/clients/linux/data/resources.gresource.xml b/clients/linux/data/resources.gresource.xml index ff0944dd..0dc09083 100644 --- a/clients/linux/data/resources.gresource.xml +++ b/clients/linux/data/resources.gresource.xml @@ -1,9 +1,17 @@ + icons/scalable/actions/pf-launcher-steam-symbolic.svg + icons/scalable/actions/pf-launcher-lutris-symbolic.svg + icons/scalable/actions/pf-launcher-heroic-symbolic.svg + icons/scalable/actions/pf-launcher-playnite-symbolic.svg + icons/scalable/actions/pf-launcher-epic-symbolic.svg + icons/scalable/actions/pf-launcher-gog-symbolic.svg + icons/scalable/actions/pf-launcher-xbox-symbolic.svg icons/scalable/actions/pf-os-windows-symbolic.svg icons/scalable/actions/pf-os-apple-symbolic.svg icons/scalable/actions/pf-os-linux-symbolic.svg diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index a75bf9de..c8bb8ae3 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -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); } "; diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 2ab415d9..c1f15a34 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -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"), diff --git a/clients/linux/src/ui_library.rs b/clients/linux/src/ui_library.rs index be1d45d7..cf6bbb25 100644 --- a/clients/linux/src/ui_library.rs +++ b/clients/linux/src/ui_library.rs @@ -285,27 +285,61 @@ fn render(state: &Rc, 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--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 { + 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, 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); diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 24b7298e..1ba24b6b 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -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(), ); diff --git a/clients/windows/assets/launchers/epic.png b/clients/windows/assets/launchers/epic.png new file mode 100644 index 00000000..020acdef Binary files /dev/null and b/clients/windows/assets/launchers/epic.png differ diff --git a/clients/windows/assets/launchers/gog.png b/clients/windows/assets/launchers/gog.png new file mode 100644 index 00000000..ef1d7423 Binary files /dev/null and b/clients/windows/assets/launchers/gog.png differ diff --git a/clients/windows/assets/launchers/heroic.png b/clients/windows/assets/launchers/heroic.png new file mode 100644 index 00000000..478108dd Binary files /dev/null and b/clients/windows/assets/launchers/heroic.png differ diff --git a/clients/windows/assets/launchers/lutris.png b/clients/windows/assets/launchers/lutris.png new file mode 100644 index 00000000..21c50052 Binary files /dev/null and b/clients/windows/assets/launchers/lutris.png differ diff --git a/clients/windows/assets/launchers/playnite.png b/clients/windows/assets/launchers/playnite.png new file mode 100644 index 00000000..cac424d8 Binary files /dev/null and b/clients/windows/assets/launchers/playnite.png differ diff --git a/clients/windows/assets/launchers/steam.png b/clients/windows/assets/launchers/steam.png new file mode 100644 index 00000000..b7bea550 Binary files /dev/null and b/clients/windows/assets/launchers/steam.png differ diff --git a/clients/windows/assets/launchers/xbox.png b/clients/windows/assets/launchers/xbox.png new file mode 100644 index 00000000..dcbc31c9 Binary files /dev/null and b/clients/windows/assets/launchers/xbox.png differ diff --git a/clients/windows/src/app/launcher_icons.rs b/clients/windows/src/app/launcher_icons.rs new file mode 100644 index 00000000..86b41e4f --- /dev/null +++ b/clients/windows/src/app/launcher_icons.rs @@ -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 { + 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 { + static DIR: OnceLock> = 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('\\', "/"))) +} diff --git a/clients/windows/src/app/library.rs b/clients/windows/src/app/library.rs index 42561c44..8b444e3b 100644 --- a/clients/windows/src/app/library.rs +++ b/clients/windows/src/app/library.rs @@ -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, } #[derive(Clone, PartialEq, Default)] @@ -140,6 +144,7 @@ pub(crate) fn start_fetch(ctx: &Arc, set_library: &AsyncSetState 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, diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index fbf74786..9a9b2c89 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -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()), diff --git a/crates/pf-client-core/src/library.rs b/crates/pf-client-core/src/library.rs index 84abb420..74d28e43 100644 --- a/crates/pf-client-core/src/library.rs +++ b/crates/pf-client-core/src/library.rs @@ -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:`, @@ -73,6 +80,17 @@ pub struct GameEntry { /// future value must never fail the whole library decode. #[serde(default)] pub role: Option, + /// 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, } 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"). diff --git a/crates/pf-console-ui/src/launcher_icons.rs b/crates/pf-console-ui/src/launcher_icons.rs new file mode 100644 index 00000000..2d8c740d --- /dev/null +++ b/crates/pf-console-ui/src/launcher_icons.rs @@ -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>; + +/// `(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 { + static CACHE: OnceLock> = 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 { + 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}" + ); + } +} diff --git a/crates/pf-console-ui/src/lib.rs b/crates/pf-console-ui/src/lib.rs index 94852f10..7e5a15f2 100644 --- a/crates/pf-console-ui/src/lib.rs +++ b/crates/pf-console-ui/src/lib.rs @@ -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; diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index 5f4b5cd3..9cf376fa 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -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(), ); diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index 53ba78ec..a85978f1 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -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. diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index 39e793a4..5d223633 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -394,6 +394,7 @@ fn dump_console_screens() { title: (*t).to_string(), store: "steam".into(), launcher: false, + icon: String::new(), }) .collect(), ); diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index 8e8a8393..0ef559d7 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -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, /// How the host would launch it, when known. #[serde(skip_serializing_if = "Option::is_none")] pub launch: Option, @@ -388,6 +441,7 @@ mod tests { title: title.into(), art: Artwork::default(), role: GameRole::default(), + icon: None, launch: None, provider: None, detect: DetectSpec::default(), diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index 2430dde9..9e40bfdc 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -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, /// 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, /// 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, /// 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 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 { 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:`) — 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). diff --git a/crates/punktfunk-host/src/library/epic.rs b/crates/punktfunk-host/src/library/epic.rs index 07e72545..a6cdde22 100644 --- a/crates/punktfunk-host/src/library/epic.rs +++ b/crates/punktfunk-host/src/library/epic.rs @@ -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(), diff --git a/crates/punktfunk-host/src/library/gog.rs b/crates/punktfunk-host/src/library/gog.rs index f7cc373f..c459f988 100644 --- a/crates/punktfunk-host/src/library/gog.rs +++ b/crates/punktfunk-host/src/library/gog.rs @@ -58,6 +58,7 @@ fn gog_games() -> Vec { out.push(GameEntry { provider: None, role: GameRole::Game, + icon: None, meta: GameMeta::pc(), id, store: "gog".into(), diff --git a/crates/punktfunk-host/src/library/heroic.rs b/crates/punktfunk-host/src/library/heroic.rs index d7badd03..e7a70b13 100644 --- a/crates/punktfunk-host/src/library/heroic.rs +++ b/crates/punktfunk-host/src/library/heroic.rs @@ -110,6 +110,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result rusqlite::Result> { games.push(GameEntry { provider: None, role: GameRole::Game, + icon: None, meta: GameMeta::pc(), id: format!("lutris:{id}"), store: "lutris".into(), diff --git a/crates/punktfunk-host/src/library/steam.rs b/crates/punktfunk-host/src/library/steam.rs index 71425505..61d50e84 100644 --- a/crates/punktfunk-host/src/library/steam.rs +++ b/crates/punktfunk-host/src/library/steam.rs @@ -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 { Some(GameEntry { provider: None, role: GameRole::Game, + icon: None, meta: GameMeta::pc(), id: format!("steam:{}", sc.appid), store: "steam".into(), diff --git a/crates/punktfunk-host/src/library/xbox.rs b/crates/punktfunk-host/src/library/xbox.rs index 82db43f7..88e78630 100644 --- a/crates/punktfunk-host/src/library/xbox.rs +++ b/crates/punktfunk-host/src/library/xbox.rs @@ -71,6 +71,7 @@ fn xbox_games() -> Vec { games.push(GameEntry { provider: None, role: GameRole::Game, + icon: None, meta: GameMeta::pc(), id, store: "xbox".into(), diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index 7e66ace1..87bb6c97 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -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, diff --git a/docs-site/content/docs/game-library.md b/docs-site/content/docs/game-library.md index f10d7dd1..4bc4ba97 100644 --- a/docs-site/content/docs/game-library.md +++ b/docs-site/content/docs/game-library.md @@ -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 diff --git a/plugin-kit/src/library/define.ts b/plugin-kit/src/library/define.ts index 0ee87419..037a5adf 100644 --- a/plugin-kit/src/library/define.ts +++ b/plugin-kit/src/library/define.ts @@ -65,6 +65,10 @@ export interface LibraryPluginDef { * 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; /** Launcher data dirs to watch, so a newly installed game appears without waiting for a poll. */ diff --git a/plugin-kit/src/wire.ts b/plugin-kit/src/wire.ts index ffd65d05..115d2283 100644 --- a/plugin-kit/src/wire.ts +++ b/plugin-kit/src/wire.ts @@ -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; diff --git a/scripts/gen-launcher-icons.sh b/scripts/gen-launcher-icons.sh new file mode 100644 index 00000000..05f78a8d --- /dev/null +++ b/scripts/gen-launcher-icons.sh @@ -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" </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." diff --git a/scripts/gen-third-party-notices.py b/scripts/gen-third-party-notices.py index 4a44f695..c6038764 100755 --- a/scripts/gen-third-party-notices.py +++ b/scripts/gen-third-party-notices.py @@ -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"), ] diff --git a/scripts/gen_launcher_icon_tables.py b/scripts/gen_launcher_icon_tables.py new file mode 100644 index 00000000..27788d0b --- /dev/null +++ b/scripts/gen_launcher_icon_tables.py @@ -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']*\sd="([^"]+)"', svg) + if len(paths) != 1: + sys.exit(f"{token}: expected exactly one , 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 = {{ +{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 +\t\t\t{{label && {{label}}}} +\t\t\t +\t\t +\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 = mapOf( +{rows} +) + +private val CACHE = HashMap() + +/** + * 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>; + +/// `(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 {{ + static CACHE: OnceLock> = 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 {{ + 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}}"); + }} +}} +""", +) diff --git a/sdk/src/gen/punktfunk.ts b/sdk/src/gen/punktfunk.ts index fd3c90c0..4ef52149 100644 --- a/sdk/src/gen/punktfunk.ts +++ b/sdk/src/gen/punktfunk.ts @@ -47,6 +47,10 @@ export type GameSession = "auto" | "dedicated" export const GameSession = Schema.Literals(["auto", "dedicated"]).annotate({ "description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop)." }) export type Health = { readonly "abi_version": number, readonly "status": string, readonly "version": string } export const Health = Schema.Struct({ "abi_version": Schema.Number.annotate({ "description": "`punktfunk-core` C ABI version.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "status": Schema.String.annotate({ "description": "Always `\"ok\"` when the host responds." }), "version": Schema.String.annotate({ "description": "`punktfunk-host` crate version." }) }).annotate({ "description": "Liveness + version probe." }) +export type HiddenState = { readonly "hidden": boolean, readonly "id": string } +export const HiddenState = Schema.Struct({ "hidden": Schema.Boolean.annotate({ "description": "Its visibility after the call." }), "id": Schema.String.annotate({ "description": "The entry id the call addressed." }) }).annotate({ "description": "What `setLibraryEntryHidden` echoes back." }) +export type HiddenToggle = { readonly "hidden": boolean } +export const HiddenToggle = Schema.Struct({ "hidden": Schema.Boolean.annotate({ "description": "Whether this title should be hidden from every play surface." }) }).annotate({ "description": "Request body for `setLibraryEntryHidden`." }) export type HookEntry = { readonly "debounce_ms"?: number, readonly "filter"?: null | { readonly "app"?: string | null, readonly "client"?: string | null, readonly "fingerprint"?: string | null, readonly "plane"?: null | "native" | "gamestream" }, readonly "hmac_secret_file"?: string | null, readonly "on": string, readonly "run"?: string | null, readonly "timeout_s"?: number, readonly "webhook"?: string | null } export const HookEntry = Schema.Struct({ "debounce_ms": Schema.optionalKey(Schema.Number.annotate({ "description": "Minimum interval between firings of this hook, in milliseconds. 0 = fire every time.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "filter": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "app": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Launched app id/title (`stream.*` events)." })), "client": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Client/device name (for `session.*`: the short client label the Dashboard shows)." })), "fingerprint": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Certificate fingerprint (hex, case-insensitive)." })), "plane": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Literals(["native", "gamestream"]).annotate({ "description": "Protocol plane (`native` / `gamestream`)." })], { mode: "oneOf" })) }).annotate({ "description": "Exact-match constraints on the event's fields; every present field must match." })], { mode: "oneOf" })), "hmac_secret_file": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "File holding the webhook HMAC secret (`X-Punktfunk-Signature: sha256=`). The file\nshould be operator-owned and private; a world-readable secret is warned about." })), "on": Schema.String.annotate({ "description": "Which events fire this hook: an exact kind (`stream.started`) or a `domain.*` prefix\n(`pairing.*`) — the same vocabulary as the SSE `?kinds=` filter." }), "run": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Shell command to execute (detached, event JSON on stdin + `PF_EVENT_*` env)." })), "timeout_s": Schema.optionalKey(Schema.Number.annotate({ "description": "Exec timeout in seconds (1–600, default 30); the process group is killed on expiry.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))), "webhook": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "URL to POST the event JSON to." })) }).annotate({ "description": "One hook: fire `run` and/or `webhook` when an event matching `on` (+ `filter`) occurs." }) export type HostFacts = { readonly "platform": string, readonly "version": string } @@ -139,16 +143,16 @@ export type UninstallRequest = { readonly "pkg": string } export const UninstallRequest = Schema.Struct({ "pkg": Schema.String }) export type UpdateStatus = { readonly "apply": string, readonly "available": boolean, readonly "channel": string, readonly "channel_hint": string, readonly "check_disabled": boolean, readonly "current_version": string, readonly "install_kind": string, readonly "job"?: null | { readonly "received_bytes": number, readonly "stage": string, readonly "started_unix": number, readonly "target_version": string, readonly "total_bytes"?: never }, readonly "last_checked_unix"?: never, readonly "last_error"?: string | null, readonly "last_result"?: null | { readonly "error"?: string | null, readonly "finished_unix": number, readonly "from": string, readonly "log_path"?: string | null, readonly "ok": boolean, readonly "stage"?: string | null, readonly "staged"?: boolean, readonly "to": string }, readonly "manifest"?: null | { readonly "notes_url": string, readonly "published_at": string, readonly "serial": number, readonly "stale": boolean, readonly "version": string }, readonly "not_published": boolean, readonly "opt_in_hint"?: string | null } export const UpdateStatus = Schema.Struct({ "apply": Schema.String.annotate({ "description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)." }), "available": Schema.Boolean.annotate({ "description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)." }), "channel": Schema.String.annotate({ "description": "Release channel this install follows: `stable` | `canary`." }), "channel_hint": Schema.String.annotate({ "description": "The copy-pastable update command for this install kind." }), "check_disabled": Schema.Boolean.annotate({ "description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)." }), "current_version": Schema.String.annotate({ "description": "The running host version." }), "install_kind": Schema.String.annotate({ "description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`." }), "job": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "received_bytes": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stage": Schema.String.annotate({ "description": "`downloading` | `verifying` | `applying` | `restarting`." }), "started_unix": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target_version": Schema.String.annotate({ "description": "The version being installed." }), "total_bytes": Schema.optionalKey(Schema.Never) }).annotate({ "description": "The apply in flight, if any." })], { mode: "oneOf" })), "last_checked_unix": Schema.optionalKey(Schema.Never), "last_error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why the last check failed, verbatim, if it did." })), "last_result": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "finished_unix": Schema.Number.annotate({ "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "from": Schema.String, "log_path": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The installer's own log file on this host, for diagnosis." })), "ok": Schema.Boolean, "stage": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stage that failed; absent on success." })), "staged": Schema.optionalKey(Schema.Boolean.annotate({ "description": "Applied but activates on the next reboot (rpm-ostree)." })), "to": Schema.String }).annotate({ "description": "Outcome of the most recent apply attempt." })], { mode: "oneOf" })), "manifest": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "notes_url": Schema.String.annotate({ "description": "Release-notes link (pinned to our forge by the manifest validator)." }), "published_at": Schema.String.annotate({ "description": "RFC-3339 publish time (display only)." }), "serial": Schema.Number.annotate({ "description": "Publish serial (unix seconds) — monotonic per channel.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "stale": Schema.Boolean.annotate({ "description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint." }), "version": Schema.String.annotate({ "description": "The released version this manifest announces." }) }).annotate({ "description": "The last verified manifest, if any check has succeeded." })], { mode: "oneOf" })), "not_published": Schema.Boolean.annotate({ "description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error." }), "opt_in_hint": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)." })) }).annotate({ "description": "The full update-check state for this host." }) -export type RuntimeStatus = { readonly "active_sessions": number, readonly "audio_streaming": boolean, readonly "games": ReadonlyArray, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "stream"?: null | { readonly "bitrate_kbps": number, readonly "codec": ApiCodec, readonly "fps": number, readonly "height": number, readonly "last_resize_ms"?: never, readonly "min_fec": number, readonly "packet_size": number, readonly "time_to_first_frame_ms"?: never, readonly "width": number }, readonly "video_streaming": boolean } -export const RuntimeStatus = Schema.Struct({ "active_sessions": Schema.Number.annotate({ "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "games": Schema.Array(ActiveGame).annotate({ "description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game." }), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices — the default plane, so on a host that has\nnever been touched by Moonlight this is the only non-zero one of the pair.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates. Native (punktfunk/1) devices pair\nagainst a separate store and are counted in `native_paired_clients` — sum the two for\n\"how many clients are paired with this host\".", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a pairing handshake is parked waiting for the user's PIN\n(submit it via `POST /api/v1/pair/pin`)." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "stream": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "codec": ApiCodec, "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "last_resize_ms": Schema.optionalKey(Schema.Never), "min_fec": Schema.Number.annotate({ "description": "Client's parity floor per FEC block (`minRequiredFecPackets`).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "packet_size": Schema.Number.annotate({ "description": "Video payload size per packet (bytes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "time_to_first_frame_ms": Schema.optionalKey(Schema.Never), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming." })], { mode: "oneOf" })), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Live host status (changes as clients launch/end sessions)." }) +export type RuntimeStatus = { readonly "active_sessions": number, readonly "audio"?: null | { readonly "last_resort": boolean, readonly "loopback"?: string | null, readonly "mic"?: string | null, readonly "mic_withheld": boolean, readonly "narrowing"?: string | null, readonly "readiness": string }, readonly "audio_streaming": boolean, readonly "games": ReadonlyArray, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "stream"?: null | { readonly "bitrate_kbps": number, readonly "codec": ApiCodec, readonly "fps": number, readonly "height": number, readonly "last_resize_ms"?: never, readonly "min_fec": number, readonly "packet_size": number, readonly "time_to_first_frame_ms"?: never, readonly "width": number }, readonly "video_streaming": boolean } +export const RuntimeStatus = Schema.Struct({ "active_sessions": Schema.Number.annotate({ "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "audio": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "last_resort": Schema.Boolean.annotate({ "description": "The loopback is the known-degraded last resort — desktop audio may be silent until the\nendpoint set changes." }), "loopback": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Friendly name of the desktop-audio loopback source; absent = desktop audio unavailable." })), "mic": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Friendly name of the virtual-mic write target; absent = mic passthrough unavailable." })), "mic_withheld": Schema.Boolean.annotate({ "description": "The mic was WITHHELD so game audio could keep the only working sink — mic passthrough\nneeds Steam installed (the host mints its own microphone) or a virtual cable." }), "narrowing": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why the chosen loopback endpoint NARROWS the desktop mix (rate/channels), when it does." })), "readiness": Schema.String.annotate({ "description": "`full` | `audio_only` | `mic_only` | `none` — whether desktop audio and mic passthrough\neach have an endpoint at all." }) }).annotate({ "description": "The audio wiring verdict (Windows hosts; absent on other platforms and before the first\nwiring pass). Present even while idle — the wiring exists for the host's lifetime." })], { mode: "oneOf" })), "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "games": Schema.Array(ActiveGame).annotate({ "description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game." }), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices — the default plane, so on a host that has\nnever been touched by Moonlight this is the only non-zero one of the pair.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates. Native (punktfunk/1) devices pair\nagainst a separate store and are counted in `native_paired_clients` — sum the two for\n\"how many clients are paired with this host\".", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a pairing handshake is parked waiting for the user's PIN\n(submit it via `POST /api/v1/pair/pin`)." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "stream": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "codec": ApiCodec, "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "last_resize_ms": Schema.optionalKey(Schema.Never), "min_fec": Schema.Number.annotate({ "description": "Client's parity floor per FEC block (`minRequiredFecPackets`).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "packet_size": Schema.Number.annotate({ "description": "Video payload size per packet (bytes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "time_to_first_frame_ms": Schema.optionalKey(Schema.Never), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming." })], { mode: "oneOf" })), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Live host status (changes as clients launch/end sessions)." }) export type DisplayStateResponse = { readonly "displays": ReadonlyArray } export const DisplayStateResponse = Schema.Struct({ "displays": Schema.Array(ApiDisplayInfo) }).annotate({ "description": "The host's managed virtual displays right now." }) export type GpuState = { readonly "active"?: null | { readonly "backend": string, readonly "id": string, readonly "name": string, readonly "sessions": number, readonly "vendor": string }, readonly "encoder_pin"?: string | null, readonly "env_override"?: string | null, readonly "gpus": ReadonlyArray, readonly "mode": string, readonly "preferred_available": boolean, readonly "preferred_id"?: string | null, readonly "preferred_name"?: string | null, readonly "selected"?: null | { readonly "id": string, readonly "name": string, readonly "source": string, readonly "vendor": string } } export const GpuState = Schema.Struct({ "active": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "backend": Schema.String.annotate({ "description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)." }), "id": Schema.String.annotate({ "description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)." }), "name": Schema.String, "sessions": Schema.Number.annotate({ "description": "Number of live encode sessions on it.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU live sessions use right now (absent while nothing is streaming)." })], { mode: "oneOf" })), "encoder_pin": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken." })), "env_override": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it." })), "gpus": Schema.Array(ApiGpu).annotate({ "description": "The host's hardware GPUs." }), "mode": Schema.String.annotate({ "description": "`auto` or `manual`." }), "preferred_available": Schema.Boolean.annotate({ "description": "Whether the preferred GPU is currently present." }), "preferred_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent." })), "preferred_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stored name of the preferred GPU (a usable label even when it is absent)." })), "selected": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "id": Schema.String, "name": Schema.String, "source": Schema.String.annotate({ "description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU the next session will use." })], { mode: "oneOf" })) }).annotate({ "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now." }) export type MonitorsResponse = { readonly "compositor"?: string | null, readonly "error"?: string | null, readonly "monitors": ReadonlyArray, readonly "pin_supported": boolean, readonly "pinned"?: string | null } export const MonitorsResponse = Schema.Struct({ "compositor": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved." })), "error": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"." })), "monitors": Schema.Array(ApiMonitorInfo).annotate({ "description": "The heads, ordered left-to-right by desktop position." }), "pin_supported": Schema.Boolean.annotate({ "description": "Whether this build can actually STREAM one of these monitors.\n\nEnumeration and capture are separate capabilities, and on Windows only the first exists: the\nheads below are real and worth showing (they explain the topology, and `/display/state`\ncross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a\nframe channel pushed by our OWN IddCx virtual display. There is no desktop-duplication\ncapturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so\n`vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.\n\nThe console renders the picker read-only on `false`. Reported as a capability rather than\nsniffed client-side from the OS so the answer comes from the build that would have to honor\nit — when a Windows mirror backend lands, this flips and the UI needs no change." }), "pinned": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"." })) }).annotate({ "description": "The host's physical monitors + which one capture is pinned to." }) -export type GameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "role"?: "game" | "launcher", readonly "store": string, readonly "title": string } -export const GameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:` or `custom:`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." }) +export type OperatorGameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art": Artwork, readonly "icon"?: string | null, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "role"?: "game" | "launcher", readonly "store": string, readonly "title": string, readonly "hidden"?: boolean } +export const OperatorGameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })), "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:` or `custom:`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String, "hidden": Schema.optionalKey(Schema.Boolean.annotate({ "description": "The operator hid this title ([`set_entry_hidden`]) — omitted when false, so the shape only\ngrows for entries that actually are hidden." })) }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." }) export type HooksConfig = { readonly "hooks"?: ReadonlyArray } export const HooksConfig = Schema.Struct({ "hooks": Schema.optionalKey(Schema.Array(HookEntry)) }).annotate({ "description": "The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body." }) export type LogPage = { readonly "dropped": boolean, readonly "entries": ReadonlyArray, readonly "next": number } @@ -171,12 +175,12 @@ export type DisplayLayoutRequest = { readonly "positions"?: { readonly [x: strin export const DisplayLayoutRequest = Schema.Struct({ "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).annotate({ "description": "`{\"\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits." }).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`)." }) export type Layout = { readonly "mode"?: LayoutMode, readonly "positions"?: { readonly [x: string]: Position } } export const Layout = Schema.Struct({ "mode": Schema.optionalKey(LayoutMode), "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON)." }) -export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "provider"?: string | null, readonly "role"?: "game" | "launcher", readonly "store"?: string | null, readonly "title": string } -export const CustomEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) -export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "role"?: "game" | "launcher", readonly "title": string } -export const CustomInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." }) -export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "role"?: "game" | "launcher", readonly "title": string } -export const ProviderEntryInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) +export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "external_id"?: string | null, readonly "icon"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "provider"?: string | null, readonly "role"?: "game" | "launcher", readonly "store"?: string | null, readonly "title": string } +export const CustomEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]." })), "store": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The **store this entry was claimed under** (D2), stamped by a `?store=`-qualified reconcile.\n`None` = an unclaimed provider entry or a manual one, both of which surface as `custom`.\n\nMaterialized onto the entry rather than looked up in [`Catalog::claims`] on every read so an\nentry is self-describing: its id and its `store` badge derive from the entry alone, and stay\ncorrect even while the claim map is being rewritten." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) +export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "icon"?: string | null, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "role"?: "game" | "launcher", readonly "title": string } +export const CustomInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A hand-added launcher\nentry is legal (an operator may want a \"Steam\" tile without installing the steam plugin)." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." }) +export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray, readonly "art"?: Artwork, readonly "detect"?: { readonly "env_marker"?: null | { readonly "key": string, readonly "value"?: string | null }, readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null, readonly "steam_appid"?: never }, readonly "external_id": string, readonly "icon"?: string | null, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray, readonly "role"?: "game" | "launcher", readonly "title": string } +export const ProviderEntryInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "env_marker": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "key": Schema.String.annotate({ "description": "The variable name (e.g. `HEROIC_GAME_ID`)." }), "value": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The exact value to require, when the launcher's value identifies *this* title. `None` matches\nthe key's mere presence — only safe for launchers that run one game at a time." })) }).annotate({ "description": "A launcher-stamped environment marker (D3) — see [`EnvMarker`]." })], { mode: "oneOf" })), "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })), "steam_appid": Schema.optionalKey(Schema.Never) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "icon": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "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": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "role": Schema.optionalKey(Schema.Literals(["game", "launcher"]).annotate({ "description": "Whether this entry is a game or the launcher itself — see [`GameRole`]. A library plugin\nemits its `launchers(cfg)` entries with `role: \"launcher\"`." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." }) export type CatalogResponse = { readonly "busy": boolean, readonly "host": HostFacts, readonly "plugins": ReadonlyArray, readonly "sources": ReadonlyArray } export const CatalogResponse = Schema.Struct({ "busy": Schema.Boolean.annotate({ "description": "True while a package operation is in flight — the console disables install buttons." }), "host": HostFacts, "plugins": Schema.Array(CatalogEntry), "sources": Schema.Array(SourceView) }) export type StatsSample = { readonly "bitrate_kbps": number, readonly "fec_recovered": number, readonly "fps": number, readonly "frames_dropped": number, readonly "mbps": number, readonly "packets_dropped": number, readonly "repeat_fps": number, readonly "send_dropped": number, readonly "session_id": number, readonly "stages": ReadonlyArray, readonly "t_ms": number } @@ -334,8 +338,8 @@ export type GetHostInfo401 = ApiError export const GetHostInfo401 = ApiError export type GetLibraryParams = { readonly "provider"?: string, readonly "platform"?: string } export const GetLibraryParams = Schema.Struct({ "provider": Schema.optionalKey(Schema.String), "platform": Schema.optionalKey(Schema.String) }) -export type GetLibrary200 = ReadonlyArray -export const GetLibrary200 = Schema.Array(GameEntry) +export type GetLibrary200 = ReadonlyArray +export const GetLibrary200 = Schema.Array(OperatorGameEntry) export type GetLibrary401 = ApiError export const GetLibrary401 = ApiError export type GetLibraryArt401 = ApiError @@ -370,6 +374,16 @@ export type DeleteCustomGame404 = ApiError export const DeleteCustomGame404 = ApiError export type DeleteCustomGame500 = ApiError export const DeleteCustomGame500 = ApiError +export type SetLibraryEntryHiddenRequestJson = HiddenToggle +export const SetLibraryEntryHiddenRequestJson = HiddenToggle +export type SetLibraryEntryHidden200 = HiddenState +export const SetLibraryEntryHidden200 = HiddenState +export type SetLibraryEntryHidden400 = ApiError +export const SetLibraryEntryHidden400 = ApiError +export type SetLibraryEntryHidden401 = ApiError +export const SetLibraryEntryHidden401 = ApiError +export type SetLibraryEntryHidden500 = ApiError +export const SetLibraryEntryHidden500 = ApiError export type ReconcileProviderEntriesParams = { readonly "store"?: string } export const ReconcileProviderEntriesParams = Schema.Struct({ "store": Schema.optionalKey(Schema.String) }) export type ReconcileProviderEntriesRequestJson = ReadonlyArray @@ -989,6 +1003,16 @@ export const make = ( "204": () => Effect.void, orElse: unexpectedStatus })) + ), + "setLibraryEntryHidden": (id, options) => HttpClientRequest.put(`/api/v1/library/hidden/${id}`).pipe( + HttpClientRequest.bodyJsonUnsafe(options.payload), + withResponse(options.config)(HttpClientResponse.matchStatus({ + "2xx": decodeSuccess(SetLibraryEntryHidden200), + "400": decodeError("SetLibraryEntryHidden400", SetLibraryEntryHidden400), + "401": decodeError("SetLibraryEntryHidden401", SetLibraryEntryHidden401), + "500": decodeError("SetLibraryEntryHidden500", SetLibraryEntryHidden500), + orElse: unexpectedStatus + })) ), "reconcileProviderEntries": (provider, options) => HttpClientRequest.put(`/api/v1/library/provider/${provider}`).pipe( HttpClientRequest.setUrlParams({ "store": options.params?.["store"] as any }), @@ -1545,6 +1569,10 @@ readonly "getHostInfo": (options: { readonly con * fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the * entries a given external provider owns; `?platform=` to one platform (case-insensitive — * installed-store titles are `PC`, custom/provider entries carry whatever was authored). +* +* **The operator's own lane additionally sees the titles they have HIDDEN**, each carrying +* `hidden: true`; every other lane gets them filtered out upstream and cannot tell they exist. The +* console needs them to offer "un-hide", and it is the only surface that does. */ readonly "getLibrary": (options: { readonly params?: typeof GetLibraryParams.Encoded | undefined; readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibrary401", typeof GetLibrary401.Type>> /** @@ -1571,6 +1599,19 @@ readonly "updateCustomGame": (id: string, option */ readonly "deleteCustomGame": (id: string, options: { readonly config?: Config | undefined } | undefined) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"DeleteCustomGame401", typeof DeleteCustomGame401.Type> | PunktfunkError<"DeleteCustomGame404", typeof DeleteCustomGame404.Type> | PunktfunkError<"DeleteCustomGame500", typeof DeleteCustomGame500.Type>> /** +* Curation, not access control: a hidden title disappears from every play surface — the console +* grid on a client, native clients, the GameStream app list, and launch resolution — while nothing +* is deleted and un-hiding restores it immediately. The operator's own console still lists it +* (flagged `hidden`) so it can be brought back. +* +* Keyed by the entry's stable `:` id, which survives re-scans and reconciles by +* construction (D2). The id is **not** validated against the current library on purpose: a title +* can be legitimately absent at this moment (launcher closed, plugin mid-sync, drive unmounted), +* and refusing the operator's choice in that window would be worse than storing an id that +* currently matches nothing. Emits `library.changed` (source = the store) only on a real change. +*/ +readonly "setLibraryEntryHidden": (id: string, options: { readonly payload: typeof SetLibraryEntryHiddenRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"SetLibraryEntryHidden400", typeof SetLibraryEntryHidden400.Type> | PunktfunkError<"SetLibraryEntryHidden401", typeof SetLibraryEntryHidden401.Type> | PunktfunkError<"SetLibraryEntryHidden500", typeof SetLibraryEntryHidden500.Type>> + /** * Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the * provider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each * surviving title's host id stable across reconciles, drops orphans, and never touches manual diff --git a/web/src/components/launcher-icon.tsx b/web/src/components/launcher-icon.tsx new file mode 100644 index 00000000..db5f72a0 --- /dev/null +++ b/web/src/components/launcher-icon.tsx @@ -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 = { + 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 ( + + {label && {label}} + + + ); +}; + +export default LauncherIcon; diff --git a/web/src/sections/Library/GameCard.tsx b/web/src/sections/Library/GameCard.tsx index fdb5d723..362c7609 100644 --- a/web/src/sections/Library/GameCard.tsx +++ b/web/src/sections/Library/GameCard.tsx @@ -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 = ({ (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 ? : null; return ( @@ -78,6 +83,16 @@ export const GameCard: FC = ({ 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. + ) : (