feat(library): launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 5m40s

A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.

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

A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.

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

scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.

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

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

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

Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
This commit is contained in:
2026-08-10 23:26:47 +02:00
parent e283f17ab4
commit f62a48d4a9
81 changed files with 1585 additions and 86 deletions
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Derive the per-client launcher-tile brand marks from the assets/launcher-icons masters.
#
# The sibling of gen-os-icons.sh, and deliberately a separate script rather than a flag on it:
# the two registries answer different questions (which OS is this host / which launcher does
# this tile open), are keyed by different vocabularies, and bake to different sizes. What they
# share is the discipline — monochrome `fill="currentColor"` masters, original viewBoxes, one
# file per token, provenance in the README.
#
# Four clients need a baked derivative because they cannot consume the master directly:
#
# GTK shell symbolic SVG, black fill -> clients/linux/data/icons/scalable/actions/
# Windows shell PNG, h=128, mid-grey -> clients/windows/assets/launchers/
# Apple clients vector PDF, black fill -> clients/apple/.../LauncherIcons.xcassets/
#
# The web console, the Android client and the in-session console UI transcribe the master's
# path data inline instead — those are hand-kept, and this script prints them at the end so a
# new token can be pasted straight in.
#
# Idempotent. Usage: bash scripts/gen-launcher-icons.sh [token ...] (default: every master)
set -euo pipefail
cd "$(dirname "$0")/.."
MASTERS=assets/launcher-icons
GTK=clients/linux/data/icons/scalable/actions
WIN=clients/windows/assets/launchers
APPLE=clients/apple/Sources/PunktfunkKit/Resources/LauncherIcons.xcassets
# Same mid-grey as the OS marks, for the same reason: the Windows shell has no vector element
# and no theme-aware tint, so one colour has to stay legible on both the light and dark WinUI
# theme. Taller than the OS marks (32) because this one fills a poster tile, not a status row.
WIN_GREY='#8A8F98'
WIN_HEIGHT=128
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
command -v rsvg-convert >/dev/null 2>&1 || {
echo "rsvg-convert not found (brew install librsvg / apt install librsvg2-bin)" >&2
exit 1
}
tokens=("$@")
if [ ${#tokens[@]} -eq 0 ]; then
for f in "$MASTERS"/*.svg; do tokens+=("$(basename "$f" .svg)"); done
fi
mkdir -p "$GTK" "$WIN" "$APPLE"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
for t in "${tokens[@]}"; do
src="$MASTERS/$t.svg"
[ -f "$src" ] || { echo "no master for token '$t' ($src)" >&2; exit 1; }
log "$t"
# GTK: the master with the fill resolved to black — Adwaita recolors a `-symbolic` icon
# from the fill it finds, so the value only has to be a real colour, not the final one.
sed 's/currentColor/#000000/' "$src" > "$GTK/pf-launcher-$t-symbolic.svg"
# Windows: black-to-grey substitution, rasterized at a fixed height so every mark shares an
# optical size and keeps its own aspect ratio.
sed "s/currentColor/$WIN_GREY/" "$src" > "$tmp/$t.grey.svg"
rsvg-convert -h "$WIN_HEIGHT" -f png -o "$WIN/$t.png" "$tmp/$t.grey.svg"
# Apple: a vector PDF at the master's natural size, in a template imageset — SwiftUI tints
# it from foregroundStyle, so the baked colour is irrelevant.
sed 's/currentColor/#000000/' "$src" > "$tmp/$t.black.svg"
mkdir -p "$APPLE/launcher-$t.imageset"
rsvg-convert -f pdf -o "$APPLE/launcher-$t.imageset/$t.pdf" "$tmp/$t.black.svg"
cat > "$APPLE/launcher-$t.imageset/Contents.json" <<JSON
{
"images" : [
{ "filename" : "$t.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
JSON
done
echo
log "Inline registries (web console, Android, in-session console UI)"
# Generated outright rather than printed for pasting, unlike gen-os-icons.sh: three clients x
# seven paths of up to 3 kB is a transcription error waiting to happen, and a mangled character
# is a silently wrong logo rather than a build failure.
python3 scripts/gen_launcher_icon_tables.py
# The Rust registry goes through rustfmt: `cargo fmt --all --check` is a CI gate, and a
# GENERATED file that fails it would fail the build every time someone re-ran this script.
if command -v rustfmt >/dev/null 2>&1; then
rustfmt --edition 2021 crates/pf-console-ui/src/launcher_icons.rs
log " rustfmt'd crates/pf-console-ui/src/launcher_icons.rs"
else
log " rustfmt not found — run 'cargo fmt' before committing"
fi
echo
log "Remember: a NEW token also has to be added to each client's shipped-token list —"
log " clients/linux/src/ui_library.rs, clients/linux/data/resources.gresource.xml,"
log " clients/windows/src/app/launcher_icons.rs,"
log " clients/apple/.../PunktfunkKit/LauncherIcon.swift"
log " (the three inline registries above pick it up automatically)"
log " — and to the plugin that emits the tile."
+12
View File
@@ -88,6 +88,18 @@ VENDORED_TREES = [
("Bazzite logo (vendored, assets/os-icons)",
"assets/os-icons/LICENSES/bazzite.txt",
"https://github.com/ublue-os/bazzite"),
# Launcher brand marks for the library's launcher tiles (assets/launcher-icons/, CC BY 4.0 /
# CC0 / MIT — see assets/launcher-icons/README.md). A separate registry from the OS marks
# above, with its own sources, so it carries its own notices even where a vendor overlaps.
("Font Awesome Free brand icons (vendored, assets/launcher-icons)",
"assets/launcher-icons/LICENSES/font-awesome-brands.txt",
"https://fontawesome.com"),
("Simple Icons (vendored, assets/launcher-icons)",
"assets/launcher-icons/LICENSES/simple-icons.txt",
"https://simpleicons.org"),
("Playnite logo (vendored, assets/launcher-icons)",
"assets/launcher-icons/LICENSES/playnite.txt",
"https://github.com/JosefNemec/Playnite"),
]
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Emit the three INLINE launcher-icon registries from the assets/launcher-icons masters.
The OS-icon pipeline prints its path data for a human to paste into each client. That is fine
for a mark you add once a year; it is not fine here, where three clients each need seven paths
of up to 3 kB and a single mangled character is a silently wrong logo. So these three files are
generated outright, with their commentary baked in below:
web/src/components/launcher-icon.tsx web console, inline SVG
clients/android/.../components/LauncherIcons.kt Android, Compose ImageVector via PathParser
crates/pf-console-ui/src/launcher_icons.rs in-session console UI, Skia Path::from_svg
The baked derivatives (GTK / Windows / Apple) come from gen-launcher-icons.sh, which calls this.
Usage: python3 scripts/gen_launcher_icon_tables.py (from anywhere; paths are repo-relative)
"""
from __future__ import annotations
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
MASTERS = ROOT / "assets" / "launcher-icons"
# Registry order — the order a reader of any of the three files sees. Live tiles first, then the
# marks that ship dormant (see the masters' README), so "what actually renders today" reads off
# the top of the list.
TOKENS = ["steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox"]
BANNER = (
"GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.\n"
"Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.\n"
"Per-mark provenance and licensing: assets/launcher-icons/README.md."
)
def mark(token: str) -> tuple[str, str, float, float]:
"""(token, path data, viewport width, viewport height) for one master."""
svg = (MASTERS / f"{token}.svg").read_text()
box = re.search(r'viewBox="([^"]+)"', svg).group(1)
paths = re.findall(r'<path[^>]*\sd="([^"]+)"', svg)
if len(paths) != 1:
sys.exit(f"{token}: expected exactly one <path>, found {len(paths)}")
d = paths[0]
if any(c in d for c in "\n\t\"\\"):
sys.exit(f"{token}: path data must be single-line and free of quotes/backslashes")
_, _, w, h = box.split()
return token, d, float(w), float(h)
MARKS = [mark(t) for t in TOKENS]
def comment(prefix: str) -> str:
return "\n".join(f"{prefix} {line}".rstrip() for line in BANNER.splitlines())
def write(rel: str, body: str) -> None:
p = ROOT / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(body)
print(f" {rel} ({len(body):,} bytes)")
# --- web console -----------------------------------------------------------------------------
rows = "\n".join(
f'\t{t}: {{\n\t\tviewBox: "0 0 {w:g} {h:g}",\n\t\td: "{d}",\n\t}},'
for t, d, w, h in MARKS
)
write(
"web/src/components/launcher-icon.tsx",
f"""{comment("//")}
//
// The mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token. lucide
// deliberately ships no brand marks, so this is a curated registry — the same shape as
// os-icon.tsx, which does the equivalent job for the host cards' OS marks.
import type {{ FC }} from "react";
/** One monochrome brand mark: original per-icon viewBox, drawn in currentColor. */
const LAUNCHER_ICONS: Record<string, {{ viewBox: string; d: string }}> = {{
{rows}
}};
/**
* The mark for an entry's `icon` token, or null — render nothing — when the entry carries no
* token or names one this console ships no art for. Callers fall back to the title, which is
* what every launcher tile looked like before the token existed.
*
* The token is looked up in the shipped set, never interpolated into anything, so a host
* sending something unexpected can only ever produce "no icon".
*/
export const LauncherIcon: FC<{{
\ticon?: string | null;
\tclassName?: string;
\tlabel?: string;
}}> = ({{ icon, className, label }}) => {{
\tconst m = icon ? LAUNCHER_ICONS[icon] : undefined;
\tif (!m) return null;
\treturn (
\t\t<svg
\t\t\txmlns="http://www.w3.org/2000/svg"
\t\t\tviewBox={{m.viewBox}}
\t\t\tfill="currentColor"
\t\t\tclassName={{className}}
\t\t\trole={{label ? "img" : undefined}}
\t\t\taria-label={{label}}
\t\t\taria-hidden={{label ? undefined : true}}
\t\t>
\t\t\t{{label && <title>{{label}}</title>}}
\t\t\t<path d={{m.d}} />
\t\t</svg>
\t);
}};
export default LauncherIcon;
""",
)
# --- Android ---------------------------------------------------------------------------------
rows = "\n".join(
f' "{t}" to LauncherGlyph(\n'
f" viewportWidth = {w:g}f,\n"
f" viewportHeight = {h:g}f,\n"
f' d = "{d}",\n'
f" ),"
for t, d, w, h in MARKS
)
write(
"clients/android/app/src/main/kotlin/io/unom/punktfunk/components/LauncherIcons.kt",
f"""package io.unom.punktfunk.components
{comment("//")}
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.dp
import kotlin.math.max
/**
* The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
* Material ships no brand icons, so this is a curated registry — the sibling of [OsIcons],
* which does the equivalent job for the host cards.
*
* Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds
* the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT
* all square, so the builder letterboxes — a mark forced into a square box is a squashed mark.
*/
private class LauncherGlyph(
val viewportWidth: Float,
val viewportHeight: Float,
val d: String,
)
private val GLYPHS: Map<String, LauncherGlyph> = mapOf(
{rows}
)
private val CACHE = HashMap<String, ImageVector>()
/**
* The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark
* this build ships no art for — the caller then falls back to naming the launcher, which is
* what every launcher tile looked like before the token existed.
*
* Tinted by the caller via `tint`, so one mark serves every palette.
*/
fun launcherIcon(token: String?): ImageVector? {{
val glyph = GLYPHS[token ?: return null] ?: return null
return CACHE.getOrPut(token) {{
// Square the box and centre the mark in it, so a wide or tall master keeps its aspect
// ratio instead of being stretched to the tile.
val side = max(glyph.viewportWidth, glyph.viewportHeight)
val dx = (side - glyph.viewportWidth) / 2f
val dy = (side - glyph.viewportHeight) / 2f
ImageVector.Builder(
name = "launcher_$token",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = side,
viewportHeight = side,
).apply {{
addGroup(translationX = dx, translationY = dy)
addPath(
pathData = PathParser().parsePathString(glyph.d).toNodes(),
fill = SolidColor(Color.White),
)
clearGroup()
}}.build()
}}
}}
""",
)
# --- in-session console UI (Skia) --------------------------------------------------------------
rows = "\n".join(
f' ("{t}", {w:g}.0, {h:g}.0, "{d}"),' for t, d, w, h in MARKS
)
write(
"crates/pf-console-ui/src/launcher_icons.rs",
f"""{comment("//!")}
//!
//! The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
//! Skia parses SVG path data directly, so the masters need no transcription into a drawing
//! DSL — the path string is the asset.
use skia_safe::{{Matrix, Path, Rect}};
use std::collections::HashMap;
use std::sync::{{Mutex, OnceLock}};
/// A parsed mark and the viewport its coordinates are in.
type Glyph = (Path, f32, f32);
/// Token → parsed mark, with `None` memoizing "no such token / did not parse" so a miss is not
/// re-attempted every frame. Named because `clippy::type_complexity` rejects it inline, and this
/// file is generated — an inline type would fail the `-D warnings` gate on every regeneration.
type GlyphCache = HashMap<String, Option<Glyph>>;
/// `(token, viewport width, viewport height, path data)` — the masters, verbatim.
const GLYPHS: &[(&str, f32, f32, &str)] = &[
{rows}
];
/// The parsed path for a token plus the viewport it was authored in, or `None` when the token is
/// absent, unknown, or (defensively) unparseable — the tile then names its launcher instead,
/// which is exactly how every launcher tile looked before icons existed.
///
/// Parsed once per token and cached: `Path::from_svg` on a 3 kB string is not free, and the
/// library shelf re-renders every frame while the cursor springs.
fn glyph(token: &str) -> Option<Glyph> {{
static CACHE: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache.lock().ok()?;
if let Some(hit) = cache.get(token) {{
return hit.clone();
}}
let built = GLYPHS
.iter()
.find(|(t, ..)| *t == token)
.and_then(|(_, w, h, d)| Path::from_svg(d).map(|p| (p, *w, *h)));
cache.insert(token.to_string(), built.clone());
built
}}
/// The mark for `token`, scaled to fit `dst` and centred in it — aspect ratio preserved, because
/// the masters' viewports are not all square. `None` when there is no mark to draw.
pub(crate) fn launcher_mark(token: &str, dst: Rect) -> Option<Path> {{
let (path, vw, vh) = glyph(token)?;
let scale = (dst.width() / vw).min(dst.height() / vh);
let mut m = Matrix::new_identity();
m.set_scale((scale, scale), None);
m.post_translate((
dst.left + (dst.width() - vw * scale) / 2.0,
dst.top + (dst.height() - vh * scale) / 2.0,
));
Some(path.with_transform(&m))
}}
#[cfg(test)]
mod tests {{
use super::*;
/// Every shipped master parses. A mark that silently fails to parse is a tile that silently
/// loses its icon, which no other test in this crate would notice.
#[test]
fn every_glyph_parses() {{
for (token, ..) in GLYPHS {{
assert!(glyph(token).is_some(), "{{token}} failed to parse");
}}
}}
#[test]
fn unknown_token_draws_nothing() {{
assert!(launcher_mark("not-a-launcher", Rect::from_wh(64.0, 64.0)).is_none());
}}
/// The mark is letterboxed into the destination, never stretched past it — the guarantee the
/// non-square viewports (playnite is 1024x1024, steam 496x512) depend on.
#[test]
fn mark_is_contained_and_centred() {{
let dst = Rect::from_xywh(10.0, 20.0, 80.0, 40.0);
let b = launcher_mark("steam", dst).unwrap().compute_tight_bounds();
assert!(b.width() <= dst.width() + 0.5 && b.height() <= dst.height() + 0.5);
let (cx, cy) = (b.center_x(), b.center_y());
assert!((cx - dst.center_x()).abs() < 1.0, "off-centre horizontally: {{cx}}");
assert!((cy - dst.center_y()).abs() < 1.0, "off-centre vertically: {{cy}}");
}}
}}
""",
)