Add staged in-app updates and use repository artwork for all game profiles

This commit is contained in:
Erick Sanchez committed 2026-10-26 14:04:54 -06:00
1 parent 8be22c449c
commit 86793e23d6
17 files changed
+644 -27

No files matched your search

+2
View File
@@ -28,3 +28,5 @@
The translator is `dm/gfx/tools/dmsh_xlate_bin`, which matches `dmsh_chain.lock`; a fresh `dotnet publish` does not.
- iOS builds are unsigned IPAs that the user sideloads and tests; diagnostics come back as the app's `Documents/log.txt`. Do not sign or install builds on the user's devices.
- Commit only when requested or as part of an authorized repository upload. Personal commits use the repository owner: `Erick Sanchez <7268597+nalancer08@users.noreply.github.com>`. Never attribute personal changes to seedds; retain original upstream authors.
- Desktop functionality releases must increment `desktop/release.json` version/build. The updater offers newer builds only. Preserve existing checkouts and games; test updates with an external cache.
+9 -1
View File
@@ -50,6 +50,14 @@ Entregamos **código, una interfaz de preparación y herramientas de compilació
La app inicia en **English**. Cambia a **Español** con el selector de la esquina superior; la elección se guarda en este Mac. Cambia el idioma de la interfaz, no el del juego. Los detalles técnicos se muestran en su idioma original.
## Actualizar la app
Desde la **versión 1.4**, la app busca versiones nuevas al abrirse y cada seis horas mientras está abierta. Cuando haya una, pulsa **Actualizar ahora**. Descarga el código y los perfiles nuevos, compila y verifica la interfaz y la reinicia. Verás el avance dentro de la ventana. También puedes usar **nxnative Easy → Buscar actualizaciones…**.
La actualización usa una carpeta separada y guarda la app anterior como respaldo. No restablece tu checkout ni modifica tus juegos, llaves o partidas. No se puede actualizar mientras se prepara un juego. Para aplicar cambios del motor o de un perfil a un juego que ya habías preparado, vuelve a prepararlo.
**La app vieja de tu amigo todavía no tiene actualizador:** que ejecute una vez el comando de arriba o abra **START-HERE.command** para recibir esta versión. Las siguientes actualizaciones ya aparecerán dentro de la app.
## ¿Qué pasa al pulsar Preparar juego?
La app prepara las herramientas y compila para el dispositivo que elegiste. **Mac** genera una app del juego con los botones **Abrir juego** y **Mostrar app**. **iPhone / iPad** genera una IPA ligera y muestra los pasos de Sideloadly y Finder al terminar. Tus archivos y salidas anteriores se conservan en la carpeta del juego.
@@ -60,7 +68,7 @@ La preparación conserva una copia separada del código y respeta los cambios lo
El acceso de apertura usa el compilador Swift de tu Xcode. La firma de la app es **ad hoc**, local y sin cuenta, certificado ni equipo Apple (`TeamIdentifier` no asignado). No usa cuentas de empresa. Sólo al instalar la **IPA de iPhone con Sideloadly** introduces tu cuenta personal, dentro de Sideloadly.
El icono del teléfono con controles es original. Los símbolos de los perfiles son genéricos del sistema; no se usan imágenes ni logotipos de los juegos en la app. La galería de documentación de abajo procede de upstream y está separada de la interfaz.
El icono del teléfono con controles es original. Las tarjetas usan las capturas de upstream que ya están en este repositorio como referencia de soporte; no proporcionan ni descargan juegos.
## Instalar: Sideloadly + Finder
+9 -1
View File
@@ -50,6 +50,14 @@ We provide **source code, a preparation interface and build tools**. The output
The app starts in **English**. Choose **Español** using the selector at the top; your selection is remembered on this Mac. This changes the interface language, not the game's language. Build diagnostics keep their original language.
## Updating the app
From **version 1.4**, the app checks for new releases at launch and every six hours while open. When one is available, click **Update now**. It downloads the new source and game profiles, builds and verifies the new Mac interface, then restarts it. Progress stays in the window. You can also use **nxnative Easy → Check for updates…**.
The update uses a separate source folder and keeps the previous app as a backup. It does not reset your checkout or modify your game files, keys or saves. Updates are disabled while preparing a game. To apply new engine or profile changes to a previously prepared game, prepare that game again.
**Your friend's older app has no updater yet:** run the setup command above once, or double-click **START-HERE.command**, to get this version. Future updates then appear inside the app.
## What happens after Prepare game?
The app prepares the tools and builds for your chosen device. **Mac** produces a game app with **Open game** and **Show app** buttons. **iPhone / iPad** produces a lightweight IPA and shows the Sideloadly and Finder steps when ready. Your own files and previous outputs stay in the game folder.
@@ -60,7 +68,7 @@ Setup keeps a separate source checkout and preserves local edits. Developers can
The launcher uses Xcode's Swift compiler. The desktop app is explicitly **ad-hoc signed**, locally, with no Apple account, certificate or team (`TeamIdentifier` is unset). No company account is used. You enter your personal Apple account separately in Sideloadly when signing the **iPhone IPA**.
The app has an original phone-and-controller icon and uses generic system symbols for game profiles, without game artwork or logos. The documentation gallery below comes from upstream and is separate from the app.
The app has an original phone-and-controller icon. Game cards use the existing upstream screenshots in this repository as a support reference; they do not provide or download games.
## Install with Sideloadly and Finder
+105
View File
@@ -0,0 +1,105 @@
import AppKit
import Combine
import Foundation
@MainActor final class AppUpdater: ObservableObject {
@Published private(set) var available: UpdateRelease?
@Published private(set) var busy=false
@Published private(set) var phase=""
@Published private(set) var progress: Double?
@Published private(set) var error: String?
@Published var showNotice=false
private var revision: String?
private var process: Process?
private var buffered=""
var visible: Bool {available != nil || busy || error != nil}
var phaseText: String {
switch phase {
case "download":return "Descargando actualización…"
case "extract":return "Preparando el código nuevo…"
case "build":return "Compilando la actualización…"
case "verify":return "Verificando la app…"
case "restart":return "Reiniciando con la versión nueva…"
default:return "Preparando actualización…"
}
}
private var checking=false
private struct Commit: Decodable {let sha:String}
private struct Event: Decodable {let phase:String;let progress:Double?;let message:String?}
init() {
let failure=FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/nxnative Easy/Updates/last-error.json")
if FileManager.default.fileExists(atPath:failure.path) {
error="No pudimos actualizar. Tu versión anterior se conservó."
try? FileManager.default.removeItem(at:failure)
}
}
private func fetch(_ url:String) async throws -> Data {
var request=URLRequest(url:URL(string:url)!,timeoutInterval:20)
request.setValue("nxnative-Easy-Updater",forHTTPHeaderField:"User-Agent")
let (data,response)=try await URLSession.shared.data(for:request)
guard let http=response as? HTTPURLResponse,http.statusCode==200 else {throw URLError(.badServerResponse)}
guard data.count<128*1024 else {throw URLError(.dataLengthExceedsMaximum)}
return data
}
func check(manual:Bool=false) async {
guard !busy,!checking else {return}
checking=true;defer {checking=false}
do {
let commit=try JSONDecoder().decode(Commit.self,from:await fetch("https://api.github.com/repos/nalancer08/nxnative-easy/commits/main"))
guard commit.sha.range(of:"^[0-9a-f]{40}$",options:.regularExpression) != nil else {throw URLError(.cannotParseResponse)}
let release=try JSONDecoder().decode(UpdateRelease.self,from:await fetch("https://raw.githubusercontent.com/nalancer08/nxnative-easy/\(commit.sha)/desktop/release.json"))
try release.validate()
let current=Int(Bundle.main.object(forInfoDictionaryKey:"CFBundleVersion") as? String ?? "0") ?? 0
if release.isNewer(than:current) {
available=release;revision=commit.sha;error=nil
} else if manual {showNotice=true}
} catch {if manual {self.error="No pudimos buscar actualizaciones. Intenta de nuevo más tarde."}}
}
func start() {
guard !busy,let release=available,let revision else {return}
guard ProcessInfo.processInfo.operatingSystemVersion.majorVersion>=release.minimumMacOSMajor else {
error="Primero actualiza macOS para instalar esta versión.";return
}
guard let helper=Bundle.main.url(forResource:"update_app",withExtension:"py") else {
error="Vuelve a ejecutar el comando de instalación para activar las actualizaciones.";return
}
error=nil;busy=true;phase="download";progress=nil;buffered=""
let task=Process();let pipe=Pipe()
task.executableURL=URL(fileURLWithPath:"/usr/bin/python3")
task.arguments=[helper.path,"--revision",revision,"--build",String(release.build),
"--target",Bundle.main.bundleURL.path,"--parent-pid",String(ProcessInfo.processInfo.processIdentifier)]
// Optional local validation cache; ordinary installs use the helper's Application Support default.
if let cache=Bundle.main.object(forInfoDictionaryKey:"NxNativeUpdateCache") as? String {
task.arguments! += ["--cache",cache]
}
task.standardOutput=pipe;task.standardError=pipe
var environment=ProcessInfo.processInfo.environment;environment["PYTHONUNBUFFERED"]="1";task.environment=environment
pipe.fileHandleForReading.readabilityHandler={ [weak self] handle in
let bytes=handle.availableData
if bytes.isEmpty {handle.readabilityHandler=nil;return}
let chunk=String(decoding:bytes,as:UTF8.self)
Task { @MainActor in self?.receive(chunk) }
}
task.terminationHandler={ [weak self] _ in
Task { @MainActor in
guard let self,self.busy,self.phase != "restart" else {return}
self.busy=false;self.process=nil
if self.error==nil {self.error="No pudimos actualizar. Tu versión anterior se conservó."}
}
}
do {process=task;try task.run()}
catch {busy=false;self.error="No pudimos iniciar la actualización."}
}
private func receive(_ chunk:String) {
buffered+=chunk
while let newline=buffered.firstIndex(of:"\n") {
let line=String(buffered[..<newline]);buffered.removeSubrange(...newline)
guard let data=line.data(using:.utf8),let event=try? JSONDecoder().decode(Event.self,from:data) else {continue}
phase=event.phase;progress=event.progress
if phase=="error" {error="No pudimos actualizar. Tu versión anterior se conservó.";busy=false}
if phase=="restart" {NSApplication.shared.terminate(nil)}
}
}
func dismissError() {error=nil}
}
+12 -1
View File
@@ -4,6 +4,10 @@ struct CatalogGame: Identifiable, Equatable {
let id: String
let name: String
let versions: [String]
let artworkURL: URL?
init(id:String,name:String,versions:[String],artworkURL:URL? = nil) {
self.id=id;self.name=name;self.versions=versions;self.artworkURL=artworkURL
}
func matches(_ query: String) -> Bool {
let normalize: (String) -> String = {
@@ -22,6 +26,8 @@ struct GameCatalog {
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)
let artworkFile=repo.appendingPathComponent("desktop/NxNativeEasy/game-artwork.json")
let artwork=(try? JSONDecoder().decode([String:String].self,from:Data(contentsOf:artworkFile))) ?? [:]
var games: [CatalogGame]=[]
for child in children {
let id=child.lastPathComponent
@@ -39,7 +45,12 @@ struct GameCatalog {
if !version.isEmpty && !versions.contains(version) {versions.append(version)}
}
}
games.append(CatalogGame(id:id,name:name?.isEmpty == false ? name! : id,versions:versions))
var image: URL?
if let path=artwork[id],path.hasPrefix("docs/images/"),!path.contains("..") {
let candidate=repo.appendingPathComponent(path).resolvingSymlinksInPath()
if candidate.path.hasPrefix(repo.resolvingSymlinksInPath().path+"/"),FileManager.default.fileExists(atPath:candidate.path) {image=candidate}
}
games.append(CatalogGame(id:id,name:name?.isEmpty == false ? name! : id,versions:versions,artworkURL:image))
}
return games.sorted {
let comparison=$0.name.localizedStandardCompare($1.name)
+17
View File
@@ -12,6 +12,23 @@ enum EasyLanguage: String, CaseIterable {
struct EasyStrings {
static let english: [String:String] = [
"Buscar actualizaciones…": "Check for updates…",
"Hay una nueva versión": "A new version is available:",
"Actualizar ahora": "Update now",
"Actualiza la app y sus perfiles de juegos.": "Update the app and its game profiles.",
"Descargando actualización…": "Downloading update…",
"Preparando el código nuevo…": "Preparing the new source…",
"Compilando la actualización…": "Building the update…",
"Verificando la app…": "Verifying the app…",
"Reiniciando con la versión nueva…": "Restarting with the new version…",
"Preparando actualización…": "Preparing update…",
"No pudimos actualizar. Tu versión anterior se conservó.": "Could not update. Your previous version was preserved.",
"No pudimos buscar actualizaciones. Intenta de nuevo más tarde.": "Could not check for updates. Try again later.",
"Primero actualiza macOS para instalar esta versión.": "Update macOS first to install this version.",
"Vuelve a ejecutar el comando de instalación para activar las actualizaciones.": "Run the setup command again to enable updates.",
"No pudimos iniciar la actualización.": "Could not start the update.",
"Ya tienes la versión más reciente.": "You are up to date.",
"Aceptar": "OK",
"Probar control": "Test controller",
"Prueba tu control": "Test your controller",
"Pulsa botones y mueve los sticks. Verás cada movimiento aquí.": "Press buttons and move the sticks. Every movement appears here.",
+14
View File
@@ -0,0 +1,14 @@
import Foundation
struct UpdateRelease: Codable, Equatable {
let version: String
let build: Int
let minimumMacOSMajor: Int
func validate() throws {
guard build>0,minimumMacOSMajor>0,
version.range(of:"^[0-9]+\\.[0-9]+(?:\\.[0-9]+)?$",options:.regularExpression) != nil else {
throw URLError(.cannotParseResponse)
}
}
func isNewer(than build:Int) -> Bool {self.build>build}
}
+72
View File
@@ -0,0 +1,72 @@
{
"0100000000010000": "docs/images/super-mario-odyssey.jpg",
"0100000011D90000": "docs/images/pokemon-brilliant-diamond.jpg",
"010005501E68C000": "docs/images/ace-attorney-investigations.jpg",
"010013C00E930000": "docs/images/hollow-knight-silksong.jpg",
"010014E00DB56000": "docs/images/digimon-story-cyber-sleuth.jpg",
"010015100B514000": "docs/images/super-mario-bros-wonder.jpg",
"0100152000022000": "docs/images/mario-kart-8-deluxe.jpg",
"010020400E1C2000": "docs/images/raging-loop.jpg",
"010020D01AD24000": "docs/images/animal-well.png",
"010028600EBDA000": "docs/images/super-mario-3d-world.jpg",
"010029B018432000": "docs/images/ghost-trick-phantom-detective.jpg",
"01002B00111A2000": "docs/images/hyrule-warriors-age-of-calamity.jpg",
"01002B30028F6000": "docs/images/celeste.jpg",
"0100300012F2A000": "docs/images/advance-wars-1-2-re-boot-camp.jpg",
"0100346017304000": "docs/images/taiko-risshiden-v-dx.jpg",
"010036E00FB20000": "docs/images/the-great-ace-attorney-chronicles.jpg",
"010038B015560000": "docs/images/final-fantasy-tactics.jpg",
"01003FC01670C000": "docs/images/13-sentinels.jpg",
"01004A4010FEA000": "docs/images/bayonetta-3.jpg",
"01004AB00A260000": "docs/images/dark-souls-remastered-creation.jpg",
"010051701FB46000": "docs/images/monster-train-2.jpg",
"01005270232F2000": "docs/images/octopath-traveler-0.jpg",
"0100535012974000": "docs/images/hades.jpg",
"010055D009F78000": "docs/images/fire-emblem-three-houses.jpg",
"010056B015FE8000": "docs/images/nier-automata.png",
"01005CA01580E000": "docs/images/persona-5-royal.jpg",
"0100606026F12000": "docs/images/final-fantasy-resonance.jpg",
"0100620012D6E000": "docs/images/big-brain-academy-brain-vs-brain.jpg",
"010069401ADB8000": "docs/images/unicorn-overlord.jpg",
"01006A800016E000": "docs/images/super-smash-bros-ultimate.jpg",
"01006BB00C6F0000": "docs/images/zelda-links-awakening.jpg",
"01006C300E9F0000": "docs/images/dragon-quest-xi-s.jpg",
"01006F8002326000": "docs/images/animal-crossing-new-horizons.jpg",
"0100713016CCC000": "docs/images/ai-the-somnium-files-nirvana-initiative.jpg",
"01007300020FA000": "docs/images/astral-chain.png",
"010076F0049A2000": "docs/images/bayonetta.jpg",
"01007960049A0000": "docs/images/bayonetta-2.jpg",
"01007BB017812000": "docs/images/portal.jpg",
"01007EF00011E000": "docs/images/zelda-breath-of-the-wild.jpg",
"010086A022444000": "docs/images/ball-x-pit.jpg",
"010089B00D09C000": "docs/images/ai-the-somnium-files.jpg",
"01008BC024FDE000": "docs/images/steins-gate-reboot.jpg",
"01008CF01BAAC000": "docs/images/zelda-echoes-of-wisdom.jpg",
"01008D7016438000": "docs/images/13-sentinels.jpg",
"01008DB008C2C000": "docs/images/pokemon-shield.jpg",
"010093801237C000": "docs/images/metroid-dread.jpg",
"010099C022B96000": "docs/images/super-mario-galaxy.jpg",
"01009BF0072D4000": "docs/images/captain-toad-treasure-tracker.jpg",
"0100A00019DE0000": "docs/images/hades-ii.jpg",
"0100A3501946E000": "docs/images/octopath-traveler-ii.jpg",
"0100A5C00D162000": "docs/images/cuphead.jpg",
"0100A6301214E000": "docs/images/fire-emblem-engage.jpg",
"0100A83022A22000": "docs/images/absolum.jpg",
"0100A9D01C446000": "docs/images/dragon-quest-vii-reimagined.jpg",
"0100ABF008968000": "docs/images/pokemon-sword.jpg",
"0100B04011742000": "docs/images/monster-hunter-rise.jpg",
"0100B7C00933A000": "docs/images/pikmin-4.jpg",
"0100B99019412000": "docs/images/mario-vs-donkey-kong.jpg",
"0100BF00112C0000": "docs/images/catherine-full-body.jpg",
"0100C2A01A730000": "docs/images/romance-of-the-three-kingdoms-8-remake.jpg",
"0100CD801CE5E000": "docs/images/balatro.jpg",
"0100D9F01D474000": "docs/images/rhythm-heaven-groove.jpg",
"0100DCA0064A6000": "docs/images/luigis-mansion-3.jpg",
"0100E44016C22000": "docs/images/patricks-parabox.jpg",
"0100E65002BB8000": "docs/images/stardew-valley.jpg",
"0100ECD018EBE000": "docs/images/paper-mario-the-thousand-year-door.jpg",
"0100F2C0115B6000": "docs/images/zelda-tears-of-the-kingdom.jpg",
"0100F43008C44000": "docs/images/pokemon-legends-z-a.jpg",
"0100F5D01DA3A000": "docs/images/brain-memory-training-academy.jpg",
"0100FD8022DAA000": "docs/images/super-mario-galaxy-2.jpg"
}
+76 -16
View File
@@ -1,4 +1,5 @@
import SwiftUI
import Combine
import AppKit
import Foundation
import Darwin
@@ -183,6 +184,28 @@ import Darwin
}
}
@MainActor enum GameArtworkCache {
static var images: [URL:NSImage] = [:]
static func image(_ url:URL?) -> NSImage? {
guard let url else {return nil}
if let image=images[url] {return image}
guard let image=NSImage(contentsOf:url) else {return nil}
images[url]=image;return image
}
}
struct GameArtworkView: View {
let game: CatalogGame
var body: some View {
GeometryReader { geometry in
if let image=GameArtworkCache.image(game.artworkURL) {
Image(nsImage:image).resizable().scaledToFill()
.frame(width:geometry.size.width,height:geometry.size.height).clipped()
} else {Color.white.opacity(0.05)}
}.accessibilityLabel(game.name)
}
}
struct SupportCarousel: View {
let games: [CatalogGame]
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -198,12 +221,8 @@ struct SupportCarousel: View {
HStack(spacing:12) {
ForEach(0..<2,id:\.self) { copy in
ForEach(Array(games.enumerated()),id:\.element.id) { index, game in
let tint: Color = [.orange,.mint,.cyan,.purple,.pink][index % 5]
VStack(alignment:.leading,spacing:7) {
ZStack {
RoundedRectangle(cornerRadius:12).fill(tint.opacity(0.12))
Image(systemName:"gamecontroller").font(.system(size:29,weight:.light)).foregroundStyle(tint)
}.frame(height:66)
GameArtworkView(game:game).frame(height:92).clipShape(RoundedRectangle(cornerRadius:12))
Text(game.name).font(.system(size:11,weight:.medium))
.foregroundStyle(Color(red:0.91,green:0.93,blue:0.88))
.lineLimit(2).frame(height:30,alignment:.topLeading)
@@ -212,7 +231,7 @@ struct SupportCarousel: View {
}
}.offset(x:-offset).frame(width:geometry.size.width,alignment:.leading)
}
}.frame(height:103).clipped()
}.frame(height:129).clipped()
.accessibilityElement(children:.ignore)
.accessibilityLabel(games.map(\.name).joined(separator:", "))
.onHover { hovering in
@@ -254,6 +273,7 @@ enum AppArtwork {
}
struct ContentView: View {
@ObservedObject var updater: AppUpdater
@StateObject private var model=BuildModel()
@StateObject private var controllerMonitor=ControllerMonitor()
@State private var showControllerTest=false
@@ -276,6 +296,34 @@ struct ContentView: View {
Text("Español").tag(EasyLanguage.spanish)
}.labelsHidden().frame(width:120).accessibilityIdentifier("interface-language")
}
if updater.visible {
VStack(alignment:.leading,spacing:10) {
HStack(alignment:.top) {
VStack(alignment:.leading,spacing:4) {
Text(model.text(updater.busy ? updater.phaseText : updater.error ?? "Hay una nueva versión")+(updater.busy || updater.error != nil ? "" : " "+(updater.available?.version ?? "")))
.font(.system(size:14,weight:.semibold))
if !updater.busy && updater.error == nil {
Text(model.text("Actualiza la app y sus perfiles de juegos."))
.font(.system(size:12)).foregroundStyle(.secondary)
}
}
Spacer()
if !updater.busy {
if updater.available != nil {
Button(model.text(updater.error == nil ? "Actualizar ahora" : "Intentar de nuevo")){updater.start()}
.disabled(model.running || updater.busy).accessibilityIdentifier("install-update")
} else {
Button(model.text("Intentar de nuevo")){Task {await updater.check(manual:true)}}
}
if updater.error != nil {Button(model.text("Cerrar")){updater.dismissError()}}
}
}
if updater.busy {
if let progress=updater.progress {ProgressView(value:progress).tint(lime)}
else {ProgressView().progressViewStyle(.linear).tint(lime)}
}
}.padding(16).background(lime.opacity(0.07),in:RoundedRectangle(cornerRadius:12))
}
VStack(alignment:.leading,spacing:6) {
Text(model.text("Prepara tu juego.")).font(.system(size:32,weight:.bold,design:.rounded)).foregroundStyle(ink)
Text(model.text(model.isMac ? "Creamos una app completa para jugar en tu Mac." : "Elige dónde quieres jugar y añade tus archivos."))
@@ -305,7 +353,7 @@ struct ContentView: View {
.font(.system(size:13,weight:.semibold)).padding(.horizontal,16).padding(.vertical,9)
.foregroundStyle(model.target == target ? Color.black : ink)
.background(model.target == target ? lime : Color.white.opacity(0.06),in:RoundedRectangle(cornerRadius:9))
}.buttonStyle(.plain).disabled(model.running).accessibilityIdentifier("target-"+target)
}.buttonStyle(.plain).disabled(model.running || updater.busy).accessibilityIdentifier("target-"+target)
}
}
if model.isMac {
@@ -325,7 +373,7 @@ struct ContentView: View {
}
Spacer()
Button(model.text("Probar control")){showControllerTest=true}
.disabled(model.running).accessibilityIdentifier("test-controller")
.disabled(model.running || updater.busy).accessibilityIdentifier("test-controller")
}.accessibilityIdentifier("controller-status")
}
HStack(spacing:12) {
@@ -338,7 +386,7 @@ struct ContentView: View {
}
}
Spacer()
Button(model.folder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseFolder()}.disabled(model.running).accessibilityLabel(model.text("Carpeta de tu juego"))
Button(model.folder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseFolder()}.disabled(model.running || updater.busy).accessibilityLabel(model.text("Carpeta de tu juego"))
}
HStack(spacing:12) {
Image(systemName:"key.horizontal").font(.system(size:19)).foregroundStyle(lime).frame(width:24)
@@ -348,7 +396,7 @@ struct ContentView: View {
.font(.system(size:12)).foregroundStyle(.secondary)
}
Spacer()
Button(model.keysFolder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseKeysFolder()}.disabled(model.running).accessibilityLabel(model.text("Carpeta de llaves"))
Button(model.keysFolder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseKeysFolder()}.disabled(model.running || updater.busy).accessibilityLabel(model.text("Carpeta de llaves"))
}
if model.running {
Divider().overlay(Color.white.opacity(0.08))
@@ -370,7 +418,7 @@ struct ContentView: View {
Image(systemName:"arrow.right")
}.font(.system(size:15,weight:.bold)).padding(15).foregroundStyle(Color.black)
.background(model.folder == nil || model.target.isEmpty ? Color.gray : lime,in:RoundedRectangle(cornerRadius:10))
}.buttonStyle(.plain).disabled(model.folder == nil || model.target.isEmpty)
}.buttonStyle(.plain).disabled(model.folder == nil || model.target.isEmpty || updater.busy)
}
}.padding(22).background(Color.white.opacity(0.045),in:RoundedRectangle(cornerRadius:16))
if model.output != nil && !model.running && !model.failed {
@@ -402,7 +450,7 @@ struct ContentView: View {
Text(model.text("Para actualizar: misma cuenta e identificador de Sideloadly. Instala encima, sin desinstalar."))
.font(.system(size:11)).foregroundStyle(.secondary)
}
Button(model.text("Preparar de nuevo")){model.build()}.buttonStyle(.link)
Button(model.text("Preparar de nuevo")){model.build()}.buttonStyle(.link).disabled(updater.busy)
}.padding(22).frame(maxWidth:.infinity,alignment:.leading)
.background(lime.opacity(0.045),in:RoundedRectangle(cornerRadius:16))
}
@@ -416,9 +464,12 @@ struct ContentView: View {
}
}.padding(28)
}
}.frame(minWidth:760,minHeight:530).background(CompactWindow(height:model.isMac ? 710 : 650)).preferredColorScheme(.dark)
}.frame(minWidth:760,minHeight:530).background(CompactWindow(height:(model.isMac ? 740 : 680)+(updater.visible ? 95 : 0))).preferredColorScheme(.dark)
.sheet(isPresented:$model.showCatalog) {GameCatalogView(model:model)}
.sheet(isPresented:$showControllerTest) {ControllerTestView(text:model.text)}
.task {await updater.check()}
.onReceive(Timer.publish(every:21600,on:.main,in:.common).autoconnect()) {_ in Task {await updater.check()}}
.alert(model.text("Ya tienes la versión más reciente."),isPresented:$updater.showNotice) {Button(model.text("Aceptar"),role:.cancel) {}}
.onReceive(NotificationCenter.default.publisher(for:NSApplication.willTerminateNotification)) {_ in model.cancel()}
}
}
@@ -463,7 +514,7 @@ struct GameCatalogView: View {
LazyVStack(spacing:8) {
ForEach(results) {game in
HStack(spacing:12) {
Image(systemName:"gamecontroller").font(.system(size:20)).foregroundStyle(lime).frame(width:36)
GameArtworkView(game:game).frame(width:80,height:45).clipShape(RoundedRectangle(cornerRadius:7))
VStack(alignment:.leading,spacing:5) {
Text(game.name).font(.system(size:14,weight:.semibold)).fixedSize(horizontal:false,vertical:true)
if !game.versions.isEmpty {
@@ -485,12 +536,21 @@ struct GameCatalogView: View {
@main struct NxNativeEasyApp: App {
@NSApplicationDelegateAdaptor(LauncherDelegate.self) private var delegate
@StateObject private var updater=AppUpdater()
@AppStorage("interfaceLanguage") private var appLanguage="en"
var body: some Scene {
Window("nxnative Easy",id:"main") {
ContentView().onAppear {
ContentView(updater:updater).onAppear {
NSApplication.shared.applicationIconImage=AppArtwork.icon
}
}
.defaultSize(width:820,height:710)
.defaultSize(width:820,height:740)
.commands {
CommandGroup(after:.appInfo) {
Button(EasyStrings.text("Buscar actualizaciones…",language:EasyLanguage(rawValue:appLanguage) ?? .english)) {
Task {await updater.check(manual:true)}
}.disabled(updater.busy)
}
}
}
}
@@ -33,6 +33,7 @@ import Foundation
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.allSatisfy {$0.artworkURL != nil},"Every current profile must have its existing repo screenshot")
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")
}
@@ -0,0 +1,15 @@
import Foundation
@main struct ReleaseChecks {
static func main() throws {
let data=Data(#"{"version":"1.4","build":5,"minimumMacOSMajor":26}"#.utf8)
let release=try JSONDecoder().decode(UpdateRelease.self,from:data)
try release.validate()
precondition(release.isNewer(than:4) && !release.isNewer(than:5) && !release.isNewer(than:6))
for version in ["latest","../invalid","", "1.4;command"] {
do {try UpdateRelease(version:version,build:5,minimumMacOSMajor:26).validate();fatalError("Invalid version accepted")}
catch {}
}
print("Release metadata: new, equal, older and malformed versions passed")
}
}
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Pinned, staged self-update for nxnative Easy; never edits an existing checkout."""
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import plistlib
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time
import urllib.request
import uuid
import zipfile
IDENTIFIER='com.nalancer08.nxnative.easy'
MAX_ARCHIVE=256*1024*1024
MAX_EXPANDED=2*1024*1024*1024
def emit(phase,progress=None,message=None):
try:
print(json.dumps(dict(phase=phase,progress=progress,message=message)),flush=True)
except BrokenPipeError:
pass
def manifest(path):
value=json.loads(Path(path).read_text())
if not re.fullmatch(r'\d+\.\d+(?:\.\d+)?',str(value.get('version',''))):
raise ValueError('Invalid update version.')
if type(value.get('build')) is not int or value['build']<1:
raise ValueError('Invalid update build.')
if type(value.get('minimumMacOSMajor')) is not int or value['minimumMacOSMajor']<1:
raise ValueError('Invalid minimum macOS version.')
return value
def extract(archive,destination,revision):
expected='nxnative-easy-'+revision
with zipfile.ZipFile(archive) as z:
total=0;seen=set()
for entry in z.infolist():
path=PurePosixPath(entry.filename)
mode=entry.external_attr>>16
if path.is_absolute() or '..' in path.parts or '\\' in entry.filename or not path.parts or path.parts[0]!=expected:
raise ValueError('Unsafe update archive path.')
if stat.S_ISLNK(mode) or (stat.S_IFMT(mode) and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode))):
raise ValueError('Links and special files are not allowed in updates.')
relative=PurePosixPath(*path.parts[1:])
if '.git' in relative.parts or relative.name.lower() in {'prod.keys','title.keys'} or relative.suffix.lower() in {'.nsp','.xci','.nsz','.xcz','.ipa','.p12','.mobileprovision'}:
raise ValueError('Unexpected game or credential file in update.')
if entry.filename in seen:raise ValueError('Duplicate archive path.')
seen.add(entry.filename);total+=entry.file_size
if total>MAX_EXPANDED:raise ValueError('Update archive is too large.')
if shutil.disk_usage(destination.parent).free<total+64*1024*1024:
raise OSError('Not enough free disk space for the update.')
for entry in z.infolist():
parts=PurePosixPath(entry.filename).parts[1:]
if not parts:continue
target=destination.joinpath(*parts)
if entry.is_dir():target.mkdir(parents=True,exist_ok=True);continue
target.parent.mkdir(parents=True,exist_ok=True)
with z.open(entry) as source,target.open('xb') as output:shutil.copyfileobj(source,output)
target.chmod(0o644 | ((entry.external_attr>>16)&0o111))
def download(revision,path):
request=urllib.request.Request('https://codeload.github.com/nalancer08/nxnative-easy/zip/'+revision,
headers={'User-Agent':'nxnative-Easy-Updater'})
emit('download')
with urllib.request.urlopen(request,timeout=45) as response,path.open('wb') as output:
total=int(response.headers.get('Content-Length') or 0)
received=0
while True:
block=response.read(256*1024)
if not block:break
received+=len(block)
if received>MAX_ARCHIVE:raise ValueError('Update download is too large.')
output.write(block)
emit('download',min(1,received/total) if total else None)
def validate_app(path,expected_build,expected_version):
info=plistlib.loads((path/'Contents/Info.plist').read_bytes())
if info.get('CFBundleIdentifier')!=IDENTIFIER or str(info.get('CFBundleVersion'))!=str(expected_build):
raise ValueError('The prepared app does not match the requested update.')
if info.get('CFBundleShortVersionString')!=expected_version:raise ValueError('Unexpected app version.')
if not (path/'Contents/MacOS/NxNativeEasy').is_file():raise ValueError('Missing update executable.')
subprocess.run(['/usr/bin/codesign','--verify','--deep','--strict',str(path)],check=True,capture_output=True)
def replace_bundle(staged,target):
backup=target.with_name('.'+target.name+'.previous-'+uuid.uuid4().hex[:10])
os.replace(target,backup)
try:os.replace(staged,target)
except BaseException:
os.replace(backup,target)
raise
return backup
def prepare(revision,build,target,cache):
cache.mkdir(parents=True,exist_ok=True)
source=cache/'Sources'/(revision+'-'+uuid.uuid4().hex[:8])
source.parent.mkdir(parents=True,exist_ok=True)
staging=Path(tempfile.mkdtemp(prefix='.nxnative-update-',dir=target.parent))
try:
with tempfile.TemporaryDirectory(prefix='download-',dir=cache) as temporary:
archive=Path(temporary)/'source.zip'
download(revision,archive)
emit('extract')
extract(archive,source,revision)
release=manifest(source/'desktop/release.json')
if release['build']!=build:raise ValueError('Release metadata changed; check for updates again.')
(source/'SOURCE_REVISION').write_text(revision+'\n')
emit('build')
app=staging/'nxnative Easy.app'
logs=cache/'Logs';logs.mkdir(exist_ok=True)
log=logs/(revision+'-'+uuid.uuid4().hex[:8]+'.log')
with log.open('w') as output:
subprocess.run([sys.executable,str(source/'scripts/build_desktop_app.py'),'--output',str(app)],
stdout=output,stderr=subprocess.STDOUT,check=True)
emit('verify')
validate_app(app,build,release['version'])
return app,source
except BaseException:
shutil.rmtree(staging,ignore_errors=True)
shutil.rmtree(source,ignore_errors=True)
raise
def open_app(target):
subprocess.Popen(['/usr/bin/open',str(target)],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,start_new_session=True)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--revision',required=True)
parser.add_argument('--build',required=True,type=int)
parser.add_argument('--target',required=True,type=Path)
parser.add_argument('--parent-pid',type=int)
parser.add_argument('--cache',type=Path,default=Path.home()/'Library/Application Support/nxnative Easy/Updates')
parser.add_argument('--prepare-only',action='store_true')
args=parser.parse_args()
if not re.fullmatch('[0-9a-f]{40}',args.revision) or args.build<1:raise ValueError('Invalid update request.')
target=args.target.expanduser().resolve(strict=True)
if target.suffix!='.app' or plistlib.loads((target/'Contents/Info.plist').read_bytes()).get('CFBundleIdentifier')!=IDENTIFIER:
raise ValueError('Only the running nxnative Easy app can be updated.')
if not args.prepare_only and (not args.parent_pid or args.parent_pid<=1):raise ValueError('Missing launcher process.')
try:os.setsid()
except PermissionError:pass
args.cache.mkdir(parents=True,exist_ok=True)
lock=(args.cache/('update-'+hashlib.sha256(str(target).encode()).hexdigest()[:16]+'.lock')).open('a')
try:fcntl.flock(lock,fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:raise ValueError('Another update is already running.')
staged,source=prepare(args.revision,args.build,target,args.cache)
if args.prepare_only:
emit('prepared',1,str(staged));return
emit('restart',1)
try:
deadline=time.monotonic()+30
while True:
try:os.kill(args.parent_pid,0)
except ProcessLookupError:break
if time.monotonic()>deadline:raise TimeoutError('The app did not close; the old version was preserved.')
time.sleep(0.2)
replace_bundle(staged,target)
open_app(target)
except BaseException as error:
(args.cache/'last-error.json').write_text(json.dumps({'message':str(error)}))
open_app(target)
raise
finally:
shutil.rmtree(staged.parent,ignore_errors=True)
if __name__=='__main__':
try:main()
except Exception as error:
emit('error',message=str(error));sys.exit(1)
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.4",
"build": 5,
"minimumMacOSMajor": 26
}
+27
View File
@@ -221,3 +221,30 @@ clears stale input; switching to another app clears the live drawing. Sampling
never replaces input handlers or enables background controller capture.
Snapshot-controller tests check pressure, axes, releases, disconnect and selecting
a second device. They are not physical-hardware or game compatibility validation.
## In-app source updates (1.4+)
`desktop/release.json` is the public release version, monotonically increasing
build number and minimum macOS version. The app checks GitHub's public main commit,
then reads the manifest pinned to that SHA. It checks at launch, every six hours
while open, and from the app menu. Offline automatic checks stay quiet.
The bundled `update_app.py` helper downloads only the fixed repository at that SHA,
rejects unsafe ZIP paths, symlinks, game packages and credential filenames, and
extracts to a new versioned directory under Application Support. It never pulls or
resets the existing checkout. It compiles the app in a staging directory on the
installation volume and verifies its build, version, bundle identifier and ad-hoc
signature before asking the old process to quit. After that process exits, it swaps
in the new bundle, retains a hidden backup and relaunches. A failed swap rolls back;
a failure before verification leaves the running app intact. The helper serializes
updates with a per-installation file lock. It does not sign or install iPhone games.
`--prepare-only` downloads and compiles without closing, replacing or launching an
installed app, for release validation. Pass an external `--cache` directory in tests.
Older apps must run the setup command once to receive the updater itself. Existing
prepared games require a normal rebuild to incorporate newer engine/profile code.
Profile artwork is mapped by title ID in `desktop/NxNativeEasy/game-artwork.json`.
All 70 current profiles use existing repository screenshots, including the shared
13 Sentinels image for its Asia profile; no network image search is performed.
+5 -5
View File
@@ -6,11 +6,11 @@ MIT notice and third-party license files. Our additions are the native desktop
preparation UI, persistent iOS touch-control editor and lightweight build workflow.
No endorsement by Nintendo, Apple, game publishers or upstream authors is implied.
The desktop UI uses original phone/controller artwork and generic system symbols rather than game screenshots or logos.
The README/gallery and upstream documentation still contain game screenshots;
removing artwork from the app does not remove that documentation or historical
Git objects. The code's license is not a blanket grant of rights over third-party
artwork. Publication is a separate owner decision; this check is not legal clearance.
The desktop UI uses original phone/controller icon artwork and, at the owner's
request, the existing upstream game screenshots for profile cards. The same images
remain in the README/gallery and historical Git objects. The code license does not
independently grant rights over third-party artwork; source attribution is retained.
No games or console keys are provided with those screenshots.
The current local desktop build uses Xcode's Swift compiler and explicit ad-hoc
signing (`codesign --sign - --timestamp=none`). `Signature=adhoc` and an unset
+8 -3
View File
@@ -7,6 +7,7 @@ import subprocess
import sys
import tempfile
import shutil
import json
REPO=Path(__file__).resolve().parents[1]
@@ -20,7 +21,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'),str(REPO/'desktop/NxNativeEasy/GameCatalog.swift'),str(REPO/'desktop/NxNativeEasy/ControllerMonitor.swift'),str(REPO/'desktop/NxNativeEasy/ControllerTest.swift'),'-o',str(macos/'NxNativeEasy'),
str(REPO/'desktop/NxNativeEasy/main.swift'),str(REPO/'desktop/NxNativeEasy/Localization.swift'),str(REPO/'desktop/NxNativeEasy/GameCatalog.swift'),str(REPO/'desktop/NxNativeEasy/ControllerMonitor.swift'),str(REPO/'desktop/NxNativeEasy/ControllerTest.swift'),str(REPO/'desktop/NxNativeEasy/UpdateRelease.swift'),str(REPO/'desktop/NxNativeEasy/AppUpdater.swift'),'-o',str(macos/'NxNativeEasy'),
'-framework','SwiftUI','-framework','AppKit','-framework','GameController'],check=True)
resources=contents/'Resources';resources.mkdir(exist_ok=True)
shutil.copy2(REPO/'desktop/NxNativeEasy/Assets/AppIcon.png',resources/'BrandIcon.png')
@@ -28,9 +29,13 @@ def main():
iconset=Path(temporary)/'AppIcon.iconset';iconset.mkdir()
subprocess.run(['xcrun','swift',str(REPO/'desktop/NxNativeEasy/Assets/draw_icon.swift'),str(iconset)],check=True)
subprocess.run(['iconutil','-c','icns',str(iconset),'-o',str(resources/'AppIcon.icns')],check=True)
info=dict(CFBundleIconFile='AppIcon', CFBundleIdentifier='com.nalancer08.nxnative.easy',CFBundleName='nxnative Easy',
shutil.copy2(REPO/'desktop/NxNativeEasy/update_app.py',resources/'update_app.py')
release=json.loads((REPO/'desktop/release.json').read_text())
revision=subprocess.run(['git','-C',str(REPO),'rev-parse','HEAD'],capture_output=True,text=True).stdout.strip()
if not revision and (REPO/'SOURCE_REVISION').is_file():revision=(REPO/'SOURCE_REVISION').read_text().strip()
info=dict(NxNativeSourceRevision=revision, CFBundleIconFile='AppIcon', CFBundleIdentifier='com.nalancer08.nxnative.easy',CFBundleName='nxnative Easy',
CFBundleDisplayName='nxnative Easy',CFBundleExecutable='NxNativeEasy',
CFBundlePackageType='APPL',CFBundleShortVersionString='1.3',CFBundleVersion='4',
CFBundlePackageType='APPL',CFBundleShortVersionString=release['version'],CFBundleVersion=str(release['build']),
LSMinimumSystemVersion='26.0',CFBundleDevelopmentRegion='en',CFBundleLocalizations=['en','es'],NSHighResolutionCapable=True,NxNativeSource=str(REPO))
(contents/'Info.plist').write_bytes(plistlib.dumps(info))
# Explicit ad-hoc signing: '-' never selects an Apple certificate or team.
+80
View File
@@ -0,0 +1,80 @@
"""Update archive validation and transactional replacement, without altering installed apps."""
import importlib.util
import json
from pathlib import Path
import os
import tempfile
import unittest
from unittest.mock import patch
import zipfile
ROOT=Path(__file__).resolve().parents[2]
spec=importlib.util.spec_from_file_location('desktop_update',ROOT/'desktop/NxNativeEasy/update_app.py')
update=importlib.util.module_from_spec(spec);spec.loader.exec_module(update)
REVISION='a'*40
PREFIX='nxnative-easy-'+REVISION+'/'
class DesktopUpdate(unittest.TestCase):
def test_rejects_unsafe_and_credential_archive_before_extracting(self):
for name in ['../escape','/absolute',PREFIX+'../escape',PREFIX+'prod.keys',PREFIX+'game.nsp']:
with self.subTest(name=name),tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);archive=root/'test.zip'
with zipfile.ZipFile(archive,'w') as z:z.writestr(name,b'synthetic')
with self.assertRaises(ValueError):update.extract(archive,root/'source',REVISION)
self.assertFalse((root/'source').exists())
def test_rejects_symlinks_and_wrong_revision(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);archive=root/'test.zip'
info=zipfile.ZipInfo(PREFIX+'link');info.external_attr=0o120777<<16
with zipfile.ZipFile(archive,'w') as z:z.writestr(info,'outside')
with self.assertRaises(ValueError):update.extract(archive,root/'source',REVISION)
with zipfile.ZipFile(archive,'w') as z:z.writestr('wrong-root/file','synthetic')
with self.assertRaises(ValueError):update.extract(archive,root/'source',REVISION)
def test_extracts_regular_sources_and_executable_permissions(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);archive=root/'test.zip'
info=zipfile.ZipInfo(PREFIX+'scripts/setup.sh');info.external_attr=0o100755<<16
with zipfile.ZipFile(archive,'w') as z:z.writestr(info,'#!/bin/bash\n')
update.extract(archive,root/'source',REVISION)
self.assertTrue(os.access(root/'source/scripts/setup.sh',os.X_OK))
def test_replacement_preserves_old_app_and_unrelated_data(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);old=root/'Current.app';new=root/'New.app';old.mkdir();new.mkdir()
(old/'marker').write_text('old');(new/'marker').write_text('new')
(root/'save').write_text('keep')
backup=update.replace_bundle(new,old)
self.assertEqual((old/'marker').read_text(),'new')
self.assertEqual((backup/'marker').read_text(),'old')
self.assertEqual((root/'save').read_text(),'keep')
def test_install_failure_rolls_back_old_app(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);old=root/'Current.app';new=root/'New.app';old.mkdir();new.mkdir()
(old/'marker').write_text('old');(new/'marker').write_text('new')
real_replace=os.replace
def replace(source,destination):
if source==new:raise OSError('synthetic installation failure')
return real_replace(source,destination)
with patch.object(update.os,'replace',side_effect=replace):
with self.assertRaises(OSError):update.replace_bundle(new,old)
self.assertEqual((old/'marker').read_text(),'old')
def test_invalid_release_metadata_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
path=Path(tmp)/'release.json'
for value in [{'version':'invalid','build':5},{'version':'1.4','build':True},{'version':'1.4','build':-1}]:
path.write_text(json.dumps(value))
with self.assertRaises(ValueError):update.manifest(path)
class ReleaseMetadata(unittest.TestCase):
@unittest.skipUnless(__import__('sys').platform=='darwin','requires Swift Foundation')
def test_version_selection(self):
import subprocess
with tempfile.TemporaryDirectory() as tmp:
executable=Path(tmp)/'release'
subprocess.run(['xcrun','swiftc','-parse-as-library',str(ROOT/'desktop/NxNativeEasy/UpdateRelease.swift'),
str(ROOT/'desktop/NxNativeEasy/tests/update_release_check.swift'),'-o',str(executable)],check=True)
subprocess.run([str(executable)],check=True)