From 86793e23d67cdb035405996d3fa6d79acc9586a8 Mon Sep 17 00:00:00 2001 From: Erick Sanchez <7268597+nalancer08@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:04:54 -0600 Subject: [PATCH] Add staged in-app updates and use repository artwork for all game profiles --- AGENTS.md | 2 + README-es.md | 10 +- README.md | 10 +- desktop/NxNativeEasy/AppUpdater.swift | 105 ++++++++++ desktop/NxNativeEasy/GameCatalog.swift | 13 +- desktop/NxNativeEasy/Localization.swift | 17 ++ desktop/NxNativeEasy/UpdateRelease.swift | 14 ++ desktop/NxNativeEasy/game-artwork.json | 72 +++++++ desktop/NxNativeEasy/main.swift | 92 +++++++-- .../NxNativeEasy/tests/catalog_check.swift | 1 + .../tests/update_release_check.swift | 15 ++ desktop/NxNativeEasy/update_app.py | 187 ++++++++++++++++++ desktop/release.json | 5 + docs/personal-ios.md | 27 +++ docs/publication-notes.md | 10 +- scripts/build_desktop_app.py | 11 +- scripts/tests/test_desktop_updates.py | 80 ++++++++ 17 files changed, 644 insertions(+), 27 deletions(-) create mode 100644 desktop/NxNativeEasy/AppUpdater.swift create mode 100644 desktop/NxNativeEasy/UpdateRelease.swift create mode 100644 desktop/NxNativeEasy/game-artwork.json create mode 100644 desktop/NxNativeEasy/tests/update_release_check.swift create mode 100644 desktop/NxNativeEasy/update_app.py create mode 100644 desktop/release.json create mode 100644 scripts/tests/test_desktop_updates.py diff --git a/AGENTS.md b/AGENTS.md index f021b0e..470ad2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README-es.md b/README-es.md index 9d4dd53..45c8b5a 100644 --- a/README-es.md +++ b/README-es.md @@ -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 diff --git a/README.md b/README.md index ef036a6..cb1b8f4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/desktop/NxNativeEasy/AppUpdater.swift b/desktop/NxNativeEasy/AppUpdater.swift new file mode 100644 index 0000000..dab5624 --- /dev/null +++ b/desktop/NxNativeEasy/AppUpdater.swift @@ -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[.. 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) diff --git a/desktop/NxNativeEasy/Localization.swift b/desktop/NxNativeEasy/Localization.swift index e2cc017..91765bd 100644 --- a/desktop/NxNativeEasy/Localization.swift +++ b/desktop/NxNativeEasy/Localization.swift @@ -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.", diff --git a/desktop/NxNativeEasy/UpdateRelease.swift b/desktop/NxNativeEasy/UpdateRelease.swift new file mode 100644 index 0000000..ba842c8 --- /dev/null +++ b/desktop/NxNativeEasy/UpdateRelease.swift @@ -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} +} diff --git a/desktop/NxNativeEasy/game-artwork.json b/desktop/NxNativeEasy/game-artwork.json new file mode 100644 index 0000000..b22880a --- /dev/null +++ b/desktop/NxNativeEasy/game-artwork.json @@ -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" +} diff --git a/desktop/NxNativeEasy/main.swift b/desktop/NxNativeEasy/main.swift index 044c424..1e0be0a 100644 --- a/desktop/NxNativeEasy/main.swift +++ b/desktop/NxNativeEasy/main.swift @@ -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) + } + } } } diff --git a/desktop/NxNativeEasy/tests/catalog_check.swift b/desktop/NxNativeEasy/tests/catalog_check.swift index c1d31d3..ff1d787 100644 --- a/desktop/NxNativeEasy/tests/catalog_check.swift +++ b/desktop/NxNativeEasy/tests/catalog_check.swift @@ -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") } diff --git a/desktop/NxNativeEasy/tests/update_release_check.swift b/desktop/NxNativeEasy/tests/update_release_check.swift new file mode 100644 index 0000000..c5c34db --- /dev/null +++ b/desktop/NxNativeEasy/tests/update_release_check.swift @@ -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") + } +} diff --git a/desktop/NxNativeEasy/update_app.py b/desktop/NxNativeEasy/update_app.py new file mode 100644 index 0000000..ee51e7b --- /dev/null +++ b/desktop/NxNativeEasy/update_app.py @@ -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>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) diff --git a/desktop/release.json b/desktop/release.json new file mode 100644 index 0000000..59a97fc --- /dev/null +++ b/desktop/release.json @@ -0,0 +1,5 @@ +{ + "version": "1.4", + "build": 5, + "minimumMacOSMajor": 26 +} diff --git a/docs/personal-ios.md b/docs/personal-ios.md index 926c834..eec8c15 100644 --- a/docs/personal-ios.md +++ b/docs/personal-ios.md @@ -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. diff --git a/docs/publication-notes.md b/docs/publication-notes.md index 46d71a3..ba2ce61 100644 --- a/docs/publication-notes.md +++ b/docs/publication-notes.md @@ -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 diff --git a/scripts/build_desktop_app.py b/scripts/build_desktop_app.py index d3fa0ba..b9b632f 100755 --- a/scripts/build_desktop_app.py +++ b/scripts/build_desktop_app.py @@ -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. diff --git a/scripts/tests/test_desktop_updates.py b/scripts/tests/test_desktop_updates.py new file mode 100644 index 0000000..6e97f74 --- /dev/null +++ b/scripts/tests/test_desktop_updates.py @@ -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)