diff --git a/README-es.md b/README-es.md index 6bd0c74..fe377ac 100644 --- a/README-es.md +++ b/README-es.md @@ -42,7 +42,7 @@ Cuando abra la app: Partimos de [seedds/nxnative](https://github.com/seedds/nxnative): el motor, los perfiles y la compatibilidad son trabajo del proyecto original. **nxnative Easy** añade: -- **Preparación guiada:** destino → archivos → llaves → revisión → compilación con avance y registro. Arrastrar archivos no mueve tus originales. +- **Preparación guiada:** destino → identificar tu juego → revisión → compilación con avance y registro. Con tu `prod.keys`, muestra el nombre real, la versión base, los updates y la versión que se preparará antes de compilar. Tus originales se conservan. - **Biblioteca con estados claros:** app/IPA preparada, instalaciones verificadas y última comprobación de dispositivos desconectados. - **Avisos para recompilar, diagnósticos sin datos sensibles y notificaciones al terminar.** La cuenta Apple es global; los cupos de cuentas gratuitas son estimaciones identificadas, con aviso antes de registrar un dispositivo nuevo. - **Controles editables en iPhone:** posición, tamaño y opacidad, guardados por juego y layout. diff --git a/README.md b/README.md index 9f8b664..d90416a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ When the app opens: Built on [seedds/nxnative](https://github.com/seedds/nxnative). The original project provides the engine, game profiles and compatibility work. **nxnative Easy** adds: -- **Guided preparation:** platform → files → keys → review → live build progress, with drag/drop that leaves originals in place. +- **Guided preparation:** platform → identify your game → review → live build progress. Choose `prod.keys` to read the real game name, base version, updates and selected version before compiling. Your originals stay in place. - **A library that explains readiness:** local Mac/IPA outputs, verified device installations and clearly dated last-seen devices. - **Rebuild advice, redacted diagnostics and background completion notifications.** Apple Account is shared across the app; free-team device slots are clearly labelled estimates, with a registration warning before installing on a new device. - **Editable iPhone touch controls:** position, size and opacity, saved per game and 2D/3D layout. diff --git a/desktop/NxNativeEasy/Localization.swift b/desktop/NxNativeEasy/Localization.swift index ad01491..afe418a 100644 --- a/desktop/NxNativeEasy/Localization.swift +++ b/desktop/NxNativeEasy/Localization.swift @@ -12,6 +12,32 @@ enum EasyLanguage: String, CaseIterable { struct EasyStrings { static let english: [String:String] = [ + "Ver archivos detectados":"View detected files", + "Los archivos cambiaron. Vuelve a identificar la carpeta antes de continuar.":"Files changed. Identify the folder again before continuing.", + "No encontramos un juego base. Añade el archivo base, además del update.":"No base game found. Add the base package alongside the update.", + "Hay varios juegos en esta carpeta. Deja un solo juego con sus updates y DLC.":"Multiple games found. Keep one game with its updates and DLC in this folder.", + "Necesitamos preparar la herramienta de lectura de metadatos. El juego todavía no se compila.":"The metadata reader needs setup. This does not compile the game.", + "Algunos archivos no se pudieron identificar. Revisa tus llaves y que los archivos estén completos.":"Some files could not be identified. Check your keys and make sure the packages are complete.", + "No pudimos leer los metadatos. Revisa tus llaves y los archivos del juego.":"Could not read the metadata. Check your keys and game packages.", + "Leyendo el juego y sus actualizaciones…":"Reading the game and its updates…", + "Sólo leemos los metadatos internos. No extraemos romfs ni compilamos el juego.":"Reading internal metadata only. No romfs extraction or game compilation.", + "Identifica tu juego antes de compilar":"Identify your game before building", + "Necesitamos tu prod.keys para leer el nombre, la versión y los updates dentro de tus archivos.":"Your prod.keys is needed to read the name, version and updates inside your packages.", + "Elegir prod.keys e identificar":"Choose prod.keys and identify", + "Juego identificado":"Game identified", + "Versión a preparar":"Version to prepare", + "Versión interna":"Internal version", + "Juego base":"Base game", + "Actualizaciones detectadas":"Detected updates", + "Se usará esta":"Selected for preparation", + "Sin updates detectados: se preparará el juego base.":"No updates found: the base game will be prepared.", + "DLC detectados":"Detected DLC", + "Este juego no tiene un perfil probado en el catálogo. Prepararlo no garantiza que funcione.":"This game has no tested catalog profile. Preparing it does not guarantee it will run.", + "Volver a comprobar":"Check again", + "Preparar herramienta de lectura":"Set up metadata reader", + "Cambiar prod.keys":"Change prod.keys", + "No pudimos guardar la comprobación del juego.":"Could not save the game inspection.", + "Selecciona el archivo prod.keys de tu Mac.":"Select the prod.keys file on your Mac.", "Selecciona un archivo prod.keys legible y no vacío, fuera del repositorio.":"Select a readable, nonempty prod.keys file outside the repository.", "Esperando tus archivos":"Waiting for your files", diff --git a/desktop/NxNativeEasy/main.swift b/desktop/NxNativeEasy/main.swift index c08dc4a..36b1fe3 100644 --- a/desktop/NxNativeEasy/main.swift +++ b/desktop/NxNativeEasy/main.swift @@ -105,6 +105,95 @@ struct PreparationIllustration:View { } } +struct InspectedPackage:Decodable,Identifiable { + let file:String + let title_id:String + let kind:String + let version:UInt64 + let name:String? + let display_version:String? + var id:String {file+title_id+kind+String(version)} +} +struct PackageInspection:Decodable { + let schema:Int + let status:String + let title_id:String? + let name:String? + let display_version:String? + let version:UInt64? + let selected_file:String? + let base_version:String? + let updates:[InspectedPackage]? + let dlc:[InspectedPackage]? + var ready:Bool {schema==1 && status=="ready"} +} + +struct PackageIdentityView:View { + @State private var started=Date() + let inspection:PackageInspection? + let inspecting:Bool + let hasKeys:Bool + let catalog:[CatalogGame] + let text:(String)->String + let chooseKeys:()->Void + let retry:()->Void + let setup:()->Void + let cancel:()->Void + private var message:String { + switch inspection?.status { + case "files_changed":return "Los archivos cambiaron. Vuelve a identificar la carpeta antes de continuar." + case "base_missing":return "No encontramos un juego base. Añade el archivo base, además del update." + case "multiple_games":return "Hay varios juegos en esta carpeta. Deja un solo juego con sus updates y DLC." + case "tools_missing","tools_unavailable":return "Necesitamos preparar la herramienta de lectura de metadatos. El juego todavía no se compila." + case "incomplete":return "Algunos archivos no se pudieron identificar. Revisa tus llaves y que los archivos estén completos." + default:return "No pudimos leer los metadatos. Revisa tus llaves y los archivos del juego." + } + } + var body:some View { + VStack(alignment:.leading,spacing:12) { + if inspecting { + HStack {ProgressView().controlSize(.small);Text(text("Leyendo el juego y sus actualizaciones…")).font(.headline);Spacer();Button(text("Cancelar"),action:cancel)} + Text(text("Sólo leemos los metadatos internos. No extraemos romfs ni compilamos el juego.")).font(.caption).foregroundStyle(.secondary) + TimelineView(.periodic(from:.now,by:1)) {context in + Text(text("Tiempo transcurrido")+": "+String(max(0,Int(context.date.timeIntervalSince(started))))+" s").font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + } else if !hasKeys { + Label(text("Identifica tu juego antes de compilar"),systemImage:"magnifyingglass").font(.headline) + Text(text("Necesitamos tu prod.keys para leer el nombre, la versión y los updates dentro de tus archivos.")).font(.callout).foregroundStyle(.secondary) + Button(text("Elegir prod.keys e identificar"),action:chooseKeys) + } else if let result=inspection,result.ready { + Label(result.name ?? catalog.first(where:{$0.id.uppercased()==result.title_id})?.name ?? text("Juego identificado"),systemImage:"checkmark.seal.fill").font(.title2.bold()) + HStack(spacing:20) { + Label(text("Versión a preparar")+": "+(result.display_version ?? text("Versión interna")+" \(result.version ?? 0)"),systemImage:"tag") + Text(result.title_id ?? "").font(.caption.monospaced()).foregroundStyle(.secondary) + } + if let base=result.base_version,!base.isEmpty {Text(text("Juego base")+": "+base).font(.callout).foregroundStyle(.secondary)} + if let updates=result.updates,!updates.isEmpty { + Text(text("Actualizaciones detectadas")+": \(updates.count)").font(.headline) + ForEach(updates) {update in + HStack { + Image(systemName:update.file==result.selected_file ? "checkmark.circle.fill" : "arrow.down.circle") + Text(update.display_version ?? text("Versión interna")+" \(update.version)").fontWeight(.semibold) + Text(update.file).lineLimit(2).foregroundStyle(.secondary) + if update.file==result.selected_file {Text(text("Se usará esta")).font(.caption.bold())} + }.font(.callout) + } + } else {Label(text("Sin updates detectados: se preparará el juego base."),systemImage:"info.circle").font(.callout)} + if let dlc=result.dlc,!dlc.isEmpty {Label(text("DLC detectados")+": \(dlc.count)",systemImage:"square.stack.3d.up").font(.callout)} + if let id=result.title_id,!catalog.contains(where:{$0.id.uppercased()==id}) {Text(text("Este juego no tiene un perfil probado en el catálogo. Prepararlo no garantiza que funcione.")).font(.caption).foregroundStyle(.orange)} + Button(text("Volver a comprobar"),action:retry).font(.caption) + } else { + Label(text(message),systemImage:"exclamationmark.triangle").foregroundStyle(.orange) + HStack { + if ["tools_missing","tools_unavailable"].contains(inspection?.status ?? "") {Button(text("Preparar herramienta de lectura"),action:setup)} + else {Button(text("Volver a comprobar"),action:retry);Button(text("Cambiar prod.keys"),action:chooseKeys)} + } + } + }.padding(18).frame(maxWidth:.infinity,alignment:.leading).background(Color(red:0.79,green:0.95,blue:0.37).opacity(0.065),in:RoundedRectangle(cornerRadius:14)) + .onChange(of:inspecting) {_,active in if active {started=Date()}} + } +} + @MainActor final class BuildModel: ObservableObject { @Published var language: EasyLanguage = EasyLanguage.load() { didSet {language.save(defaults:defaults)} @@ -118,7 +207,13 @@ struct PreparationIllustration:View { @Published var splitIOS=true {didSet {defaults.set(splitIOS,forKey:"iosSplitGameData");output=nil;assets=nil;restoreOutput()}} var isMac: Bool {target == "mac"} @Published var folder: URL? {didSet {inspectFiles()}} - @Published var keysFolder: URL? + @Published var keysFolder: URL? {didSet {identifyGame()}} + @Published var inspecting=false + @Published var inspection:PackageInspection? + private var inspectionData:Data? + private var inspectionManifest:URL? + private var inspectionProcess:Process? + private var inspectionGeneration=UUID() @Published var detectedFiles:[PreparationFile]=[] var readableKeys:Bool { guard let key=keysFolder?.appendingPathComponent("prod.keys"), @@ -128,6 +223,7 @@ struct PreparationIllustration:View { func inspectFiles() { guard let folder else {detectedFiles=[];return} detectedFiles=PreparationFile.inspect(folder) + identifyGame() } @Published var running = false @Published var phase = "Tu próximo juego empieza aquí." @@ -174,6 +270,36 @@ struct PreparationIllustration:View { do {catalog=try GameCatalog.load(repo:repo);catalogFailed=false} catch {catalog=[];catalogFailed=true} } + func cancelInspection() { + inspectionGeneration=UUID() + if let task=inspectionProcess,task.isRunning {if kill(-task.processIdentifier,SIGTERM) != 0 {task.terminate()}} + inspectionProcess=nil;inspecting=false + } + func identifyGame(setupTools:Bool=false) { + cancelInspection();inspection=nil;inspectionData=nil + guard let folder,readableKeys,!detectedFiles.isEmpty else {return} + let script=repo.appendingPathComponent("scripts/inspect_game.py") + guard FileManager.default.fileExists(atPath:script.path),let keysFolder else {return} + let generation=inspectionGeneration,task=Process(),pipe=Pipe() + task.executableURL=URL(fileURLWithPath:"/usr/bin/python3") + task.arguments=[repo.appendingPathComponent("desktop/NxNativeEasy/build_session.py").path,script.path,folder.path,"--keys",keysFolder.appendingPathComponent("prod.keys").path]+(setupTools ? ["--setup-tools"] : []) + task.standardOutput=pipe;task.standardError=FileHandle.nullDevice + var env=ProcessInfo.processInfo.environment + env.removeValue(forKey:"NXE_BUILD_LOG");env.removeValue(forKey:"NXE_GAME_DIR") + task.environment=env + do {try task.run();inspectionProcess=task;inspecting=true} + catch {inspection=try? JSONDecoder().decode(PackageInspection.self,from:Data("{\"schema\":1,\"status\":\"metadata_unreadable\"}".utf8));return} + DispatchQueue.global(qos:.userInitiated).async { [weak self] in + let data=try? pipe.fileHandleForReading.readToEnd() + task.waitUntilExit() + Task {@MainActor in + guard let self,self.inspectionGeneration==generation else {return} + self.inspectionProcess=nil;self.inspecting=false + if let data,data.count<=1048576,let result=try? JSONDecoder().decode(PackageInspection.self,from:data),result.schema==1 {self.inspection=result;self.inspectionData=data} + else {self.inspection=try? JSONDecoder().decode(PackageInspection.self,from:Data("{\"schema\":1,\"status\":\"metadata_unreadable\"}".utf8))} + } + } + } func openCatalog() { refreshCatalog() showCatalog=true @@ -243,7 +369,7 @@ struct PreparationIllustration:View { } func build() { - guard let folder, !running, !target.isEmpty else {return} + guard let folder, !running, !inspecting,inspection?.ready==true,!target.isEmpty else {return} let script=repo.appendingPathComponent("scripts/prepare_ios.sh") guard FileManager.default.fileExists(atPath:script.path) else { phase="No encontramos el código de nxnative.";detail="Vuelve a crear la app desde el repositorio con Abrir-interfaz.command.";failed=true;return @@ -253,7 +379,11 @@ struct PreparationIllustration:View { let task=Process();let pipe=Pipe() task.executableURL=URL(fileURLWithPath:"/usr/bin/python3") var arguments=[repo.appendingPathComponent("desktop/NxNativeEasy/build_session.py").path,script.path,"--target",target,folder.path] - arguments += ["--keys-folder",keysFolder.path] + guard let inspectionData else {return} + let manifest=FileManager.default.temporaryDirectory.appendingPathComponent("nxnative-inspection-"+UUID().uuidString+".json") + do {try inspectionData.write(to:manifest,options:.atomic);inspectionManifest=manifest} + catch {failed=true;phase="No pudimos guardar la comprobación del juego.";return} + arguments += ["--keys-folder",keysFolder.path,"--package-metadata",manifest.path] if target=="ios" && !splitIOS {arguments.append("--full-only")} task.arguments=arguments;task.currentDirectoryURL=repo task.standardOutput=pipe;task.standardError=pipe @@ -280,6 +410,7 @@ struct PreparationIllustration:View { guard let self else{return} self.activity.finish(logURL:self.logURL);self.activity.complete(exitCode:status,cancelled:self.cancelled);self.transcript=self.activity.transcript self.running=false;self.process=nil + if let manifest=self.inspectionManifest {try? FileManager.default.removeItem(at:manifest);self.inspectionManifest=nil} if self.cancelled { self.phase="Preparación cancelada.";self.detail="Tus archivos y partidas anteriores se conservaron." } else if status==0 { @@ -308,7 +439,7 @@ struct PreparationIllustration:View { } } do { process=task;try task.run() } - catch { running=false;process=nil;failed=true;phase="No pudimos iniciar la preparación.";detail=error.localizedDescription } + catch { if let manifest=inspectionManifest {try? FileManager.default.removeItem(at:manifest);inspectionManifest=nil};running=false;process=nil;failed=true;phase="No pudimos iniciar la preparación.";detail=error.localizedDescription } } func append(_ data:Data) { guard running else {return} @@ -349,6 +480,7 @@ struct PreparationIllustration:View { NSWorkspace.shared.activateFileViewerSelecting(paths.isEmpty ? [folder] : paths) } func cancel() { + cancelInspection() guard let process,process.isRunning else{return} cancelled=true if kill(-process.processIdentifier,SIGTERM) != 0 {process.terminate()} @@ -537,9 +669,11 @@ struct ContentView: View { Spacer() Button(model.folder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseFolder()}.disabled(model.running || updater.busy || sideload.busy).accessibilityLabel(model.text("Carpeta de tu juego")) } - PreparationInventory(files:model.detectedFiles,text:model.text) - Text(model.text("Detectamos nombres, formatos y tamaños. El juego y su versión se confirman al preparar con tus llaves.")).font(.caption).foregroundStyle(.secondary) - Button(model.text("Continuar")){model.inspectFiles();model.preparationStep=2}.buttonStyle(LibraryPrimaryButtonStyle()).disabled(model.failed || model.folder==nil || (model.detectedFiles.isEmpty || model.detectedFiles.contains(where:{!$0.readable})) || model.running || sideload.busy || updater.busy) + PackageIdentityView(inspection:model.inspection,inspecting:model.inspecting,hasKeys:model.readableKeys,catalog:model.catalog,text:model.text,chooseKeys:{model.chooseKeysFolder()},retry:{model.identifyGame()},setup:{model.identifyGame(setupTools:true)},cancel:{model.cancelInspection()}) + if model.inspection?.ready==true { + DisclosureGroup(model.text("Ver archivos detectados")) {PreparationInventory(files:model.detectedFiles,text:model.text)}.font(.callout).foregroundStyle(.secondary) + } else {PreparationInventory(files:model.detectedFiles,text:model.text)} + Button(model.text("Continuar")){model.preparationStep=model.readableKeys ? 3 : 2}.buttonStyle(LibraryPrimaryButtonStyle()).disabled(model.inspecting || model.inspection?.ready != true || model.failed || model.folder==nil || (model.detectedFiles.isEmpty || model.detectedFiles.contains(where:{!$0.readable})) || model.running || sideload.busy || updater.busy) } if model.preparationStep==2 { HStack(spacing:12) { @@ -553,11 +687,13 @@ struct ContentView: View { Button(model.keysFolder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseKeysFolder()}.disabled(model.running || updater.busy || sideload.busy).accessibilityLabel(model.text("Seleccionar prod.keys")) } Label(model.text(model.readableKeys ? "Recordamos su ubicación. La compatibilidad de las llaves se comprueba al preparar." : "Elige prod.keys o arrástralo aquí. No necesitas seleccionar una carpeta."),systemImage:model.readableKeys ? "checkmark.shield" : "hand.draw").font(.callout).foregroundStyle(.secondary) - Button(model.text("Continuar")){model.preparationStep=3}.buttonStyle(LibraryPrimaryButtonStyle()).disabled(model.failed || !model.readableKeys || model.running || sideload.busy || updater.busy) + if model.folder != nil {PackageIdentityView(inspection:model.inspection,inspecting:model.inspecting,hasKeys:model.readableKeys,catalog:model.catalog,text:model.text,chooseKeys:{model.chooseKeysFolder()},retry:{model.identifyGame()},setup:{model.identifyGame(setupTools:true)},cancel:{model.cancelInspection()})} + Button(model.text("Continuar")){model.preparationStep=3}.buttonStyle(LibraryPrimaryButtonStyle()).disabled(model.inspecting || model.inspection?.ready != true || model.failed || !model.readableKeys || model.running || sideload.busy || updater.busy) } if model.preparationStep==3 { Label(model.isMac ? "Mac" : "iPhone / iPad",systemImage:model.isMac ? "desktopcomputer" : "ipad.and.iphone").font(.title2.bold()) - Text(model.gameName).font(.headline) + Text(model.inspection?.name ?? model.gameName).font(.headline) + if let version=model.inspection?.display_version {Label(model.text("Versión a preparar")+": "+version,systemImage:"tag")} Label(model.text("Llaves seleccionadas"),systemImage:"key.horizontal").foregroundStyle(.secondary) } if model.target=="ios" && model.preparationStep==3 { diff --git a/desktop/NxNativeEasy/tests/workflow_check.swift b/desktop/NxNativeEasy/tests/workflow_check.swift index f497773..4594423 100644 --- a/desktop/NxNativeEasy/tests/workflow_check.swift +++ b/desktop/NxNativeEasy/tests/workflow_check.swift @@ -1,6 +1,6 @@ import Foundation @main struct WorkflowCheck { - @MainActor static func main() throws { + @MainActor static func main() async throws { let root=FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).resolvingSymlinksInPath() try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) defer {try? FileManager.default.removeItem(at:root)} @@ -29,6 +29,27 @@ import Foundation model.running=true;model.acceptDrop([other]);assert(model.folder?.path==game.path);model.running=false for url in [file,update,key] {let bytes=try Data(contentsOf:url);assert(bytes==Data("synthetic original".utf8))} assert(!FileManager.default.fileExists(atPath:repo.appendingPathComponent("prod.keys").path)) + // Fake metadata worker: exercises automatic read, stale result rejection and cancellation only. + let scripts=repo.appendingPathComponent("scripts"),desktop=repo.appendingPathComponent("desktop/NxNativeEasy") + try FileManager.default.createDirectory(at:scripts,withIntermediateDirectories:true) + try FileManager.default.createDirectory(at:desktop,withIntermediateDirectories:true) + try """ + import os,sys + if os.getpgrp()!=os.getpid():os.setsid() + os.execv(sys.executable,[sys.executable]+sys.argv[1:]) + """.write(to:desktop.appendingPathComponent("build_session.py"),atomically:true,encoding:.utf8) + try """ + import sys,json,time,os + time.sleep(0.15) + print(json.dumps({'schema':1,'status':'ready','name':os.path.basename(sys.argv[1]),'title_id':'010093801237C000','version':0,'updates':[],'dlc':[]})) + """.write(to:scripts.appendingPathComponent("inspect_game.py"),atomically:true,encoding:.utf8) + assert(model.acceptFolder(game));assert(model.inspecting) + assert(model.acceptFolder(other));assert(model.inspecting) + for _ in 0..<100 {if !model.inspecting {break};try await Task.sleep(nanoseconds:20_000_000)} + assert(model.inspection?.ready==true && model.inspection?.name=="other") + model.identifyGame();assert(model.inspecting);model.cancelInspection() + try await Task.sleep(nanoseconds:250_000_000) + assert(!model.inspecting && model.inspection==nil) print("Workflow PASS: shared chooser/drop validation, target-first flow, one-folder ambiguity, repository/symlink rejection, busy guard and unchanged source/key bytes") } } diff --git a/desktop/release.json b/desktop/release.json index 1f00196..8f5c712 100644 --- a/desktop/release.json +++ b/desktop/release.json @@ -1,5 +1,5 @@ { - "version": "1.15.0", - "build": 26, + "version": "1.16.0", + "build": 27, "minimumMacOSMajor": 26 } diff --git a/dm/tools/nx_extract/Program.cs b/dm/tools/nx_extract/Program.cs index 5d7c0bb..98a68b5 100644 --- a/dm/tools/nx_extract/Program.cs +++ b/dm/tools/nx_extract/Program.cs @@ -26,6 +26,7 @@ static class P { [DllImport("libc", SetLastError = true)] static extern int clonefile(string src, string dst, uint flags); static int Main(string[] a) { + if (a.Length >= 3 && a[0] == "--inspect") return Inspect(a); bool manifest = Array.IndexOf(a, "--manifest") >= 0; a = Array.FindAll(a, s => s != "--manifest"); g_allow = Array.IndexOf(a, "--allow-damaged") >= 0; a = Array.FindAll(a, s => s != "--allow-damaged"); if (a.Length < 4) { Console.Error.WriteLine("usage: nx_extract [base RomFS dir] [--manifest] [--allow-damaged]\n nx_extract --aoc "); return 2; } @@ -71,6 +72,56 @@ static class P { return Damaged(); } + // Metadata-only path: no ExeFS, RomFS, icon or game output is written. + // CNMT supplies package identity/type/numeric version; NACP supplies the display name/version. + static int Inspect(string[] args) { + var packages = new List(); var errors = new List(); + KeySet keys; + try { keys = ExternalKeyReader.ReadKeyFile(args[1]); } + catch { Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(new { schema = 1, packages, errors = new[] { new { file = "prod.keys", code = "keys_unreadable" } } })); return 1; } + foreach (string path in args[2..]) { + string file = Path.GetFileName(path); + try { + var ncas = OpenNsp(keys, path); int found = 0; + foreach (Nca meta in ncas) { + if (meta.Header.ContentType != NcaContentType.Meta) continue; + using IFileSystem fs = meta.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid); + foreach (DirectoryEntryEx entry in fs.EnumerateEntries("*", SearchOptions.Default)) { + if (!entry.Name.EndsWith(".cnmt", StringComparison.OrdinalIgnoreCase)) continue; + using var stream = new UniqueRef(); + fs.OpenFile(ref stream.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + byte[] header = new byte[0x20]; + stream.Get.Read(out long read, 0, header, ReadOption.None).ThrowIfFailure(); + if (read != header.Length) throw new InvalidDataException(); + ulong id = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(header); + uint version = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(8)); + string kind = header[12] switch { 0x80 => "base", 0x81 => "update", 0x82 => "dlc", _ => "other" }; + ulong application = kind == "update" ? id & ~0xFFFUL : id; + if (kind == "dlc") { + byte[] extended = new byte[8]; + stream.Get.Read(out long got, 0x20, extended, ReadOption.None).ThrowIfFailure(); + if (got != 8) throw new InvalidDataException(); + application = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(extended); + } + string name = null, display = null; + Nca control = Pick(ncas, NcaContentType.Control, application); + if (control != null) { + byte[] nacp = Nacp(control); + if (nacp.Length < 0x3070) throw new InvalidDataException(); + name = TitleName(nacp); + display = System.Text.Encoding.UTF8.GetString(nacp, 0x3060, 0x10).TrimEnd('\0'); + } + packages.Add(new { file, title_id = application.ToString("X16"), content_id = id.ToString("X16"), kind, version, name, display_version = display }); + found++; + } + } + if (found == 0) errors.Add(new { file, code = "metadata_missing" }); + } catch { errors.Add(new { file, code = "metadata_unreadable" }); } + } + Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(new { schema = 1, packages, errors })); + return errors.Count == 0 ? 0 : 1; + } + // Files whose data failed the hash check: listed at the end; exit code 3 unless --allow-damaged static readonly List g_damaged = new List(); static bool g_allow; static Func g_lenient; static IFileSystem g_lenientFs; static bool HashError(HorizonResultException e) => e.ResultValue.Module == 2 && e.ResultValue.Description >= 4600 && e.ResultValue.Description < 4700; // fs: integrity verification failures (2002-46xx) diff --git a/docs/agents/07-upstream-maintenance.md b/docs/agents/07-upstream-maintenance.md index 4a13bb0..160e553 100644 --- a/docs/agents/07-upstream-maintenance.md +++ b/docs/agents/07-upstream-maintenance.md @@ -116,3 +116,5 @@ appear everywhere without being forgotten. No recurring schedule is implied. The CI catalog gate prevents incomplete game lists from silently shipping. The tools stage and check updates; they do not auto-approve unreviewed engine changes, erase conflicts, install on devices or overwrite a user's game files. + +Preserve metadata-only `nx_extract --inspect` and `scripts/inspect_game.py`: game names/display versions come from NACP, package identity/type/version from CNMT. Frontend builds pass their checked manifest through both wrappers; do not restore filename-based guesses for these builds. Reject changed file signatures and mixed games before preparation; retain local-only key handling and cancelled/stale worker-result guards. diff --git a/docs/personal-ios.md b/docs/personal-ios.md index efae6e8..cac628e 100644 --- a/docs/personal-ios.md +++ b/docs/personal-ios.md @@ -697,3 +697,9 @@ checks validate the delivered UI/client patch. ### Guided preparation inspection (1.15.0) Each milestone has a distinct native vector composition. The file step lists top-level NSP/XCI/NSZ/XCZ regular files, their size and readability; it does not infer a title/version from folder names or decrypt packages. Identity and key compatibility are confirmed by preparation. Unreadable or empty packages block continuation. Drop stays on the file step so the user can inspect before continuing. The keys picker accepts the `prod.keys` file directly (or drag/drop), rejects empty/unreadable files and resolved repository paths, and remembers only its parent path for the existing build CLI. No key contents are read by the picker. Existing folder preferences remain compatible. Synthetic workflow tests cover inspection, direct key selection and source preservation. + +### Internal game identity before compilation (1.16.0) + +The file step now invokes `scripts/inspect_game.py` with the chosen local `prod.keys`. Its metadata-only `nx_extract --inspect` mode reads CNMT identities, package types and numeric versions plus NACP names/display versions. It writes no game extraction or app. The highest internal update version is selected regardless of filename tags; base-only folders are explicit. Multiple game identities, missing bases, partial reads, missing reader tools and unreadable keys have distinct states. Unknown display versions are shown as internal numeric versions, never invented. Compressed archives work when their metadata/control NCAs remain readable; otherwise the UI reports failure rather than silently ignoring them. The reader is built once per source digest into an external user cache, with optional explicit .NET setup. + +Both iOS and Mac build wrappers receive the inspection manifest and use its actual title IDs and numeric package versions rather than filename guesses. Input filenames/sizes/mtime are rechecked before the normal builder runs; changes require re-identification. The manifest contains no key contents and is removed after the preparation exits. Existing CLI users without a manifest keep the original scanning behavior. Verified read-only against the local Metroid base XCI (1.0.0) plus update NSP (2.1.0, internal 327680), without extracting RomFS or invoking a game. Runtime/graphics paths are unchanged; no gameplay or device install was used to validate this metadata feature. diff --git a/scripts/build_ios.py b/scripts/build_ios.py index 3b6e0be..6e5116a 100755 --- a/scripts/build_ios.py +++ b/scripts/build_ios.py @@ -34,7 +34,7 @@ def build_lock(): fcntl.flock(lock,fcntl.LOCK_UN) -def load_builder(): +def load_builder(package_metadata=None): spec = importlib.util.spec_from_file_location('nxnative_builder', REPO / 'scripts/build_ipa_from_nsp.py') assert spec is not None and spec.loader is not None builder = importlib.util.module_from_spec(spec) @@ -42,6 +42,12 @@ def load_builder(): spec.loader.exec_module(builder) # Preserve the venv executable path: resolving its symlink selects global Python. builder.TOOL_PATHS.insert(0, str(Path(sys.executable).parent)) + if package_metadata is not None: + metadata_spec = importlib.util.spec_from_file_location('nxnative_inspection', REPO/'scripts/inspect_game.py') + metadata = importlib.util.module_from_spec(metadata_spec) + metadata_spec.loader.exec_module(metadata) + value = json.loads(Path(package_metadata).read_text()) + builder.scan_nsps = lambda folder: metadata.family_from_metadata(folder, value) return builder @@ -96,6 +102,7 @@ def parser(): p.add_argument('-l', '--language', help='first build defaults to es-419; later builds retain identity settings') p.add_argument('--bundle-id', help='use the SAME effective identifier when updating an installed app') p.add_argument('--app-name') + p.add_argument('--package-metadata',type=Path,help='verified preflight metadata; changed inputs are rejected') p.add_argument('--package', help='explicit game update, such as v327680') p.add_argument('--gpu', help='pipeline archive target; first build defaults to none') p.add_argument('--show-fps', action=argparse.BooleanOptionalAction, default=None, help='retain FPS display choice across builds') @@ -184,7 +191,7 @@ def main(argv=None): keys = select_keys(args) if any(p.suffix.lower() in {'.nsz','.xcz'} for p in packages): subprocess.run([sys.executable,'-m','pip','install','nsz'],check=True) - builder = load_builder() + builder = load_builder(args.package_metadata) if args.package_metadata else load_builder() code = builder.cli(command + ['-k',str(keys)]) if code: return code info = verify_ipa(output) diff --git a/scripts/build_mac.py b/scripts/build_mac.py index 172c4a7..d731420 100644 --- a/scripts/build_mac.py +++ b/scripts/build_mac.py @@ -15,6 +15,7 @@ def main(argv=None): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('game_dir',type=Path) parser.add_argument('--keys-folder',type=Path,required=True) + parser.add_argument('--package-metadata',type=Path) parser.add_argument('--dry-run',action='store_true') args=parser.parse_args(argv) game=args.game_dir.expanduser().resolve(strict=True) @@ -48,7 +49,8 @@ def main(argv=None): if any(p.suffix.lower() in {'.nsz','.xcz'} for p in packages): import subprocess subprocess.run([sys.executable,'-m','pip','install','nsz'],check=True) - code=load_builder().cli(command+['-k',str(keys)]) + builder=load_builder(args.package_metadata) if args.package_metadata else load_builder() + code=builder.cli(command+['-k',str(keys)]) if code: return code romfs=output/'Contents/Resources/romfs' if not (output/'Contents/MacOS/dmhost').is_file() or not romfs.is_dir() or romfs.is_symlink(): diff --git a/scripts/inspect_game.py b/scripts/inspect_game.py new file mode 100644 index 0000000..65a8228 --- /dev/null +++ b/scripts/inspect_game.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Read CNMT/NACP metadata without extracting or compiling a game. + +Builds only the metadata tool in the user's external tool cache when necessary. +Package/key contents and tool diagnostics are never forwarded to the UI. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + +REPO = Path(__file__).resolve().parents[1] +EXTENSIONS = {'.nsp', '.xci', '.nsz', '.xcz'} + + +def summarize(value): + if value.get('schema') != 1 or not isinstance(value.get('packages'), list): + raise ValueError('Invalid metadata response') + packages = value['packages'] + errors = value.get('errors', []) + bases = [p for p in packages if p['kind'] == 'base'] + ids = {p['title_id'] for p in packages if p['kind'] in ('base', 'update', 'dlc')} + if errors and not bases: + return dict(schema=1,status='keys_unreadable' if any(e.get('code')=='keys_unreadable' for e in errors) else 'metadata_unreadable',packages=packages,errors=errors) + if len(ids) != 1 or not bases: + return dict(schema=1, status='multiple_games' if len(ids) > 1 else 'base_missing', packages=packages, errors=errors) + title_id = bases[0]['title_id'] + updates = sorted([p for p in packages if p['kind'] == 'update'], key=lambda p: p['version'], reverse=True) + base = max(bases, key=lambda p: p['version']) + selected = updates[0] if updates else base + name = selected.get('name') or next((p.get('name') for p in bases if p.get('name')), None) + return dict(schema=1, status='ready' if not errors else 'incomplete', title_id=title_id, + name=name, display_version=selected.get('display_version'), version=selected['version'], + selected_file=selected['file'], base_version=base.get('display_version'), + updates=updates, dlc=[p for p in packages if p['kind'] == 'dlc'], packages=packages, errors=errors) + + +def inspect(folder, keys, cache, setup_tools=False): + folder, keys = folder.resolve(), keys.resolve() + if folder == REPO or REPO in folder.parents or REPO in keys.parents: + return dict(schema=1, status='invalid_location') + files = sorted(p for p in folder.iterdir() if p.suffix.lower() in EXTENSIONS and p.is_file()) + if not files: + return dict(schema=1, status='base_missing') + if any(p.resolve() == REPO or REPO in p.resolve().parents for p in files): + return dict(schema=1, status='invalid_location') + env = dict(os.environ) + env['PATH'] = '/opt/homebrew/bin:/usr/local/bin:/usr/local/share/dotnet:' + env.get('PATH', '') + env.update(DOTNET_CLI_TELEMETRY_OPTOUT='1', DOTNET_NOLOGO='1') + dotnet = shutil.which('dotnet', path=env['PATH']) + if setup_tools: + brew = shutil.which('brew', path=env['PATH']) + if not brew: + return dict(schema=1, status='tools_missing') + command = 'upgrade' if subprocess.run([brew, 'list', '--cask', '--versions', 'dotnet-sdk'], env=env, capture_output=True).returncode == 0 else 'install' + result = subprocess.run([brew, command, '--cask', 'dotnet-sdk'], env=env, capture_output=True, timeout=900) + if result.returncode: + return dict(schema=1, status='tools_unavailable') + dotnet = shutil.which('dotnet', path=env['PATH']) + if not dotnet: + return dict(schema=1, status='tools_missing') + source = REPO / 'dm/tools/nx_extract' + digest = hashlib.sha256(b''.join(p.read_bytes() for p in (source/'Program.cs', source/'nx_extract.csproj'))).hexdigest() + tool = cache / digest / 'nx_extract.dll' + if not tool.is_file(): + cache.mkdir(parents=True, exist_ok=True) + # Both the build output and MSBuild's intermediate files stay outside the checkout. + with tempfile.TemporaryDirectory(prefix='metadata-', dir=cache) as temporary: + work = Path(temporary) + project = work/'source' + project.mkdir() + for name in ('Program.cs', 'nx_extract.csproj'): + shutil.copy2(source/name, project/name) + result = subprocess.run([dotnet, 'build', str(project), '-c', 'Release', '-o', str(work/'tool'), '-v', 'quiet', '-nologo'], + env=env, capture_output=True, timeout=240) + if result.returncode: + return dict(schema=1, status='tools_unavailable') + try: + (work/'tool').rename(tool.parent) + except FileExistsError: + pass # another read-only inspection populated the same tool cache + signatures = {p.name: [p.stat().st_size, p.stat().st_mtime_ns] for p in files} + result = subprocess.run([dotnet, str(tool), '--inspect', str(keys), *(str(p) for p in files)], + env=env, capture_output=True, timeout=180) + if len(result.stdout) > 1048576: + return dict(schema=1, status='metadata_unreadable') + try: + value = summarize(json.loads(result.stdout)) + if any([p.stat().st_size, p.stat().st_mtime_ns] != signatures[p.name] for p in files): + return dict(schema=1, status='files_changed') + value['sources'] = signatures + return value + except (ValueError, KeyError, TypeError): + return dict(schema=1, status='metadata_unreadable') + + +def family_from_metadata(folder, value): + """Use the exact inspected identities/versions, rejecting stale or ambiguous inputs.""" + if value.get('schema') != 1 or value.get('status') != 'ready' or value.get('errors'): + raise ValueError('Game metadata is incomplete; identify the folder again.') + folder = Path(folder).resolve() + files = {p.name: p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in EXTENSIONS} + signatures = {name: [p.stat().st_size, p.stat().st_mtime_ns] for name, p in files.items()} + if signatures != value.get('sources'): + raise ValueError('Game files changed after identification; identify the folder again.') + application = int(value['title_id'], 16) + family = dict(base=None, updates=[], dlc=[]) + bases = [] + for package in value['packages']: + name = package['file'] + if name not in files or int(package['title_id'], 16) != application: + raise ValueError('Package metadata does not match the selected game.') + path = files[name] + version = package['version'] + if not isinstance(version, int) or not 0 <= version <= 0xFFFFFFFF: + raise ValueError('Invalid package version.') + if package['kind'] == 'base': + bases.append((version, path)) + elif package['kind'] == 'update': + family['updates'].append((version, path)) + elif package['kind'] == 'dlc': + family['dlc'].append((int(package['content_id'], 16), version, path)) + if not bases: + raise ValueError('No base game in inspected packages.') + family['base'] = max(bases, key=lambda item: (item[0], item[1].suffix.lower() not in ('.nsz', '.xcz'), item[1].name))[1] + return application, family + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('folder', type=Path) + parser.add_argument('--keys', type=Path, required=True) + parser.add_argument('--setup-tools', action='store_true') + parser.add_argument('--cache', type=Path, default=Path.home()/'Library/Caches/nxnative Easy/metadata') + args = parser.parse_args() + try: + value = inspect(args.folder, args.keys, args.cache, args.setup_tools) + except (OSError, subprocess.SubprocessError): + value = dict(schema=1, status='metadata_unreadable') + print(json.dumps(value)) + + +if __name__ == '__main__': + main() diff --git a/scripts/tests/test_game_inspection.py b/scripts/tests/test_game_inspection.py new file mode 100644 index 0000000..d7d53c7 --- /dev/null +++ b/scripts/tests/test_game_inspection.py @@ -0,0 +1,59 @@ +"""Metadata decisions use internal identities and reject stale inputs, never filename tags.""" +import copy +import importlib.util +from pathlib import Path +import tempfile +import unittest + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location('inspection',ROOT/'scripts/inspect_game.py') +inspection=importlib.util.module_from_spec(spec);spec.loader.exec_module(inspection) + + +def package(file,kind,version,display=None,title='010093801237C000'): + return dict(file=file,kind=kind,version=version,display_version=display,name='Synthetic Game',title_id=title,content_id=title if kind=='base' else title[:-3]+'800') + + +class GameInspection(unittest.TestCase): + def test_internal_version_beats_filename(self): + value=inspection.summarize(dict(schema=1,packages=[package('base.xci','base',0,'1.0'),package('update-v999999.nsp','update',65536,'1.1'),package('no tags.nsp','update',131072,'1.2')],errors=[])) + self.assertEqual((value['status'],value['display_version'],value['selected_file']),('ready','1.2','no tags.nsp')) + self.assertEqual(value['base_version'],'1.0') + self.assertEqual(len(value['updates']),2) + + def test_base_only_and_unknown_display_are_honest(self): + value=inspection.summarize(dict(schema=1,packages=[package('base.xci','base',0)],errors=[])) + self.assertEqual(value['updates'],[]) + self.assertIsNone(value['display_version']) + + def test_multiple_games_missing_base_and_partial_errors(self): + base=package('base.xci','base',0) + for packages,errors,status in [([package('update.nsp','update',1)],[],'base_missing'),([base,package('other.xci','base',0,title='010015100B514000')],[],'multiple_games'),([base],[dict(file='bad.nsp',code='metadata_unreadable')],'incomplete')]: + self.assertEqual(inspection.summarize(dict(schema=1,packages=packages,errors=errors))['status'],status) + + def test_manifest_drives_build_and_rejects_changed_inputs(self): + with tempfile.TemporaryDirectory() as temporary: + folder=Path(temporary) + for name in ['original.xci','untagged.nsp']:(folder/name).write_bytes(b'synthetic') + value=inspection.summarize(dict(schema=1,packages=[package('original.xci','base',0,'1.0'),package('untagged.nsp','update',196608,'1.3')],errors=[])) + value['sources']={p.name:[p.stat().st_size,p.stat().st_mtime_ns] for p in folder.iterdir()} + app,family=inspection.family_from_metadata(folder,value) + self.assertEqual(app,int('010093801237C000',16)) + self.assertEqual(family['updates'],[(196608,(folder/'untagged.nsp').resolve())]) + self.assertEqual((folder/'untagged.nsp').read_bytes(),b'synthetic') + bad=copy.deepcopy(value);bad['packages'][1]['file']='../escape.nsp' + with self.assertRaises(ValueError):inspection.family_from_metadata(folder,bad) + (folder/'untagged.nsp').write_bytes(b'changed') + with self.assertRaises(ValueError):inspection.family_from_metadata(folder,value) + + def test_key_errors_do_not_claim_base_is_missing(self): + for code,status in [('keys_unreadable','keys_unreadable'),('metadata_unreadable','metadata_unreadable')]: + self.assertEqual(inspection.summarize(dict(schema=1,packages=[],errors=[dict(file='x',code=code)]))['status'],status) + + def test_added_package_invalidates_inspection(self): + with tempfile.TemporaryDirectory() as temporary: + folder=Path(temporary);(folder/'base.xci').write_bytes(b'base') + value=inspection.summarize(dict(schema=1,packages=[package('base.xci','base',0)],errors=[])) + value['sources']={p.name:[p.stat().st_size,p.stat().st_mtime_ns] for p in folder.iterdir()} + (folder/'new.nsp').write_bytes(b'update') + with self.assertRaises(ValueError):inspection.family_from_metadata(folder,value)