diff --git a/README-es.md b/README-es.md index a05a853..16f1978 100644 --- a/README-es.md +++ b/README-es.md @@ -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ú. diff --git a/README.md b/README.md index d36345b..6d5d3b5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/desktop/NxNativeEasy/GameCatalog.swift b/desktop/NxNativeEasy/GameCatalog.swift new file mode 100644 index 0000000..01faebd --- /dev/null +++ b/desktop/NxNativeEasy/GameCatalog.swift @@ -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)} + } +} diff --git a/desktop/NxNativeEasy/Localization.swift b/desktop/NxNativeEasy/Localization.swift index 48ba211..9a1f3a8 100644 --- a/desktop/NxNativeEasy/Localization.swift +++ b/desktop/NxNativeEasy/Localization.swift @@ -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", diff --git a/desktop/NxNativeEasy/main.swift b/desktop/NxNativeEasy/main.swift index 698ab51..55a35fc 100644 --- a/desktop/NxNativeEasy/main.swift +++ b/desktop/NxNativeEasy/main.swift @@ -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()} diff --git a/desktop/NxNativeEasy/tests/catalog_check.swift b/desktop/NxNativeEasy/tests/catalog_check.swift new file mode 100644 index 0000000..c1d31d3 --- /dev/null +++ b/desktop/NxNativeEasy/tests/catalog_check.swift @@ -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") + } +} diff --git a/docs/images/nxnative-catalog-es.png b/docs/images/nxnative-catalog-es.png new file mode 100644 index 0000000..a90418e Binary files /dev/null and b/docs/images/nxnative-catalog-es.png differ diff --git a/docs/images/nxnative-easy-es.png b/docs/images/nxnative-easy-es.png index cb96c09..90a4dd2 100644 Binary files a/docs/images/nxnative-easy-es.png and b/docs/images/nxnative-easy-es.png differ diff --git a/docs/images/nxnative-easy.png b/docs/images/nxnative-easy.png index e461247..a9fdc24 100644 Binary files a/docs/images/nxnative-easy.png and b/docs/images/nxnative-easy.png differ diff --git a/docs/personal-ios.md b/docs/personal-ios.md index e642421..bd3a67a 100644 --- a/docs/personal-ios.md +++ b/docs/personal-ios.md @@ -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//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. diff --git a/scripts/build_desktop_app.py b/scripts/build_desktop_app.py index 5acd91e..0832d05 100755 --- a/scripts/build_desktop_app.py +++ b/scripts/build_desktop_app.py @@ -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', diff --git a/scripts/tests/test_desktop_catalog.py b/scripts/tests/test_desktop_catalog.py new file mode 100644 index 0000000..8870464 --- /dev/null +++ b/scripts/tests/test_desktop_catalog.py @@ -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)