A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.
Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).
A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.
assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.
scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.
All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.
Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.
api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).
Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
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.