diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index f54de80f..34fd3a9b 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -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), ) } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt index 6088df8a..3dfbeeb2 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt @@ -37,9 +37,51 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String? val posterCandidates: List get() = listOfNotNull(portrait, header, hero) } -/** One title in the unified library. [id] is store-qualified (`steam:` / `custom:`). */ -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:` / `custom:`). + * + * [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.launchersFirst(): List { + 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. */ diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 0c7248fd..e4d3593f 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 8945e2d1..03a2e918 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 77548a4d..79d2219d 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -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) } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift index 6a7f459a..b7024989 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift @@ -38,12 +38,46 @@ public struct LaunchSpec: Codable, Hashable, Sendable { /// One title in the unified library. `id` is store-qualified: `steam:` / `custom:`. 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"). diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 6d23c499..a75bf9de 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -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. diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index ce558a22..2ab415d9 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -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) => { diff --git a/clients/linux/src/ui_library.rs b/clients/linux/src/ui_library.rs index 62c278e5..be1d45d7 100644 --- a/clients/linux/src/ui_library.rs +++ b/clients/linux/src/ui_library.rs @@ -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>, @@ -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) { /// immediately; the rest keep their monogram placeholder until `load_art` delivers. fn render(state: &Rc, 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, 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, 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, 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); diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index a2a7bdfc..7bd78a70 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -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(), ); diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index af2ba382..6f43b049 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -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)); diff --git a/clients/windows/src/app/library.rs b/clients/windows/src/app/library.rs index 773eaf86..42561c44 100644 --- a/clients/windows/src/app/library.rs +++ b/clients/windows/src/app/library.rs @@ -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, set_library: &AsyncSetState 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 = 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, + )); + } } } diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index cd6abe37..0e7f6cf1 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -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) { + 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)); diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index c7d13722..558abd7b 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -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 { 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 = (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), diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index 2f196e79..da58255f 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -312,6 +312,7 @@ fn dump_console_screens() { id: format!("steam:{i}"), title: (*t).to_string(), store: "steam".into(), + launcher: false, }) .collect(), ); diff --git a/crates/punktfunk-host/src/library/art.rs b/crates/punktfunk-host/src/library/art.rs index 0db6660e..f5324497 100644 --- a/crates/punktfunk-host/src/library/art.rs +++ b/crates/punktfunk-host/src/library/art.rs @@ -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] diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index 56c9a5b4..1d4fe59c 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -371,7 +371,7 @@ pub fn delete_custom(id: &str) -> Result> { /// 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). diff --git a/crates/punktfunk-host/src/library/launch.rs b/crates/punktfunk-host/src/library/launch.rs index 9e3e053f..b5bb5eac 100644 --- a/crates/punktfunk-host/src/library/launch.rs +++ b/crates/punktfunk-host/src/library/launch.rs @@ -200,6 +200,31 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option 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 { + 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::("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 (`:`) 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() { diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index 25030b8c..179e91a1 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -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" ), ));