Launcher tiles reach the clients, and Playnite can publish again #70
@@ -248,7 +248,22 @@ private fun Coverflow(
|
||||
onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
|
||||
)
|
||||
|
||||
// Design D4: the launcher entries lead the strip (the client groups them at parse time).
|
||||
// A coverflow is one-dimensional, so instead of a second focus rail the heading names the
|
||||
// group the cursor is in and changes as it crosses the boundary. Only drawn when the
|
||||
// library actually has both groups — otherwise the screen is exactly what it was.
|
||||
val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher }
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
|
||||
if (bothGroups) {
|
||||
Text(
|
||||
if (current?.isLauncher == true) "LAUNCHERS" else "GAMES",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
letterSpacing = 2.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
pageSize = PageSize.Fixed(coverWidth),
|
||||
@@ -312,7 +327,8 @@ private fun Coverflow(
|
||||
)
|
||||
if (current != null) {
|
||||
Text(
|
||||
if (current.isCustom) "CUSTOM" else "STEAM",
|
||||
if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER"
|
||||
else current.storeLabel.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
letterSpacing = 2.sp,
|
||||
@@ -346,8 +362,10 @@ 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(
|
||||
game.title,
|
||||
if (game.isLauncher) game.storeLabel else game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
@@ -355,15 +373,18 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
// Store badge, top-start.
|
||||
// Store badge, top-start — brand-filled for a launcher entry (design D4).
|
||||
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
|
||||
Text(
|
||||
if (game.isCustom) "Custom" else "Steam",
|
||||
game.storeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.background(
|
||||
if (game.isLauncher) MaterialTheme.colorScheme.primary
|
||||
else Color.Black.copy(alpha = 0.5f),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,9 +37,51 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
|
||||
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
|
||||
}
|
||||
|
||||
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
|
||||
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
|
||||
/**
|
||||
* One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`).
|
||||
*
|
||||
* [role] is `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that
|
||||
* 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).
|
||||
*/
|
||||
data class GameEntry(
|
||||
val id: String,
|
||||
val store: String,
|
||||
val title: String,
|
||||
val art: Artwork,
|
||||
val role: String? = null,
|
||||
) {
|
||||
val isCustom: Boolean get() = store == "custom"
|
||||
|
||||
/** Whether this entry opens a launcher rather than a game. */
|
||||
val isLauncher: Boolean get() = role == "launcher"
|
||||
|
||||
/**
|
||||
* 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
|
||||
* entry, which a Lutris or GOG title made a lie.
|
||||
*/
|
||||
val storeLabel: String get() = when (store) {
|
||||
"steam" -> "Steam"
|
||||
"custom" -> "Custom"
|
||||
"heroic" -> "Heroic"
|
||||
"lutris" -> "Lutris"
|
||||
"epic" -> "Epic"
|
||||
"gog" -> "GOG"
|
||||
"xbox" -> "Xbox"
|
||||
else -> "Game"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Design D4: launcher entries lead the shelf, keeping the host's title order within each group.
|
||||
* Applied once where the library is fetched, so no screen has to remember the rule — and a library
|
||||
* without launcher entries comes back untouched.
|
||||
*/
|
||||
fun List<GameEntry>.launchersFirst(): List<GameEntry> {
|
||||
val launchers = filter { it.isLauncher }
|
||||
return if (launchers.isEmpty()) this else launchers + filterNot { it.isLauncher }
|
||||
}
|
||||
|
||||
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
|
||||
@@ -108,10 +150,11 @@ object LibraryClient {
|
||||
header = resolveArt(str(art, "header"), base),
|
||||
hero = resolveArt(str(art, "hero"), base),
|
||||
),
|
||||
role = str(o, "role"),
|
||||
),
|
||||
)
|
||||
}
|
||||
return out
|
||||
return out.launchersFirst()
|
||||
}
|
||||
|
||||
/** A present, non-null, non-blank JSON string field, else null. */
|
||||
|
||||
@@ -52,12 +52,15 @@ struct LibraryCoverflowView: View {
|
||||
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
|
||||
// safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and
|
||||
// clamped by width on a narrow screen.
|
||||
let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers
|
||||
let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0)
|
||||
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
|
||||
let coverWidth = coverHeight * 2 / 3
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 4)
|
||||
if showsGroupHeading {
|
||||
groupHeading.padding(.bottom, 6)
|
||||
}
|
||||
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
|
||||
detailPanel
|
||||
.padding(.top, 12)
|
||||
@@ -89,7 +92,9 @@ struct LibraryCoverflowView: View {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||
@@ -112,6 +117,23 @@ struct LibraryCoverflowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this library have both groups? Only then does the heading earn its row — a
|
||||
/// launcher-less library gets exactly the layout it had before design D4.
|
||||
private var showsGroupHeading: Bool {
|
||||
games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher }
|
||||
}
|
||||
|
||||
/// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus
|
||||
/// rail (a whole new up/down nav model for two or three tiles) the heading names the group and
|
||||
/// changes as the selection crosses the boundary — the launcher entries lead the strip.
|
||||
private var groupHeading: some View {
|
||||
let selected = games.first { $0.id == selection }
|
||||
return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
}
|
||||
|
||||
/// The centered title + store tag — empty (not hidden) so the layout doesn't jump.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let game = games.first { $0.id == selection }
|
||||
@@ -123,10 +145,13 @@ struct LibraryCoverflowView: View {
|
||||
.minimumScaleFactor(0.75)
|
||||
.multilineTextAlignment(.center)
|
||||
if let game {
|
||||
Text(game.isCustom ? "CUSTOM" : "STEAM")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
Text(
|
||||
game.isLauncher
|
||||
? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased()
|
||||
)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -139,7 +164,10 @@ struct LibraryCoverflowView: View {
|
||||
private var hints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = []
|
||||
if onLaunch != nil {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch"))
|
||||
// You *open* a launcher and *launch* a game — the hint follows the focused entry.
|
||||
let opens = games.first { $0.id == selection }?.isLauncher == true
|
||||
hints.append(
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
|
||||
}
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
|
||||
return hints
|
||||
|
||||
@@ -80,21 +80,47 @@ struct LibraryView: View {
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(games) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
// Design D4: launcher entries get their own section above the titles, never interleaved.
|
||||
// Both headers appear only when both groups exist, so a library without launcher entries
|
||||
// renders exactly as it did before.
|
||||
let launchers = games.filter(\.isLauncher)
|
||||
let titles = games.filter { !$0.isLauncher }
|
||||
let both = !launchers.isEmpty && !titles.isEmpty
|
||||
return ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !launchers.isEmpty {
|
||||
if both { sectionHeader("Launchers") }
|
||||
tiles(launchers)
|
||||
}
|
||||
if !titles.isEmpty {
|
||||
if both { sectionHeader("Games") }
|
||||
tiles(titles)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func tiles(_ entries: [GameEntry]) -> some View {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(entries) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
||||
.tracking(1.1)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var columns: [GridItem] {
|
||||
#if os(tvOS)
|
||||
let minW: CGFloat = 220
|
||||
@@ -152,12 +178,15 @@ struct LibraryView: View {
|
||||
return
|
||||
}
|
||||
do {
|
||||
// `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and
|
||||
// the gamepad coverflow both inherit the D4 ordering.
|
||||
games = try await LibraryClient.fetch(
|
||||
address: current.address,
|
||||
port: current.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM,
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256)
|
||||
hostFingerprint: current.pinnedSHA256
|
||||
).launchersFirst
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = try LibraryImageLoader.session(
|
||||
address: current.address,
|
||||
@@ -185,7 +214,9 @@ private struct GameCard: View {
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) }
|
||||
.overlay(alignment: .topLeading) {
|
||||
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
|
||||
}
|
||||
Text(game.title)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -12,14 +12,21 @@ import AppKit
|
||||
/// 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 {
|
||||
let isCustom: Bool
|
||||
/// 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
|
||||
|
||||
var body: some View {
|
||||
Text(isCustom ? "Custom" : "Steam")
|
||||
Text(label)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.background(
|
||||
isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial),
|
||||
in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,46 @@ public struct LaunchSpec: Codable, Hashable, Sendable {
|
||||
/// One title in the unified library. `id` is store-qualified: `steam:<appid>` / `custom:<id>`.
|
||||
public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: String
|
||||
public var store: String // "steam" | "custom"
|
||||
public var store: String // "steam" | "custom" | "lutris" | "heroic" | "epic" | "gog" | "xbox"
|
||||
public var title: String
|
||||
public var art: Artwork
|
||||
public var launch: LaunchSpec?
|
||||
/// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens
|
||||
/// the launcher itself (Steam Big Picture, Heroic) rather than a title. Deliberately a plain
|
||||
/// 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?
|
||||
|
||||
public var isCustom: Bool { store == "custom" }
|
||||
|
||||
/// Whether this entry opens a launcher rather than a game.
|
||||
public var isLauncher: Bool { role == "launcher" }
|
||||
|
||||
/// 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.
|
||||
public var storeLabel: String {
|
||||
switch store {
|
||||
case "steam": return "Steam"
|
||||
case "custom": return "Custom"
|
||||
case "heroic": return "Heroic"
|
||||
case "lutris": return "Lutris"
|
||||
case "epic": return "Epic"
|
||||
case "gog": return "GOG"
|
||||
case "xbox": return "Xbox"
|
||||
default: return "Game"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Array where Element == GameEntry {
|
||||
/// Design D4: launcher entries lead the shelf, and the host's title order survives within each
|
||||
/// group. Applied once where the library is fetched, so no individual view has to remember
|
||||
/// the rule — and a library without launcher entries comes back untouched.
|
||||
var launchersFirst: [GameEntry] {
|
||||
let launchers = filter(\.isLauncher)
|
||||
return launchers.isEmpty ? self : launchers + filter { !$0.isLauncher }
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
|
||||
@@ -61,6 +61,13 @@ const CSS: &str = "
|
||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
||||
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
||||
.pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); }
|
||||
/* Launcher entries (design D4) open the launcher itself. They rarely have poster art, so an
|
||||
art-less one must not read as a game whose cover failed to load: accent face, the launcher
|
||||
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); }
|
||||
.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); }
|
||||
";
|
||||
|
||||
/// Everything the shell shares below the component tree.
|
||||
|
||||
@@ -204,10 +204,18 @@ pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||
});
|
||||
match crate::library::fetch_games(&addr, port, &identity, pin) {
|
||||
Ok(games) => {
|
||||
// A fourth column, appended: `game` or `launcher` (design D4). Appended rather than
|
||||
// folded into an existing field so anything reading the first three columns is
|
||||
// untouched.
|
||||
for g in &games {
|
||||
println!("{}\t{}\t{}", g.id, g.store, g.title);
|
||||
let role = if g.is_launcher() { "launcher" } else { "game" };
|
||||
println!("{}\t{}\t{}\t{}", g.id, g.store, g.title, role);
|
||||
}
|
||||
let launchers = games.iter().filter(|g| g.is_launcher()).count();
|
||||
match launchers {
|
||||
0 => println!("{} game(s)", games.len()),
|
||||
n => println!("{} game(s), {} launcher(s)", games.len() - n, n),
|
||||
}
|
||||
println!("{} game(s)", games.len());
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -28,6 +28,12 @@ struct State {
|
||||
req: ConnectRequest,
|
||||
stack: gtk::Stack,
|
||||
flow: gtk::FlowBox,
|
||||
/// Launcher entries (design D4) get their own shelf above the games, so a handful of ways to
|
||||
/// open a launcher aren't buried in a 400-title grid. Hidden outright when there are none.
|
||||
launcher_flow: gtk::FlowBox,
|
||||
launchers_group: gtk::Box,
|
||||
/// The "Games" heading — only earns its space once a Launchers shelf is above it.
|
||||
games_heading: gtk::Label,
|
||||
error_page: adw::StatusPage,
|
||||
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
|
||||
art: RefCell<HashMap<String, gdk::Texture>>,
|
||||
@@ -94,11 +100,44 @@ fn build(
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
// The launcher shelf: same tile geometry as the games grid, its own FlowBox so the two
|
||||
// groups never interleave and each wraps on its own.
|
||||
let launcher_flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(6)
|
||||
.column_spacing(12)
|
||||
.row_spacing(18)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
launcher_flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
let launchers_heading = gtk::Label::new(Some("Launchers"));
|
||||
launchers_heading.add_css_class("pf-group-heading");
|
||||
launchers_heading.set_halign(gtk::Align::Start);
|
||||
launchers_heading.set_margin_bottom(8);
|
||||
let launchers_group = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
launchers_group.append(&launchers_heading);
|
||||
launchers_group.append(&launcher_flow);
|
||||
launchers_group.set_margin_bottom(24);
|
||||
launchers_group.set_visible(false);
|
||||
|
||||
let games_heading = gtk::Label::new(Some("Games"));
|
||||
games_heading.add_css_class("pf-group-heading");
|
||||
games_heading.set_halign(gtk::Align::Start);
|
||||
games_heading.set_margin_bottom(8);
|
||||
games_heading.set_visible(false);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
content.set_margin_top(24);
|
||||
content.set_margin_bottom(24);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&launchers_group);
|
||||
content.append(&games_heading);
|
||||
content.append(&flow);
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(1100)
|
||||
@@ -166,6 +205,9 @@ fn build(
|
||||
req,
|
||||
stack,
|
||||
flow,
|
||||
launcher_flow,
|
||||
launchers_group,
|
||||
games_heading,
|
||||
error_page,
|
||||
art: RefCell::new(HashMap::new()),
|
||||
pics: RefCell::new(HashMap::new()),
|
||||
@@ -224,18 +266,41 @@ fn load(state: &Rc<State>) {
|
||||
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
|
||||
fn render(state: &Rc<State>, games: &[GameEntry]) {
|
||||
state.flow.remove_all();
|
||||
state.launcher_flow.remove_all();
|
||||
state.pics.borrow_mut().clear();
|
||||
for game in games {
|
||||
// Design D4: launchers never interleave with titles. The host already sorts by title, and
|
||||
// `partition` is stable, so each group keeps that order.
|
||||
let (launchers, titles): (Vec<&GameEntry>, Vec<&GameEntry>) =
|
||||
games.iter().partition(|g| g.is_launcher());
|
||||
for game in &launchers {
|
||||
state.launcher_flow.append(&game_card(state, game));
|
||||
}
|
||||
for game in &titles {
|
||||
state.flow.append(&game_card(state, game));
|
||||
}
|
||||
// A library with no launcher entries looks exactly as it did before this existed.
|
||||
state.launchers_group.set_visible(!launchers.is_empty());
|
||||
state
|
||||
.games_heading
|
||||
.set_visible(!launchers.is_empty() && !titles.is_empty());
|
||||
}
|
||||
|
||||
/// 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<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let monogram = gtk::Label::new(Some(&initials(&game.title)));
|
||||
monogram.add_css_class("pf-poster-monogram");
|
||||
// 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".
|
||||
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);
|
||||
@@ -252,6 +317,9 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let badge = gtk::Label::new(Some(store_label(&game.store)));
|
||||
badge.add_css_class("pf-pill");
|
||||
badge.add_css_class("pf-store-badge");
|
||||
if launcher {
|
||||
badge.add_css_class("pf-launcher");
|
||||
}
|
||||
badge.set_halign(gtk::Align::Start);
|
||||
badge.set_valign(gtk::Align::Start);
|
||||
badge.set_margin_start(6);
|
||||
@@ -262,6 +330,9 @@ fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
poster.add_overlay(&pic);
|
||||
poster.add_overlay(&badge);
|
||||
poster.add_css_class("pf-poster");
|
||||
if launcher {
|
||||
poster.add_css_class("pf-launcher");
|
||||
}
|
||||
poster.set_overflow(gtk::Overflow::Hidden);
|
||||
poster.set_size_request(150, 225);
|
||||
poster.set_halign(gtk::Align::Center);
|
||||
|
||||
@@ -803,6 +803,7 @@ fn spawn_fetch(
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -843,6 +844,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -560,7 +560,7 @@ fn wake_and_connect(
|
||||
None => {}
|
||||
}
|
||||
ticks += 1;
|
||||
if ticks % 5 == 0 {
|
||||
if ticks.is_multiple_of(5) {
|
||||
rescan.request();
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
|
||||
@@ -39,6 +39,10 @@ pub(crate) struct Game {
|
||||
pub(crate) id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) store: String,
|
||||
/// This entry opens the launcher itself (Steam Big Picture, Heroic) rather than a title —
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
@@ -135,6 +139,7 @@ pub(crate) fn start_fetch(ctx: &Arc<AppCtx>, set_library: &AsyncSetState<Library
|
||||
id: g.id.clone(),
|
||||
title: g.title.clone(),
|
||||
store: g.store.clone(),
|
||||
launcher: g.is_launcher(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
@@ -215,6 +220,17 @@ fn initials(title: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A small group label above a tile grid ("Launchers" / "Games"). Only drawn when the page shows
|
||||
/// both groups — a single unlabelled grid is what every launcher-less library looked like before.
|
||||
fn group_heading(text: &str) -> Element {
|
||||
text_block(text)
|
||||
.font_size(12.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.margin(edges(2.0, 8.0, 2.0, 2.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// One poster tile: the artwork (or a monogram placeholder while it loads) with the store
|
||||
/// badge overlaid top-left, the title below, tap-to-launch across the whole tile.
|
||||
fn poster_tile(
|
||||
@@ -228,13 +244,20 @@ fn poster_tile(
|
||||
.stretch(Stretch::UniformToFill)
|
||||
.height(poster_h)
|
||||
.into(),
|
||||
// A launcher rarely has poster art, and an art-less launcher drawn like an art-less game
|
||||
// reads as "a game whose cover failed to load". So it names its launcher instead of
|
||||
// showing a title monogram, and the frame below picks up the accent stroke.
|
||||
None => border(
|
||||
text_block(initials(&game.title))
|
||||
.font_size(28.0)
|
||||
.semibold()
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.horizontal_alignment(HorizontalAlignment::Center)
|
||||
.vertical_alignment(VerticalAlignment::Center),
|
||||
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)
|
||||
@@ -242,14 +265,27 @@ fn poster_tile(
|
||||
};
|
||||
let framed = border(grid(vec![
|
||||
poster,
|
||||
pill(store_label(&game.store), Pill::Neutral)
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(6.0))
|
||||
.into(),
|
||||
// `Pill::Info` rather than a solid accent fill — `style.rs` is explicit that
|
||||
// white-on-bright is unreadable here.
|
||||
pill(
|
||||
store_label(&game.store),
|
||||
if game.launcher {
|
||||
Pill::Info
|
||||
} else {
|
||||
Pill::Neutral
|
||||
},
|
||||
)
|
||||
.horizontal_alignment(HorizontalAlignment::Left)
|
||||
.vertical_alignment(VerticalAlignment::Top)
|
||||
.margin(uniform(6.0))
|
||||
.into(),
|
||||
]))
|
||||
.corner_radius(8.0)
|
||||
.border_brush(ThemeRef::CardStroke)
|
||||
.border_brush(if game.launcher {
|
||||
ThemeRef::Accent
|
||||
} else {
|
||||
ThemeRef::CardStroke
|
||||
})
|
||||
.border_thickness(uniform(1.0));
|
||||
|
||||
border(
|
||||
@@ -332,22 +368,43 @@ pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element {
|
||||
.into(),
|
||||
),
|
||||
LibraryPhase::Ready(games) => {
|
||||
let tiles: Vec<Element> = games
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
|
||||
let (target, id) = (target.clone(), g.id.clone());
|
||||
poster_tile(
|
||||
g,
|
||||
props.state.art.get(&g.id).map(String::as_str),
|
||||
poster_h,
|
||||
Box::new(move || {
|
||||
initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
body.push(tile_grid(tiles, cols, POSTER_GAP));
|
||||
let tile = |g: &Game| -> Element {
|
||||
let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone());
|
||||
let (target, id) = (target.clone(), g.id.clone());
|
||||
poster_tile(
|
||||
g,
|
||||
props.state.art.get(&g.id).map(String::as_str),
|
||||
poster_h,
|
||||
Box::new(move || initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)),
|
||||
)
|
||||
};
|
||||
// Design D4: launcher entries get their own shelf above the titles, never
|
||||
// interleaved. `partition` is stable, so the host's title order survives in each
|
||||
// group. Headings appear only when both groups exist, so a library without launcher
|
||||
// entries renders exactly as it did before.
|
||||
let (launchers, titles): (Vec<&Game>, Vec<&Game>) =
|
||||
games.iter().partition(|g| g.launcher);
|
||||
let both = !launchers.is_empty() && !titles.is_empty();
|
||||
if !launchers.is_empty() {
|
||||
if both {
|
||||
body.push(group_heading("Launchers"));
|
||||
}
|
||||
body.push(tile_grid(
|
||||
launchers.iter().map(|g| tile(g)).collect(),
|
||||
cols,
|
||||
POSTER_GAP,
|
||||
));
|
||||
}
|
||||
if !titles.is_empty() {
|
||||
if both {
|
||||
body.push(group_heading("Games"));
|
||||
}
|
||||
body.push(tile_grid(
|
||||
titles.iter().map(|g| tile(g)).collect(),
|
||||
cols,
|
||||
POSTER_GAP,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -419,6 +419,11 @@ pub struct LibraryGame {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub store: String,
|
||||
/// This entry opens the launcher itself (Steam Big Picture, Heroic, Lutris) rather than a
|
||||
/// title — design D4. The host's `role` field, already reduced to a boolean by
|
||||
/// [`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,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
@@ -454,7 +459,15 @@ impl LibraryShared {
|
||||
}
|
||||
|
||||
/// Loaded games → the carousel (empty = the empty scene).
|
||||
///
|
||||
/// **Launcher entries are moved to the front, keeping the host's title order within each
|
||||
/// group.** Grouping here rather than in the renderer means the carousel's cursor arithmetic,
|
||||
/// the art pump and every future consumer of this model all inherit the invariant for free —
|
||||
/// a launcher tile is never buried in the middle of a 400-title shelf.
|
||||
pub fn set_games(&self, games: Vec<LibraryGame>) {
|
||||
let mut games = games;
|
||||
// `sort_by_key` is stable, so this is a partition that preserves the incoming order.
|
||||
games.sort_by_key(|g| !g.launcher);
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.phase = if games.is_empty() {
|
||||
LibraryPhase::Empty
|
||||
@@ -521,6 +534,52 @@ mod tests {
|
||||
assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary);
|
||||
}
|
||||
|
||||
/// Design D4: launcher entries lead the shelf, and the host's title order survives within
|
||||
/// each group. The renderer's `launcher_count()` reads the launcher group as the prefix
|
||||
/// `0..n`, so an interleaved list would silently mislabel the group heading.
|
||||
#[test]
|
||||
fn set_games_groups_launchers_first_and_keeps_title_order() {
|
||||
let g = |title: &str, launcher: bool| LibraryGame {
|
||||
id: format!("steam:{title}"),
|
||||
title: title.to_string(),
|
||||
store: "steam".into(),
|
||||
launcher,
|
||||
};
|
||||
let shared = LibraryShared::default();
|
||||
shared.set_games(vec![
|
||||
g("Celeste", false),
|
||||
g("Big Picture", true),
|
||||
g("Portal 2", false),
|
||||
g("Heroic", true),
|
||||
]);
|
||||
let (phase, games, _) = shared.snapshot();
|
||||
assert!(matches!(phase, LibraryPhase::Ready));
|
||||
let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Big Picture", "Heroic", "Celeste", "Portal 2"]);
|
||||
assert_eq!(games.iter().take_while(|g| g.launcher).count(), 2);
|
||||
}
|
||||
|
||||
/// A library with no launcher entries is untouched — the whole point of the grouping being
|
||||
/// invisible until a plugin actually publishes a launcher tile.
|
||||
#[test]
|
||||
fn set_games_leaves_a_launcher_less_library_alone() {
|
||||
let shared = LibraryShared::default();
|
||||
shared.set_games(
|
||||
["Celeste", "Portal 2", "Tunic"]
|
||||
.iter()
|
||||
.map(|t| LibraryGame {
|
||||
id: format!("steam:{t}"),
|
||||
title: (*t).to_string(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let (_, games, _) = shared.snapshot();
|
||||
let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Celeste", "Portal 2", "Tunic"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_clamps_onto_the_ends() {
|
||||
assert_eq!(step_cursor(1, 5, -JUMP, true), StepResult::Moved(0));
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::library::{
|
||||
};
|
||||
use crate::model::{ConsoleCmd, HostRow};
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
||||
use crate::theme::{white, Fonts, DIM, W, WHITE};
|
||||
use crate::theme::{brand, white, Fonts, DIM, W, WHITE};
|
||||
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44};
|
||||
use std::collections::HashMap;
|
||||
@@ -168,10 +168,31 @@ impl LibraryScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many launcher entries lead the shelf — [`LibraryShared::set_games`] groups them at the
|
||||
/// front, so the launcher group is always the prefix `0..launcher_count()`.
|
||||
fn launcher_count(&self) -> usize {
|
||||
self.games.iter().take_while(|g| g.launcher).count()
|
||||
}
|
||||
|
||||
/// Is the focused entry a launcher? (Drives the confirm hint: you *open* Steam, you *play* a
|
||||
/// game.)
|
||||
fn focused_is_launcher(&self) -> bool {
|
||||
self.games
|
||||
.get(self.cursor as usize)
|
||||
.is_some_and(|g| g.launcher)
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
match &self.phase {
|
||||
LibraryPhase::Ready => vec![
|
||||
Hint::new(HintKey::Confirm, "Play"),
|
||||
Hint::new(
|
||||
HintKey::Confirm,
|
||||
if self.focused_is_launcher() {
|
||||
"Open"
|
||||
} else {
|
||||
"Play"
|
||||
},
|
||||
),
|
||||
Hint::new(HintKey::Shoulders, "Jump"),
|
||||
Hint::new(HintKey::Back, "Back"),
|
||||
],
|
||||
@@ -277,6 +298,30 @@ impl LibraryScreen {
|
||||
let pos = self.anim.pos;
|
||||
let bump = self.bump.pos * k;
|
||||
|
||||
// Group heading. The model groups launcher entries at the front (design D4), and a
|
||||
// coverflow is one-dimensional — so instead of a second focus rail (a new up/down nav
|
||||
// model, in three renderers, for two or three tiles) the heading names the group the
|
||||
// cursor is in and changes as it crosses the boundary. Drawn only when the shelf
|
||||
// actually has both groups, so a library without launchers looks exactly as before.
|
||||
let launchers = self.launcher_count();
|
||||
if launchers > 0 && launchers < self.games.len() {
|
||||
let heading = if (self.cursor as usize) < launchers {
|
||||
"LAUNCHERS"
|
||||
} else {
|
||||
"GAMES"
|
||||
};
|
||||
fonts.centered(
|
||||
canvas,
|
||||
heading,
|
||||
W::SemiBold,
|
||||
12.0 * k,
|
||||
white(0.5),
|
||||
f64::from(rect.left) + w / 2.0,
|
||||
cy - card_h / 2.0 - 22.0 * k,
|
||||
w * 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint order = draw order: farthest from the (integer) cursor first, so the
|
||||
// dense side stacks overlap toward the focus.
|
||||
let mut order: Vec<usize> = (0..self.games.len()).collect();
|
||||
@@ -326,21 +371,31 @@ impl LibraryScreen {
|
||||
}
|
||||
None => {
|
||||
// Solid face, not glass: the side cards OVERLAP.
|
||||
canvas.draw_rect(
|
||||
crect,
|
||||
&Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None),
|
||||
);
|
||||
let mono = initials(&game.title);
|
||||
let font = fonts.font(W::Bold, 38.0 * k);
|
||||
let tw = font.measure_str(&mono, None).0;
|
||||
//
|
||||
// A launcher tile usually has no poster, and an art-less launcher drawn like
|
||||
// an art-less game reads as "a game whose cover failed to load". So it gets
|
||||
// the brand-tinted face and names its launcher, instead of a title monogram.
|
||||
let face = if game.launcher {
|
||||
Color4f::new(0.153, 0.137, 0.267, 1.0)
|
||||
} else {
|
||||
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, white(0.85))
|
||||
} else {
|
||||
(initials(&game.title), 38.0 * k, white(0.45))
|
||||
};
|
||||
let font = fonts.font(W::Bold, size);
|
||||
let tw = font.measure_str(&glyph, None).0;
|
||||
canvas.draw_str(
|
||||
&mono,
|
||||
&glyph,
|
||||
Point::new(
|
||||
(card_w as f32 - tw) / 2.0,
|
||||
card_h as f32 / 2.0 + 13.0 * k as f32,
|
||||
),
|
||||
&font,
|
||||
&Paint::new(white(0.45), None),
|
||||
&Paint::new(ink, None),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -351,13 +406,20 @@ impl LibraryScreen {
|
||||
let tw = fonts.measure(label, W::SemiBold, size) as f64;
|
||||
let (px, py) = (8.0 * k, 8.0 * k);
|
||||
let (bw, bh) = (tw + 16.0 * k, 20.0 * k);
|
||||
// Brand-filled for a launcher, smoked glass for a game — the one cue that
|
||||
// survives being three cards deep in the recede.
|
||||
let pill = if game.launcher {
|
||||
brand(0.85)
|
||||
} else {
|
||||
Color4f::new(0.0, 0.0, 0.0, 0.55)
|
||||
};
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(
|
||||
Rect::from_xywh(px as f32, py as f32, bw as f32, bh as f32),
|
||||
(bh / 2.0) as f32,
|
||||
(bh / 2.0) as f32,
|
||||
),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
|
||||
&Paint::new(pill, None),
|
||||
);
|
||||
fonts.draw(
|
||||
canvas,
|
||||
@@ -395,9 +457,14 @@ impl LibraryScreen {
|
||||
f64::from(rect.bottom) - 64.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
let sub = if g.launcher {
|
||||
format!("{} · LAUNCHER", store_label(&g.store).to_uppercase())
|
||||
} else {
|
||||
store_label(&g.store).to_uppercase()
|
||||
};
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&store_label(&g.store).to_uppercase(),
|
||||
&sub,
|
||||
W::Regular,
|
||||
12.0 * k,
|
||||
white(0.5),
|
||||
|
||||
@@ -312,6 +312,7 @@ fn dump_console_screens() {
|
||||
id: format!("steam:{i}"),
|
||||
title: (*t).to_string(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
@@ -671,9 +671,10 @@ mod tests {
|
||||
} else {
|
||||
"/home/u/.cache/lutris/coverart/cover.jpg".to_string()
|
||||
};
|
||||
let url = file_url(std::path::Path::new(&path));
|
||||
let mut art = Artwork {
|
||||
portrait: Some(path.clone()),
|
||||
hero: Some(format!("file://{path}")),
|
||||
hero: Some(url),
|
||||
logo: Some("https://cdn/l.png".into()),
|
||||
header: None,
|
||||
};
|
||||
@@ -757,7 +758,7 @@ mod tests {
|
||||
// half that matters for the extracted scanners: they emit `file://` values, so if the
|
||||
// conversion happened after the confinement check the check would be inspecting a string
|
||||
// that is not the path being read.
|
||||
let as_url = format!("file://{}", cover.to_str().unwrap());
|
||||
let as_url = file_url(&cover);
|
||||
assert_eq!(
|
||||
local_art_bytes(&as_url)
|
||||
.expect("file:// reads the same cover")
|
||||
@@ -766,15 +767,15 @@ mod tests {
|
||||
);
|
||||
// …and a `file://` value is confined exactly like a bare one — no bypass by spelling.
|
||||
assert!(
|
||||
local_art_bytes(&format!("file://{}", elsewhere.to_str().unwrap())).is_none(),
|
||||
local_art_bytes(&file_url(&elsewhere)).is_none(),
|
||||
"file:// must not escape the art roots"
|
||||
);
|
||||
// Percent-encoded traversal is decoded BEFORE canonicalization, so it cannot hide from the
|
||||
// `..` check.
|
||||
assert!(
|
||||
local_art_bytes(&format!(
|
||||
"file://{}/%2e%2e/{}/cover.png",
|
||||
dir.to_str().unwrap(),
|
||||
"{}/%2e%2e/{}/cover.png",
|
||||
file_url(&dir),
|
||||
outside.file_name().unwrap().to_str().unwrap()
|
||||
))
|
||||
.is_none(),
|
||||
@@ -789,6 +790,21 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
|
||||
/// Build a `file://` value the way the kit's `fileUrl` does, so these tests exercise the real
|
||||
/// plugin contract on both platforms. A POSIX path keeps the two-slash form
|
||||
/// (`file:///home/u/c.png` — empty authority, then the leading `/`); a Windows path becomes
|
||||
/// `file:///C:/covers/c.png`, i.e. three slashes and forward separators. Building it as
|
||||
/// `format!("file://{path}")` on Windows yields `file://C:\covers\c.png`, whose authority is
|
||||
/// `C:` — that is a UNC reference, not a local file, and the parser is right to refuse it.
|
||||
fn file_url(p: &std::path::Path) -> String {
|
||||
let posix = p.to_str().unwrap().replace('\\', "/");
|
||||
if posix.starts_with('/') {
|
||||
format!("file://{posix}")
|
||||
} else {
|
||||
format!("file:///{posix}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Write-time validation refuses what read-time would refuse, so an unservable path never even
|
||||
/// reaches `library.json`. URLs are none of its business.
|
||||
#[test]
|
||||
|
||||
@@ -371,7 +371,7 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
|
||||
/// copies of the very primitive the `/hooks` carve-out exists to withhold.
|
||||
///
|
||||
/// Returns the field name for the error message, so a plugin author sees exactly what was refused.
|
||||
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`,
|
||||
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`, `playnite`,
|
||||
/// `lutris_id`, `heroic`) are all
|
||||
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
|
||||
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
|
||||
@@ -461,6 +461,13 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
launch.value
|
||||
));
|
||||
}
|
||||
// The value is interpolated into a `playnite://` URI, so it is charset-checked here as
|
||||
// well as at launch time — same reasoning as the two kinds above.
|
||||
if launch.kind == "playnite" && !valid_playnite_id(&launch.value) {
|
||||
return Err(format!(
|
||||
"entries[{i}]: `launch.value` for kind `playnite` must be a Playnite game GUID"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(marker) = &e.detect.env_marker {
|
||||
if !valid_env_key(&marker.key) {
|
||||
@@ -743,6 +750,47 @@ mod tests {
|
||||
assert!(library_id_for(&r3[0]).starts_with("custom:"));
|
||||
}
|
||||
|
||||
/// A plugin's `launchers(cfg)` tile, end to end: `role: "launcher"` survives the reconcile onto
|
||||
/// the stored entry AND onto the `GameEntry` a client renders, keeps the deterministic claimed
|
||||
/// id, and stays out of the wire for ordinary games.
|
||||
///
|
||||
/// This is the path the lutris and heroic plugins publish through, and nothing exercised it
|
||||
/// before — every earlier test reconciled `GameRole::Game`, which is the serde default, so the
|
||||
/// field could have been dropped anywhere between the payload and the client without a failure.
|
||||
#[test]
|
||||
fn a_launcher_entry_survives_reconcile_onto_the_wire() {
|
||||
let mut launcher = input("launcher", "Lutris");
|
||||
launcher.role = GameRole::Launcher;
|
||||
launcher.launch = Some(LaunchSpec {
|
||||
kind: "launcher_ui".into(),
|
||||
value: "lutris".into(),
|
||||
});
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let out = reconcile_entries(
|
||||
&mut entries,
|
||||
"lutris",
|
||||
Some("lutris"),
|
||||
vec![launcher, input("42", "Some Game")],
|
||||
);
|
||||
|
||||
assert_eq!(library_id_for(&out[0]), "lutris:launcher");
|
||||
assert_eq!(out[0].role, GameRole::Launcher);
|
||||
assert_eq!(out[1].role, GameRole::Game, "the game is untouched");
|
||||
|
||||
// Onto the wire: the client sees `role`, and `is_game` keeps it off ordinary entries.
|
||||
let tile: GameEntry = out[0].clone().into();
|
||||
assert_eq!(tile.role, GameRole::Launcher);
|
||||
let v = serde_json::to_value(&tile).unwrap();
|
||||
assert_eq!(v["role"], "launcher");
|
||||
let game: GameEntry = out[1].clone().into();
|
||||
let vg = serde_json::to_value(&game).unwrap();
|
||||
assert!(
|
||||
vg.get("role").is_none(),
|
||||
"a game's role stays off the wire, so old clients are unaffected"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -200,6 +200,31 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::Pa
|
||||
)
|
||||
})
|
||||
}
|
||||
// Playnite: open the game through Playnite's own URI handler, which is what actually knows
|
||||
// how to start it (Playnite maps the id to whichever store owns the title). explorer.exe
|
||||
// resolves the registered protocol as the user — the same pattern as the `epic` kind — and
|
||||
// the id is GUID-validated, so the only variable part of the line is 36 hex-and-dash chars.
|
||||
//
|
||||
// This kind exists because the plugin used to publish `kind: "command"` (a `start ""` shell
|
||||
// line). The 2026-08-05 review made `command` operator-only, which refuses a plugin's whole
|
||||
// reconcile — so without a typed kind the Playnite plugin cannot publish anything at all.
|
||||
"playnite" => valid_playnite_id(&spec.value).then(|| {
|
||||
(
|
||||
format!("explorer.exe \"playnite://playnite/start/{}\"", spec.value),
|
||||
None,
|
||||
)
|
||||
}),
|
||||
// A launcher entry (D4) on Windows: today that is Playnite's Fullscreen app, spawned
|
||||
// directly (its `playnite://` handler opens the DESKTOP app, so no URI can do this). The
|
||||
// value is the literal "playnite" — nothing from the entry reaches the command line — and
|
||||
// the working directory is Playnite's own install dir, as a .NET app expects.
|
||||
"launcher_ui" => match spec.value.as_str() {
|
||||
"playnite" => playnite_fullscreen_exe().map(|exe| {
|
||||
let dir = exe.parent().map(std::path::Path::to_path_buf);
|
||||
(format!("\"{}\"", exe.display()), dir)
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
// Operator-typed custom command (host-owned, never client-set): run it through the shell in the
|
||||
// interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator
|
||||
// input — the same trust as the operator typing it — not a client-influenced string.
|
||||
@@ -260,6 +285,21 @@ pub(crate) fn valid_steam_ui(value: &str) -> bool {
|
||||
matches!(value, "bigpicture" | "desktop")
|
||||
}
|
||||
|
||||
/// A Playnite game id: the GUID Playnite's own database uses, and the only client-influenced part
|
||||
/// of a `playnite` launch. Interpolated into a URI handed to explorer.exe, so the charset is
|
||||
/// validated first — 8-4-4-4-12 lowercase-or-uppercase hex with dashes, nothing else.
|
||||
pub(crate) fn valid_playnite_id(value: &str) -> bool {
|
||||
let groups = [8usize, 4, 4, 4, 12];
|
||||
let mut parts = value.split('-');
|
||||
for want in groups {
|
||||
match parts.next() {
|
||||
Some(p) if p.len() == want && p.bytes().all(|b| b.is_ascii_hexdigit()) => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
parts.next().is_none()
|
||||
}
|
||||
|
||||
/// The launcher UIs **this host** can open, as `launcher_ui` values (D4).
|
||||
///
|
||||
/// One kind for every launcher but Steam, rather than one kind each: they all have exactly a single
|
||||
@@ -280,17 +320,71 @@ fn launcher_ui_stores() -> &'static [&'static str] {
|
||||
{
|
||||
&["heroic", "lutris"]
|
||||
}
|
||||
// Windows launchers (Epic, GOG Galaxy, the Xbox app) are not wired yet — each needs its own
|
||||
// verified activation, and an unverified guess would ship a tile that does nothing.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
// Playnite's activation is verified (2026-08-06, on the .173 box); Epic, GOG Galaxy and the
|
||||
// Xbox app are still unwired — each needs its own verified activation, and an unverified guess
|
||||
// would ship a tile that does nothing.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
&["playnite"]
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
{
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this a `launcher_ui` value this host can resolve?
|
||||
///
|
||||
/// On Windows, Playnite is validated by *resolution* rather than by being on the list: a host
|
||||
/// without Playnite installed refuses the entry (a 400 the plugin author can act on) instead of
|
||||
/// publishing a tile that does nothing when a user clicks it.
|
||||
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
|
||||
launcher_ui_stores().contains(&value)
|
||||
if !launcher_ui_stores().contains(&value) {
|
||||
return false;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if value == "playnite" {
|
||||
return playnite_fullscreen_exe().is_some();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Windows: Playnite's **Fullscreen** app, if this host can find it.
|
||||
///
|
||||
/// Fullscreen rather than Desktop for two reasons: a launcher tile is opened from a couch over a
|
||||
/// stream, and — verified on 2026-08-06 — the registered `playnite://` protocol handler points at
|
||||
/// `Playnite.DesktopApp.exe`, so a URI cannot open fullscreen mode at all. The exe is launched
|
||||
/// directly, which is also why nothing here is interpolated from the entry: the whole value is the
|
||||
/// literal `"playnite"`.
|
||||
///
|
||||
/// Playnite installs per-user by default, so the install directory comes from its own uninstall
|
||||
/// entry (HKCU first, then HKLM for a machine-wide install), falling back to the default
|
||||
/// `%LOCALAPPDATA%\Playnite`. `None` when nothing resolves, which is what refuses the tile.
|
||||
#[cfg(windows)]
|
||||
fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
use winreg::RegKey;
|
||||
const KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Playnite";
|
||||
const EXE: &str = "Playnite.FullscreenApp.exe";
|
||||
|
||||
let from_registry = [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE]
|
||||
.into_iter()
|
||||
.find_map(|root| {
|
||||
RegKey::predef(root)
|
||||
.open_subkey(KEY)
|
||||
.ok()?
|
||||
.get_value::<String, _>("InstallLocation")
|
||||
.ok()
|
||||
})
|
||||
.map(std::path::PathBuf::from);
|
||||
|
||||
from_registry
|
||||
.into_iter()
|
||||
.chain(
|
||||
std::env::var_os("LOCALAPPDATA").map(|l| std::path::PathBuf::from(l).join("Playnite")),
|
||||
)
|
||||
.map(|dir| dir.join(EXE))
|
||||
.find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
|
||||
@@ -566,9 +660,23 @@ mod tests {
|
||||
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// No Windows/macOS launcher UIs are wired yet, so every value is refused.
|
||||
// Playnite is accepted only when this host can actually FIND its Fullscreen app:
|
||||
// validation is resolution, so a box without Playnite refuses the entry rather than
|
||||
// publishing a tile that does nothing when clicked.
|
||||
assert_eq!(
|
||||
valid_launcher_ui("playnite"),
|
||||
playnite_fullscreen_exe().is_some()
|
||||
);
|
||||
// The Linux launchers, and the Windows ones whose activation is still unverified
|
||||
// (Epic, GOG Galaxy, the Xbox app), stay refused.
|
||||
assert!(!valid_launcher_ui("heroic"));
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
{
|
||||
// No launcher UIs are wired on this OS, so every value is refused.
|
||||
assert!(!valid_launcher_ui("heroic"));
|
||||
assert!(!valid_launcher_ui("gog"));
|
||||
}
|
||||
@@ -576,6 +684,34 @@ mod tests {
|
||||
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
|
||||
}
|
||||
|
||||
/// Windows' launcher tile opens Playnite's FULLSCREEN app. Both negatives are the point: the
|
||||
/// desktop app is not what a couch tile should open, and the `playnite://` handler cannot be
|
||||
/// used because it is registered to the desktop app (verified on .173, 2026-08-06).
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn playnite_launcher_opens_the_fullscreen_app() {
|
||||
let ui = |v: &str| {
|
||||
windows_launch_for(&LaunchSpec {
|
||||
kind: "launcher_ui".into(),
|
||||
value: v.into(),
|
||||
})
|
||||
};
|
||||
// A launcher this host cannot open is refused, whatever the OS.
|
||||
assert!(ui("gog").is_none());
|
||||
assert!(ui("heroic").is_none());
|
||||
assert!(ui("").is_none());
|
||||
|
||||
// The rest only means anything on a box that actually has Playnite.
|
||||
let Some(exe) = playnite_fullscreen_exe() else {
|
||||
return;
|
||||
};
|
||||
let (cmd, dir) = ui("playnite").expect("resolvable when the exe was found");
|
||||
assert!(cmd.contains("Playnite.FullscreenApp.exe"), "{cmd}");
|
||||
assert!(!cmd.contains("DesktopApp"), "{cmd}");
|
||||
assert!(!cmd.contains("playnite://"), "{cmd}");
|
||||
assert_eq!(dir.as_deref(), exe.parent());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn launcher_ui_opens_the_launcher_itself() {
|
||||
|
||||
@@ -31,7 +31,8 @@ fn check_entry_fields(
|
||||
&format!(
|
||||
"`{field}` is executed as the host user and may only be set with the \
|
||||
operator's admin token — a plugin may publish entries with any host-resolved \
|
||||
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic) \
|
||||
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic, \
|
||||
playnite) \
|
||||
instead"
|
||||
),
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user