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.
Why a token and not the icon itself
The obvious alternative — a plugin ships its own icon.svg and the art proxy serves it — is closed by construction, and deliberately: local_art_bytes serves what the bytes are (sniff_image_type), 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 would trade a rendering nicety for a stored-XSS surface.
Naming the mark instead keeps that refusal intact and buys three things a proxied image could not have given us anyway: the glyph stays vector at whatever size a tile 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: a third-party plugin cannot ship a mark no client bundles. Its tile falls back to the launcher's name — exactly what every launcher tile looked like before this — and the fix is a PR adding the master.
Validation
[a-z][a-z0-9-]{0,31}, shape-validated by the host on every lane. Not a privilege question — an icon token names no resource the host owns, so it is refused for being unrepresentable as a slug, not for being privileged. Every client interpolates the value into a resource name, an asset-catalog lookup or a file path, so the guard belongs upstream of all of them — and each client re-checks anyway rather than trusting the peer.
The marks
assets/launcher-icons/ — seven monochrome masters with per-mark provenance and licensing, folded into THIRD-PARTY-NOTICES by the existing generator:
token
source
steam
Font Awesome Free brands (CC BY 4.0)
lutris, heroic, epic, gog
Simple Icons (CC0 1.0)
xbox
Font Awesome Free brands (CC BY 4.0)
playnite
JosefNemec/Playnite's own logo (MIT)
steam is generated fromassets/os-icons/steam.svg, so the SteamOS host badge and the Steam launcher tile can never drift apart.
Epic, GOG and Xbox 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.
Tooling
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 × 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.
Rendering
All six renderers draw the mark contained, never cover-cropped: the masters' viewports are not square (steam 496×512, playnite 1024×1024) and filling a 2:3 frame would reproduce the very strip this exists to avoid. Every renderer keeps its old fallback for a token it has no art for, so an unknown mark degrades to the previous design rather than to a hole.
Purely additive in both directions: an old client ignores the field; a new client against an old host sees no token and renders exactly as today.
New tests worth naming: one asserts all seven masters actually parse under Skia (a mark that silently fails to parse is a tile that silently loses its icon, which nothing else would catch), one asserts the letterbox stays inside its box, and two cover the icon round-trip through reconcile onto the wire — including that dropping the token on a later reconcile clears it rather than stranding the old mark.
Not done / needs attention
The Windows client is not compile-verified. It cannot be built from a Mac (scripts/xcheck.sh covers only the capture stack by design — the full client drags ring/opus/ffmpeg through punktfunk-core), and CI does not build it either. Its tile change was hand-checked against the pinned windows-reactor source but needs a real box before it ships.
THIRD-PARTY-NOTICES is not regenerated here. Regenerating on macOS shrinks the root file by ~14,700 lines with both cargo about and the Python fallback, because cargo metadata resolves a different graph than Linux/Windows. That is pre-existing drift; the three new attribution entries are wired into scripts/gen-third-party-notices.py and will land the next time it is regenerated on Linux.
The plugin side is a separate change.steam (both tiles), lutris, heroic and playnite need their one-line icon: addition in their own repos before any of this is visible on a real host.
api/openapi.json's version field was stale at 0.25.0 and now reads 0.26.0 — an unrelated line that regeneration necessarily corrects.
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.
## Why a token and not the icon itself
The obvious alternative — a plugin ships its own `icon.svg` and the art proxy serves it — is closed by construction, and deliberately: `local_art_bytes` serves what the bytes **are** (`sniff_image_type`), 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 would trade a rendering nicety for a stored-XSS surface.
Naming the mark instead keeps that refusal intact and buys three things a proxied image could not have given us anyway: the glyph stays vector at whatever size a tile 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:** a third-party plugin cannot ship a mark no client bundles. Its tile falls back to the launcher's name — exactly what every launcher tile looked like before this — and the fix is a PR adding the master.
## Validation
`[a-z][a-z0-9-]{0,31}`, shape-validated by the host on **every** lane. Not a privilege question — an icon token names no resource the host owns, so it is refused for being unrepresentable as a slug, not for being privileged. Every client interpolates the value into a resource name, an asset-catalog lookup or a file path, so the guard belongs upstream of all of them — and each client re-checks anyway rather than trusting the peer.
## The marks
`assets/launcher-icons/` — seven monochrome masters with per-mark provenance and licensing, folded into `THIRD-PARTY-NOTICES` by the existing generator:
| token | source |
|---|---|
| `steam` | Font Awesome Free brands (CC BY 4.0) |
| `lutris`, `heroic`, `epic`, `gog` | Simple Icons (CC0 1.0) |
| `xbox` | Font Awesome Free brands (CC BY 4.0) |
| `playnite` | JosefNemec/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 apart.
Epic, GOG and Xbox 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.
## Tooling
`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 × 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.
## Rendering
All six renderers draw the mark **contained**, never cover-cropped: the masters' viewports are not square (steam 496×512, playnite 1024×1024) and filling a 2:3 frame would reproduce the very strip this exists to avoid. Every renderer keeps its old fallback for a token it has no art for, so an unknown mark degrades to the previous design rather than to a hole.
Purely additive in both directions: an old client ignores the field; a new client against an old host sees no token and renders exactly as today.
## Verification
| Gate | Result |
|---|---|
| `punktfunk-host` cargo check (Linux container) | ✅ |
| `punktfunk-host` unit tests | ✅ 13 passed |
| clippy `-D warnings` — pf-client-core, pf-console-ui, punktfunk-client-session, punktfunk-client-linux | ✅ |
| plain build (console-ui, client-linux) | ✅ |
| `pf-console-ui` tests | ✅ 77 passed |
| `pf-client-core` tests | ✅ 188 passed |
| `cargo fmt --all --check` | ✅ |
| Apple `swift build` | ✅ |
| Android `compileDebugKotlin` | ✅ |
| web `tsc --noEmit` + `vite build` | ✅ |
| `plugin-kit` `tsc --noEmit` | ✅ |
| biome (all touched files) | ✅ |
New tests worth naming: one asserts **all seven masters actually parse under Skia** (a mark that silently fails to parse is a tile that silently loses its icon, which nothing else would catch), one asserts the letterbox stays inside its box, and two cover the `icon` round-trip through reconcile onto the wire — including that dropping the token on a later reconcile **clears** it rather than stranding the old mark.
## Not done / needs attention
- **The Windows client is not compile-verified.** It cannot be built from a Mac (`scripts/xcheck.sh` covers only the capture stack by design — the full client drags ring/opus/ffmpeg through `punktfunk-core`), and CI does not build it either. Its tile change was hand-checked against the pinned windows-reactor source but needs a real box before it ships.
- **`THIRD-PARTY-NOTICES` is not regenerated here.** Regenerating on macOS shrinks the root file by ~14,700 lines with both `cargo about` and the Python fallback, because `cargo metadata` resolves a different graph than Linux/Windows. That is pre-existing drift; the three new attribution entries are wired into `scripts/gen-third-party-notices.py` and will land the next time it is regenerated on Linux.
- **The plugin side is a separate change.** `steam` (both tiles), `lutris`, `heroic` and `playnite` need their one-line `icon:` addition in their own repos before any of this is visible on a real host.
- `api/openapi.json`'s `version` field was stale at `0.25.0` and now reads `0.26.0` — an unrelated line that regeneration necessarily corrects.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.Why a token and not the icon itself
The obvious alternative — a plugin ships its own
icon.svgand the art proxy serves it — is closed by construction, and deliberately:local_art_bytesserves what the bytes are (sniff_image_type), 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 would trade a rendering nicety for a stored-XSS surface.Naming the mark instead keeps that refusal intact and buys three things a proxied image could not have given us anyway: the glyph stays vector at whatever size a tile 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: a third-party plugin cannot ship a mark no client bundles. Its tile falls back to the launcher's name — exactly what every launcher tile looked like before this — and the fix is a PR adding the master.
Validation
[a-z][a-z0-9-]{0,31}, shape-validated by the host on every lane. Not a privilege question — an icon token names no resource the host owns, so it is refused for being unrepresentable as a slug, not for being privileged. Every client interpolates the value into a resource name, an asset-catalog lookup or a file path, so the guard belongs upstream of all of them — and each client re-checks anyway rather than trusting the peer.The marks
assets/launcher-icons/— seven monochrome masters with per-mark provenance and licensing, folded intoTHIRD-PARTY-NOTICESby the existing generator:steamlutris,heroic,epic,gogxboxplaynitesteamis generated fromassets/os-icons/steam.svg, so the SteamOS host badge and the Steam launcher tile can never drift apart.Epic, GOG and Xbox ship dormant: those plugins' launcher switches are off by default and emit nothing, because the host has no verified
launcher_uiactivation 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.Tooling
scripts/gen-launcher-icons.shbakes the three derivatives that cannot consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF) and — unlikegen-os-icons.sh, which prints path data for a human to paste — generates the three inline registries (web console, AndroidImageVector,pf-console-uiSkia). Three clients × 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 throughrustfmt, sincecargo fmt --all --checkis a CI gate and a generated file that fails it would fail every regeneration.Rendering
All six renderers draw the mark contained, never cover-cropped: the masters' viewports are not square (steam 496×512, playnite 1024×1024) and filling a 2:3 frame would reproduce the very strip this exists to avoid. Every renderer keeps its old fallback for a token it has no art for, so an unknown mark degrades to the previous design rather than to a hole.
Purely additive in both directions: an old client ignores the field; a new client against an old host sees no token and renders exactly as today.
Verification
punktfunk-hostcargo check (Linux container)punktfunk-hostunit tests-D warnings— pf-client-core, pf-console-ui, punktfunk-client-session, punktfunk-client-linuxpf-console-uitestspf-client-coretestscargo fmt --all --checkswift buildcompileDebugKotlintsc --noEmit+vite buildplugin-kittsc --noEmitNew tests worth naming: one asserts all seven masters actually parse under Skia (a mark that silently fails to parse is a tile that silently loses its icon, which nothing else would catch), one asserts the letterbox stays inside its box, and two cover the
iconround-trip through reconcile onto the wire — including that dropping the token on a later reconcile clears it rather than stranding the old mark.Not done / needs attention
scripts/xcheck.shcovers only the capture stack by design — the full client drags ring/opus/ffmpeg throughpunktfunk-core), and CI does not build it either. Its tile change was hand-checked against the pinned windows-reactor source but needs a real box before it ships.THIRD-PARTY-NOTICESis not regenerated here. Regenerating on macOS shrinks the root file by ~14,700 lines with bothcargo aboutand the Python fallback, becausecargo metadataresolves a different graph than Linux/Windows. That is pre-existing drift; the three new attribution entries are wired intoscripts/gen-third-party-notices.pyand will land the next time it is regenerated on Linux.steam(both tiles),lutris,heroicandplayniteneed their one-lineicon:addition in their own repos before any of this is visible on a real host.api/openapi.json'sversionfield was stale at0.25.0and now reads0.26.0— an unrelated line that regeneration necessarily corrects.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.