Replace the fourth featured card with a searchable live game catalog

Read all checkout game profiles, search names, versions and title IDs without case or accent sensitivity, and link each result to its game notes. Localize the catalog in English and Spanish and verify discovery against the current profiles.
This commit is contained in:
Erick Sanchez committed 2026-10-26 11:28:53 -06:00
1 parent e7bb0e27a0
commit 6db52d357a
12 files changed
+221 -5

No files matched your search

+2
View File
@@ -49,6 +49,8 @@ Para actualizar, usa la misma cuenta y el mismo bundle ID efectivo de Sideloadly
## Juegos incluidos
En la app pulsa **Ver todos** para consultar los perfiles de esta copia. Busca por nombre, versión o Title ID; cada resultado enlaza a su ficha. El catálogo vuelve a leer los perfiles locales cada vez que lo abres.
**Las pruebas propias cubren compilación y empaquetado en Mac, no una verificación automática de juego en iPhone.** Los perfiles y reportes de juego proceden de upstream; revisa [la ficha de tu juego](dm/games/README.md#games) antes de prepararlo. Un perfil no garantiza que cualquier versión funcione en cualquier iPhone.
Esta base requiere iOS/iPadOS 26 o posterior, iPhone con A17 Pro o posterior o iPad con M1 o posterior. Los datos de los juegos y las llaves los aportas tú.
+2
View File
@@ -49,6 +49,8 @@ For updates, keep the same Apple account and effective bundle ID used in Sideloa
## Included game profiles
In the desktop app, click **View all** to browse every profile in your checkout. Search by name, version or Title ID; each result links to its game notes. The catalog reads the local profiles each time you open it.
**Our own checks cover macOS compilation and packaging, not automatic iPhone gameplay verification.** Profiles and gameplay reports come from upstream and may concern Mac or iPhone; read [your game's notes](dm/games/README.md#games). A profile does not guarantee that every game version works on every device.
This base requires iOS/iPadOS 26 or later, an iPhone with A17 Pro or later, or an iPad with M1 or later. Supply your own game files and keys.
+52
View File
@@ -0,0 +1,52 @@
import Foundation
struct CatalogGame: Identifiable, Equatable {
let id: String
let name: String
let versions: [String]
func matches(_ query: String) -> Bool {
let normalize: (String) -> String = {
$0.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier:"en_US_POSIX"))
}
let haystack=normalize(([name,id]+versions).joined(separator:" "))
return normalize(query).split(whereSeparator: {$0.isWhitespace}).allSatisfy {haystack.contains($0)}
}
var notesURL: URL {
URL(string:"https://github.com/nalancer08/nxnative-easy/blob/main/dm/games/\(id)/README.md")!
}
}
struct GameCatalog {
// Read the installed checkout, not a manually maintained compatibility list.
static func load(repo: URL) throws -> [CatalogGame] {
let directory=repo.appendingPathComponent("dm/games",isDirectory:true)
let children=try FileManager.default.contentsOfDirectory(at:directory,includingPropertiesForKeys:nil)
var games: [CatalogGame]=[]
for child in children {
let id=child.lastPathComponent
guard id.range(of:"^[0-9A-Fa-f]{16}$",options:.regularExpression) != nil else {continue}
let profile=child.appendingPathComponent("game.tsv")
guard FileManager.default.fileExists(atPath:profile.path) else {continue}
let source=try String(contentsOf:profile,encoding:.utf8)
var name: String?;var versions: [String]=[]
for line in source.components(separatedBy:.newlines) {
guard !line.trimmingCharacters(in:.whitespaces).hasPrefix("#") else {continue}
let fields=line.components(separatedBy:"\t")
if fields.count>1 && fields[0]=="name" {name=fields[1].trimmingCharacters(in:.whitespaces)}
if fields.count>2 && fields[0]=="build" {
let version=fields[2].trimmingCharacters(in:.whitespaces)
if !version.isEmpty && !versions.contains(version) {versions.append(version)}
}
}
games.append(CatalogGame(id:id,name:name?.isEmpty == false ? name! : id,versions:versions))
}
return games.sorted {
let comparison=$0.name.localizedStandardCompare($1.name)
return comparison == .orderedSame ? $0.id < $1.id : comparison == .orderedAscending
}
}
static func search(_ games: [CatalogGame], query: String) -> [CatalogGame] {
games.filter {$0.matches(query)}
}
}
+12
View File
@@ -12,6 +12,18 @@ enum EasyLanguage: String, CaseIterable {
struct EasyStrings {
static let english: [String:String] = [
"Ver todos": "View all",
"Todos los juegos": "All games",
"Cerrar": "Close",
"perfiles incluidos en esta copia": "profiles included in this checkout",
"Son perfiles del repositorio, no una prueba de funcionamiento en tu dispositivo. Consulta la ficha para ver plataformas, versiones y límites.": "These are repository profiles, not proof of gameplay on your device. Check each game note for platforms, versions and limitations.",
"Buscar por nombre, versión o Title ID": "Search by name, version or Title ID",
"Limpiar búsqueda": "Clear search",
"No pudimos leer los perfiles. Comprueba que el repositorio siga en su ubicación original.": "Could not read the profiles. Check that the source checkout is still at its original location.",
"No encontramos juegos con esa búsqueda.": "No games match your search.",
"Versiones del perfil": "Profile versions",
"Ficha del juego": "Game notes",
"Tu próximo juego empieza aquí.": "Your next game starts here.",
"Elige su carpeta. Nosotros preparamos la IPA ligera.": "Choose its folder. We will prepare the lightweight IPA.",
"Ningún juego seleccionado": "No game selected",
+86 -4
View File
@@ -26,14 +26,16 @@ struct GameCard: Identifiable {
@Published var failed = false
@Published var appOnly = false
@Published var showDetails = false
@Published var showCatalog = false
@Published var catalog: [CatalogGame] = []
@Published var catalogFailed = false
private var process: Process?
private var cancelled = false
let repo: URL
let cards: [GameCard] = [
.init(id:"dread",name:"Metroid Dread",symbol:"scope",tint:.orange),
.init(id:"wonder",name:"Super Mario Bros. Wonder",symbol:"sparkles",tint:.mint),
.init(id:"zelda",name:"Zelda: Breath of the Wild",symbol:"mountain.2",tint:.cyan),
.init(id:"odyssey",name:"Super Mario Odyssey",symbol:"globe.americas",tint:.red)
.init(id:"zelda",name:"Zelda: Breath of the Wild",symbol:"mountain.2",tint:.cyan)
]
init() {
let configured = Bundle.main.object(forInfoDictionaryKey:"NxNativeSource") as? String ?? ""
@@ -47,6 +49,11 @@ struct GameCard: Identifiable {
}
}
var gameName: String { folder?.lastPathComponent ?? text("Ningún juego seleccionado") }
func openCatalog() {
do {catalog=try GameCatalog.load(repo:repo);catalogFailed=false}
catch {catalog=[];catalogFailed=true}
showCatalog=true
}
func chooseFolder() {
let panel=NSOpenPanel();panel.canChooseDirectories=true;panel.canChooseFiles=false
panel.allowsMultipleSelection=false;panel.prompt=text("Elegir juego")
@@ -186,7 +193,7 @@ struct ContentView: View {
Text(model.text("Elige una carpeta. Prepara tu juego. A jugar.")).font(.system(size:17)).foregroundStyle(.gray)
}
HStack(spacing:12) {
ForEach(model.cards.prefix(4)) {card in
ForEach(model.cards) {card in
VStack(alignment:.leading,spacing:8) {
ZStack {
RoundedRectangle(cornerRadius:12).fill(card.tint.opacity(0.12))
@@ -195,7 +202,20 @@ struct ContentView: View {
Text(card.name).font(.system(size:11,weight:.medium)).foregroundStyle(ink).lineLimit(1)
}.frame(maxWidth:.infinity)
}
}.accessibilityLabel(model.text("Juegos con perfiles incluidos; consulta la ficha de cada juego para compatibilidad"))
Button {model.openCatalog()} label: {
VStack(alignment:.leading,spacing:8) {
ZStack {
RoundedRectangle(cornerRadius:12).fill(lime.opacity(0.12))
Image(systemName:"square.grid.2x2").font(.system(size:34,weight:.light)).foregroundStyle(lime)
}.frame(height:106)
HStack {
Text(model.text("Ver todos")).font(.system(size:11,weight:.semibold))
Spacer()
Image(systemName:"arrow.up.right").font(.system(size:10))
}.foregroundStyle(ink)
}.frame(maxWidth:.infinity)
}.buttonStyle(.plain).accessibilityIdentifier("view-all-games")
}
VStack(alignment:.leading,spacing:18) {
HStack(alignment:.center,spacing:14) {
Image(systemName:"folder.fill").font(.system(size:28)).foregroundStyle(lime)
@@ -260,10 +280,72 @@ struct ContentView: View {
}.padding(32)
}
}.frame(minWidth:820,minHeight:680).preferredColorScheme(.dark)
.sheet(isPresented:$model.showCatalog) {GameCatalogView(model:model)}
.onReceive(NotificationCenter.default.publisher(for:NSApplication.willTerminateNotification)) {_ in model.cancel()}
}
}
struct GameCatalogView: View {
@ObservedObject var model: BuildModel
@State private var query=""
@FocusState private var searchFocused: Bool
private let lime=Color(red:0.79,green:0.95,blue:0.37)
private var results: [CatalogGame] {GameCatalog.search(model.catalog,query:query)}
var body: some View {
VStack(alignment:.leading,spacing:18) {
HStack {
VStack(alignment:.leading,spacing:5) {
Text(model.text("Todos los juegos")).font(.system(size:26,weight:.bold,design:.rounded))
HStack(spacing:5) {
Text("\(results.count) / \(model.catalog.count)").monospacedDigit()
Text(model.text("perfiles incluidos en esta copia"))
}.font(.system(size:12)).foregroundStyle(.secondary)
}
Spacer()
Button(model.text("Cerrar")) {model.showCatalog=false}.keyboardShortcut(.cancelAction)
}
Text(model.text("Son perfiles del repositorio, no una prueba de funcionamiento en tu dispositivo. Consulta la ficha para ver plataformas, versiones y límites."))
.font(.system(size:12)).foregroundStyle(.secondary).fixedSize(horizontal:false,vertical:true)
HStack {
Image(systemName:"magnifyingglass").foregroundStyle(lime)
TextField(model.text("Buscar por nombre, versión o Title ID"),text:$query)
.textFieldStyle(.plain).focused($searchFocused).accessibilityIdentifier("game-search")
if !query.isEmpty {Button {query=""} label: {Image(systemName:"xmark.circle.fill")}.buttonStyle(.plain).accessibilityLabel(model.text("Limpiar búsqueda"))}
}.padding(12).background(Color.white.opacity(0.06),in:RoundedRectangle(cornerRadius:10))
if model.catalogFailed {
Text(model.text("No pudimos leer los perfiles. Comprueba que el repositorio siga en su ubicación original."))
.foregroundStyle(.orange).frame(maxWidth:.infinity,maxHeight:.infinity)
} else if results.isEmpty {
VStack(spacing:8) {
Image(systemName:"magnifyingglass").font(.system(size:32)).foregroundStyle(.secondary)
Text(model.text("No encontramos juegos con esa búsqueda."))
}.frame(maxWidth:.infinity,maxHeight:.infinity)
} else {
ScrollView {
LazyVStack(spacing:8) {
ForEach(results) {game in
HStack(spacing:12) {
Image(systemName:"gamecontroller").font(.system(size:20)).foregroundStyle(lime).frame(width:36)
VStack(alignment:.leading,spacing:5) {
Text(game.name).font(.system(size:14,weight:.semibold)).fixedSize(horizontal:false,vertical:true)
Text(game.id).font(.system(size:10,design:.monospaced)).foregroundStyle(.secondary).textSelection(.enabled)
if !game.versions.isEmpty {
Text(model.text("Versiones del perfil")+": "+game.versions.joined(separator:", "))
.font(.system(size:11)).foregroundStyle(.secondary)
}
}
Spacer()
Link(model.text("Ficha del juego"),destination:game.notesURL).font(.system(size:12))
}.padding(14).background(Color.white.opacity(0.04),in:RoundedRectangle(cornerRadius:12))
}
}
}
}
}.padding(26).frame(width:760,height:620).preferredColorScheme(.dark)
.onAppear {searchFocused=true}
}
}
@main struct NxNativeEasyApp: App {
var body: some Scene {
WindowGroup("nxnative Easy") {ContentView()}
@@ -0,0 +1,39 @@
import Foundation
@main struct CatalogCheck {
static func main() throws {
let base=FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer {try? FileManager.default.removeItem(at:base)}
let games=base.appendingPathComponent("dm/games")
try FileManager.default.createDirectory(at:games,withIntermediateDirectories:true)
func profile(_ id:String,_ text:String) throws {
let folder=games.appendingPathComponent(id)
try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true)
try text.write(to:folder.appendingPathComponent("game.tsv"),atomically:true,encoding:.utf8)
}
try profile("0100000000000001","# name\tignored\nname\tPokémon Example\nbuild\tv0\t1.0\n")
try profile("0100000000000002","name\tZelda Example\nbuild\tv0\t1.9.0\nbuild\tv1\t1.9.0\n")
try profile("not-a-title","name\tNot a game\n")
var entries=try GameCatalog.load(repo:base)
assert(entries.count==2)
assert(entries[0].name=="Pokémon Example")
assert(entries[1].versions==["1.9.0"])
assert(GameCatalog.search(entries,query:" POKEMON ").count==1)
assert(GameCatalog.search(entries,query:"zelda 1.9").count==1)
assert(GameCatalog.search(entries,query:"0100000000000002").first?.name=="Zelda Example")
assert(GameCatalog.search(entries,query:"no-match").isEmpty)
assert(GameCatalog.search(entries,query:" ").count==2)
assert(entries[0].notesURL.path.hasSuffix("0100000000000001/README.md"))
try profile("0100000000000003","name\tAnother game\nbuild\tv1\t2.0\n")
entries=try GameCatalog.load(repo:base)
assert(entries.count==3 && entries.first?.name=="Another game")
let repo=URL(fileURLWithPath:CommandLine.arguments[1])
let actual=try GameCatalog.load(repo:repo)
let children=try FileManager.default.contentsOfDirectory(at:repo.appendingPathComponent("dm/games"),includingPropertiesForKeys:nil)
let expected=children.filter {$0.lastPathComponent.range(of:"^[0-9A-Fa-f]{16}$",options:.regularExpression) != nil && FileManager.default.fileExists(atPath:$0.appendingPathComponent("game.tsv").path)}
assert(!actual.isEmpty && actual.count==expected.count)
assert(Set(actual.map {$0.id}).count==actual.count)
assert(actual.contains {$0.name=="Metroid Dread" && $0.versions.contains("2.1.0")})
print("Catalog PASS: \(actual.count) repository profiles, live discovery, sorting, versions, accent-insensitive search and no-results state")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 77 KiB

+11
View File
@@ -146,3 +146,14 @@ downloaded or stored in repository configuration. The CLI always prompts for a
folder when no explicit key option is supplied, even if prod.keys is beside a game.
The game app's key file is never packed into an IPA. Tests use disposable synthetic
bytes, not Nintendo key material.
## Game catalog
The fourth home card opens **View all / Ver todos**. The native catalog reads every
16-digit title directory containing `dm/games/<id>/game.tsv` from the current checkout
when opened. It shows the profile name, registered build display versions and title ID,
with a link to the corresponding repository game note. Search ignores case and accents
and matches name, version or title ID; whitespace-separated terms must all match.
No game artwork or network request is needed to populate/search the list. Profile
presence is not interpreted as verified iPhone gameplay. The current checkout has 70
profiles, including regional variants.
+1 -1
View File
@@ -18,7 +18,7 @@ def main():
if output.suffix!='.app':parser.error('Output must end with .app')
contents=output/'Contents';macos=contents/'MacOS';macos.mkdir(parents=True,exist_ok=True)
subprocess.run(['xcrun','swiftc','-parse-as-library','-swift-version','5',
str(REPO/'desktop/NxNativeEasy/main.swift'),str(REPO/'desktop/NxNativeEasy/Localization.swift'),'-o',str(macos/'NxNativeEasy'),
str(REPO/'desktop/NxNativeEasy/main.swift'),str(REPO/'desktop/NxNativeEasy/Localization.swift'),str(REPO/'desktop/NxNativeEasy/GameCatalog.swift'),'-o',str(macos/'NxNativeEasy'),
'-framework','SwiftUI','-framework','AppKit'],check=True)
info=dict(CFBundleIdentifier='com.nalancer08.nxnative.easy',CFBundleName='nxnative',
CFBundleDisplayName='nxnative Easy',CFBundleExecutable='NxNativeEasy',
+16
View File
@@ -0,0 +1,16 @@
"""Execute the live catalog parser/search model used by the desktop UI."""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT=Path(__file__).resolve().parents[2]
@unittest.skipUnless(sys.platform=='darwin','requires Swift Foundation')
class DesktopCatalog(unittest.TestCase):
def test_profile_discovery_and_search(self):
with tempfile.TemporaryDirectory() as tmp:
exe=Path(tmp)/'catalog'
subprocess.run(['xcrun','swiftc',str(ROOT/'desktop/NxNativeEasy/GameCatalog.swift'),
str(ROOT/'desktop/NxNativeEasy/tests/catalog_check.swift'),'-o',str(exe)],check=True)
subprocess.run([str(exe),str(ROOT)],check=True)