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.
150 lines
6.4 KiB
Swift
150 lines
6.4 KiB
Swift
// Reusable library widgets, shared by the touch grid (LibraryView's `GameCard`) and the gamepad
|
||
// coverflow (LibraryCoverflowView's cover cell).
|
||
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#elseif canImport(AppKit)
|
||
import AppKit
|
||
#endif
|
||
|
||
/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster —
|
||
/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell.
|
||
struct StoreBadge: View {
|
||
/// Which store surfaced the entry, already resolved to a display name (`GameEntry.storeLabel`).
|
||
let label: String
|
||
/// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size
|
||
/// without reading the title.
|
||
var isLauncher: Bool = false
|
||
/// Fill the chip with a flat wash instead of a frosted material.
|
||
///
|
||
/// The coverflow MUST pass true. Its cards ride a `.scrollTransition` that composites them
|
||
/// with `opacity < 1` and a 3D rotation, and a material cannot sample a backdrop through an
|
||
/// offscreen composite — so the frost stayed blank on every card and only appeared on the one
|
||
/// card sitting at exactly full opacity in the centre, reading as a flash on focus. A flat
|
||
/// wash has no backdrop to sample: it is simply always there. (Deliberately black, not
|
||
/// palette ink: the chip sits on cover art, whose colours the palette has no business
|
||
/// fighting.)
|
||
var solid: Bool = false
|
||
|
||
private var fill: AnyShapeStyle {
|
||
if isLauncher { return AnyShapeStyle(Color.brand) }
|
||
return solid ? AnyShapeStyle(Color.black.opacity(0.58)) : AnyShapeStyle(.ultraThinMaterial)
|
||
}
|
||
|
||
var body: some View {
|
||
Text(label)
|
||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||
.foregroundStyle(isLauncher || solid ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 3)
|
||
.background(fill, in: Capsule())
|
||
.padding(6)
|
||
}
|
||
}
|
||
|
||
#if canImport(UIKit)
|
||
private typealias PlatformImage = UIImage
|
||
#elseif canImport(AppKit)
|
||
private typealias PlatformImage = NSImage
|
||
#endif
|
||
|
||
private extension Image {
|
||
init(platformImage: PlatformImage) {
|
||
#if canImport(UIKit)
|
||
self.init(uiImage: platformImage)
|
||
#elseif canImport(AppKit)
|
||
self.init(nsImage: platformImage)
|
||
#endif
|
||
}
|
||
}
|
||
|
||
/// Sequentially tries cover-art URLs over `loader` (so a paired client can reach the host's own
|
||
/// art proxy, not just public CDNs — see `LibraryArtLoader`), advancing past any that fail to
|
||
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
|
||
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
|
||
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
|
||
/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private` —
|
||
/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art.
|
||
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.
|
||
var onLoaded: (() -> Void)?
|
||
@State private var index = 0
|
||
@State private var image: PlatformImage?
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let image {
|
||
Image(platformImage: image)
|
||
.resizable()
|
||
.scaledToFill()
|
||
.transition(.opacity)
|
||
} else if index < candidates.count {
|
||
ZStack { placeholder; ProgressView() }
|
||
.transition(.opacity)
|
||
} else {
|
||
placeholder
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
// Art crosses over its placeholder instead of replacing it between two frames. Cover
|
||
// fetches land one by one, so without this a freshly opened library is a run of cards
|
||
// visibly snapping from grey to artwork after the strip has already settled.
|
||
.animation(.easeOut(duration: 0.3), value: image != nil)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.clipped()
|
||
.task(id: index) { await loadCurrent() }
|
||
}
|
||
|
||
private func loadCurrent() async {
|
||
// Past the end: the placeholder IS the final look, so this poster has settled.
|
||
guard index < candidates.count else {
|
||
onLoaded?()
|
||
return
|
||
}
|
||
guard let loader, let data = try? await loader.data(for: candidates[index]),
|
||
let loaded = PlatformImage(data: data)
|
||
else {
|
||
index += 1 // advance to the next candidate (or past the end → placeholder)
|
||
return
|
||
}
|
||
image = loaded
|
||
onLoaded?()
|
||
}
|
||
|
||
private var placeholder: some View {
|
||
ZStack {
|
||
Rectangle().fill(.quaternary)
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
}
|