Add staged in-app updates and use repository artwork for all game profiles
This commit is contained in:
1 parent
8be22c449c
commit
86793e23d6
17 files changed
+644
-27
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1.4",
|
||||
"build": 5,
|
||||
"minimumMacOSMajor": 26
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user