diff --git a/.gitignore b/.gitignore index 23d49ea..6eef001 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ exefs/ save/ Payload/ nxnative-ios.json + +nxnative-mac.json diff --git a/README-es.md b/README-es.md index 27c47ca..2ea0dac 100644 --- a/README-es.md +++ b/README-es.md @@ -2,7 +2,35 @@ [English](README.md) · **Español** -**Prepara tus juegos para iPhone desde una app de Mac. Sin comandos para el uso diario.** +**Tus propios juegos, preparados para iPhone, iPad o Mac.** + +## Empieza aquí + +**Primero:** instala [Xcode](https://apps.apple.com/app/xcode/id497799835) completo desde App Store y ábrelo una vez. **Xcode también compila los juegos**; una interfaz ya compilada no elimina ese requisito. + +**Después:** pega este único comando en Terminal: + +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/nalancer08/nxnative-easy/main/install.sh)" +``` + +Descarga nxnative Easy, instala Homebrew y las herramientas que falten, **compila tu interfaz de Mac y la abre**. Sigue las solicitudes de contraseña o confirmación que aparezcan en Terminal. No usa una cuenta de desarrollador Apple. + +**¿Ya descargaste el repositorio?** Abre **START-HERE.command** en la carpeta principal con doble clic. Hace la misma preparación; no necesitas buscar scripts dentro de `scripts/`. Si macOS bloquea ese archivo, usa el comando de arriba. + +Cuando abra la app: + +1. Elige **iPhone / iPad** o **Mac**. +2. Selecciona la **carpeta de tu juego** y la **carpeta de tus llaves**. +3. Pulsa **Preparar juego**. + +**Las siguientes veces:** abre **nxnative Easy.app** en la carpeta **Aplicaciones de tu usuario**. La preparación conserva su propia copia del código; puedes borrar el ZIP y la carpeta que descargaste. + +**Mac:** crea una `.app` del juego; pulsa **Abrir juego**. No necesitas Sideloadly ni un iPhone. Las partidas quedan en `~/Library/Application Support/nxnative//`. + +**iPhone / iPad:** crea una IPA ligera sin firmar. Instálala con Sideloadly y copia los datos del juego como te indica la app. + +**Necesitas:** Mac con Apple silicon (M1 o posterior), macOS 26+ y Xcode 26+. El comando instala las otras herramientas. Los juegos y las llaves los aportas tú. La primera compilación puede tardar. ![Nuestra app para preparar juegos](docs/images/nxnative-easy-es.png) @@ -16,22 +44,17 @@ Partimos de [seedds/nxnative](https://github.com/seedds/nxnative): el motor, los - **Llaves del usuario:** debes seleccionar la carpeta de tus propias `prod.keys`. No incluimos, descargamos ni distribuimos llaves o juegos. - **Preparación local:** sin cuenta Apple, sin certificados de empresa y sin servicio que reciba tus archivos. -Entregamos **código, una interfaz de preparación y herramientas de compilación**. El resultado es una IPA sin firmar; tú la firmas e instalas con Sideloadly. No ofrecemos juegos, llaves, firma de Apple ni compatibilidad garantizada con cualquier juego. +Entregamos **código, una interfaz de preparación y herramientas de compilación**. El resultado es una app local para Mac o una IPA para iPhone/iPad sin firmar, que firmas e instalas con Sideloadly. No ofrecemos juegos, llaves, firma de Apple ni compatibilidad garantizada con cualquier juego. ## Idioma 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. -## Abre la app y elige tu juego +## ¿Qué pasa al pulsar Preparar juego? -1. Abre **nxnative.app**. -2. Elige la **Carpeta de tu juego**, selecciona tu **Carpeta de llaves** una vez y pulsa **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. -La app prepara las herramientas, compila y deja lista la **IPA ligera**. Verás el avance en la ventana. Al terminar aparecen los dos pasos para instalar. **Enviar a Sideloadly** abre allí la IPA preparada; si Sideloadly no está instalado, el botón ofrece descargarlo. - -**Una vez creada:** abre `nxnative.app`. Recuerda tu última carpeta y permite volver a mostrar la IPA ya preparada. - -**En otro Mac:** después de descargar este repositorio, abre [`scripts/Abrir-interfaz.command`](scripts/Abrir-interfaz.command) con doble clic. Crea y abre la app local. La primera vez necesitas Xcode completo y Homebrew instalados; si hacen falta tus llaves, te pedirá elegir la carpeta donde guardas tu propio `prod.keys`. Mantén cada juego fuera del repositorio, en una carpeta con su base y actualizaciones. +La preparación conserva una copia separada del código y respeta los cambios locales. Para desarrollo sigue disponible [`scripts/Abrir-interfaz.command`](scripts/Abrir-interfaz.command). ## Cómo se compila la app de Mac diff --git a/README.md b/README.md index 6830de8..4082723 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,35 @@ **English** · [Español](README-es.md) -**Prepare your games for iPhone with a native Mac app. No commands for everyday use.** +**Your own games, prepared for iPhone, iPad or Mac.** + +## Start here + +**First:** install full [Xcode](https://apps.apple.com/app/xcode/id497799835) from the Mac App Store and open it once. **Xcode compiles the games too**, so a prebuilt launcher cannot remove this requirement. + +**Then:** paste this single command into Terminal: + +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/nalancer08/nxnative-easy/main/install.sh)" +``` + +It downloads nxnative Easy, installs Homebrew and the missing build tools, **compiles your own Mac interface and opens it**. Follow any password or confirmation prompts in Terminal. No Apple developer account is used. + +**Already downloaded this repository?** Double-click **START-HERE.command** in its top-level folder. It performs the same setup; you do not need to find a script under `scripts/`. If macOS blocks that file, use the Terminal command above. + +When the app opens: + +1. Choose **iPhone / iPad** or **Mac**. +2. Choose your own **game folder** and **keys folder**. +3. Click **Prepare game**. + +**After setup:** open **nxnative Easy.app** in your home **Applications** folder. The setup keeps its source checkout separately, so you can delete the downloaded ZIP and folder. + +**Mac:** creates a game `.app`; click **Open game**. No Sideloadly or iPhone needed. Saves stay in `~/Library/Application Support/nxnative/<title ID>/`. + +**iPhone / iPad:** creates an unsigned lightweight IPA; install it with Sideloadly, then copy the game data as shown in the app. + +**Required:** an Apple silicon Mac (M1 or newer), macOS 26+ and Xcode 26+. The command installs the other tools. You must provide your own games and keys. Compilation can take time, especially for the first game. ![The nxnative Easy desktop app](docs/images/nxnative-easy.png) @@ -16,22 +44,17 @@ Built on [seedds/nxnative](https://github.com/seedds/nxnative). The original pro - **Your own keys:** explicitly select the external folder containing your `prod.keys`. No games or keys are included, downloaded or distributed. - **Local preparation:** no Apple account or company certificate is used to build the desktop app. -We provide **source code, a preparation interface and build tools**. The output is an unsigned IPA that you sign and install yourself with Sideloadly. Games, console keys, Apple signing and universal game compatibility are not provided. +We provide **source code, a preparation interface and build tools**. The output is a local Mac game app, or an unsigned iPhone/iPad IPA that you sign and install yourself with Sideloadly. Games, console keys, Apple signing and universal game compatibility are not provided. ## Language 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. -## Open the app and choose your game +## What happens after Prepare game? -1. Open **nxnative.app**. -2. Choose **Your game folder**, select your **Keys folder** once, then click **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. -The app prepares the tools, builds the game app and creates a **lightweight IPA**. Progress stays in the window. When finished, the app shows the two installation steps. **Send to Sideloadly** opens the prepared IPA in Sideloadly; if it is not installed, the button offers its download. - -**First-time setup:** download or clone this repository, then double-click the local [`scripts/Abrir-interfaz.command`](scripts/Abrir-interfaz.command) file in Finder. It creates and opens the Mac app. Full Xcode and Homebrew must already be installed. Select the folder containing your own `prod.keys` when asked. Keep one game per folder, with the base game and its updates, outside this source checkout. - -**Already set up:** open the existing `nxnative.app`. It remembers your last game folder and can reveal a previously prepared IPA. +Setup keeps a separate source checkout and preserves local edits. Developers can still use [`scripts/Abrir-interfaz.command`](scripts/Abrir-interfaz.command). ## How the Mac app is built diff --git a/START-HERE.command b/START-HERE.command new file mode 100755 index 0000000..a21cccc --- /dev/null +++ b/START-HERE.command @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +nxe_here=$(cd "$(dirname "$0")" && pwd -P) +/bin/bash "$nxe_here/install.sh" diff --git a/START-HERE.txt b/START-HERE.txt new file mode 100644 index 0000000..f917572 --- /dev/null +++ b/START-HERE.txt @@ -0,0 +1,17 @@ +START HERE / EMPIEZA AQUÍ + +1. Install Xcode from the Mac App Store and open it once. + Instala Xcode desde App Store y ábrelo una vez. + +2. Double-click START-HERE.command in this folder. + Abre START-HERE.command en esta carpeta con doble clic. + +It downloads missing tools, builds your own app and opens it. +Descarga las herramientas que faltan, compila tu app y la abre. + +3. Choose iPhone / iPad or Mac, then your own game and keys folders. + Elige iPhone / iPad o Mac y las carpetas de tu juego y tus llaves. + +App location / Dónde queda: ~/Applications/nxnative Easy.app +Requires / Necesitas: Apple silicon (M1+), macOS 26+, full Xcode. +No games or keys included / No incluye juegos ni llaves. diff --git a/desktop/NxNativeEasy/Localization.swift b/desktop/NxNativeEasy/Localization.swift index 2b3cf74..977ebcf 100644 --- a/desktop/NxNativeEasy/Localization.swift +++ b/desktop/NxNativeEasy/Localization.swift @@ -12,6 +12,18 @@ enum EasyLanguage: String, CaseIterable { struct EasyStrings { static let english: [String:String] = [ + "¿Dónde quieres jugar?": "Where do you want to play?", + "Elige un dispositivo": "Choose a device", + "Elige dónde quieres jugar y añade tus archivos.": "Choose where to play, then add your own files.", + "Creamos una app para jugar en tu Mac.": "We create an app to play on your Mac.", + "Terminó, pero no encontramos la app.": "Finished, but the app could not be found.", + "Tu juego está listo para Mac.": "Your game is ready for Mac.", + "Listo. Juega en tu Mac.": "Ready. Play on your Mac.", + "Listo. Instálalo en tu iPhone / iPad.": "Ready. Install it on your iPhone / iPad.", + "Abrir juego": "Open game", + "Compilando la app para Mac…": "Building the Mac app…", + "Mostrar app": "Show app", + "Tus partidas se guardan en este Mac y se conservan al crear otra versión.": "Saves stay on this Mac and are preserved when you build a new version.", "Juegos con soporte en la herramienta": "Supported game profiles", "No incluye ni descarga juegos. Debes añadir tus propios archivos.": "No games are included or downloaded. You must add your own files.", "Carpeta de tu juego": "Your game folder", diff --git a/desktop/NxNativeEasy/main.swift b/desktop/NxNativeEasy/main.swift index 2dc24d3..d7bae35 100644 --- a/desktop/NxNativeEasy/main.swift +++ b/desktop/NxNativeEasy/main.swift @@ -8,6 +8,10 @@ import Darwin didSet {language.save()} } func text(_ source: String) -> String {EasyStrings.text(source, language:language)} + @Published var target = "" { + didSet { output=nil;assets=nil;failed=false;restoreOutput() } + } + var isMac: Bool {target == "mac"} @Published var folder: URL? @Published var keysFolder: URL? @Published var running = false @@ -25,8 +29,8 @@ import Darwin private var cancelled = false let repo: URL init() { - let configured = Bundle.main.object(forInfoDictionaryKey:"NxNativeSource") as? String ?? "" - repo = URL(fileURLWithPath:configured) + let configured=Bundle.main.object(forInfoDictionaryKey:"NxNativeSource") as? String ?? "" + repo=URL(fileURLWithPath:configured) refreshCatalog() if let path=UserDefaults.standard.string(forKey:"keysDirectory"), FileManager.default.fileExists(atPath:path+"/prod.keys") { keysFolder=URL(fileURLWithPath:path) @@ -81,7 +85,15 @@ import Darwin failed=false;phase="Tus llaves están seleccionadas.";detail="Sólo guardamos la ruta en este Mac, nunca su contenido." } func restoreOutput() { - guard let folder, + if isMac { + guard let folder, + let data=try? Data(contentsOf:folder.appendingPathComponent("nxnative-mac.json")), + let record=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any], + let path=record["app_path"] as? String, + FileManager.default.fileExists(atPath:path+"/Contents/MacOS/dmhost") else {return} + output=URL(fileURLWithPath:path);return + } + guard target == "ios", let folder, let data=try? Data(contentsOf:folder.appendingPathComponent("nxnative-ios.json")), let record=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any], let path=record["light_dir"] as? String else {return} @@ -90,7 +102,7 @@ import Darwin let ipa=files.first(where:{$0.pathExtension=="ipa"}) { output=ipa;assets=directory.appendingPathComponent("romfs") } } func build() { - guard let folder, !running else {return} + guard let folder, !running, !target.isEmpty else {return} let script=repo.appendingPathComponent("scripts/prepare_ios.sh") guard FileManager.default.fileExists(atPath:script.path) else { phase="No encontramos el código de nxnative.";detail="Vuelve a crear la app desde el repositorio con Abrir-interfaz.command.";failed=true;return @@ -99,7 +111,7 @@ import Darwin guard let keysFolder else {return} let task=Process();let pipe=Pipe() task.executableURL=URL(fileURLWithPath:"/usr/bin/python3") - var arguments=[repo.appendingPathComponent("desktop/NxNativeEasy/build_session.py").path,script.path,folder.path] + var arguments=[repo.appendingPathComponent("desktop/NxNativeEasy/build_session.py").path,script.path,"--target",target,folder.path] arguments += ["--keys-folder",keysFolder.path] task.arguments=arguments;task.currentDirectoryURL=repo task.standardOutput=pipe;task.standardError=pipe @@ -124,7 +136,7 @@ import Darwin } else if status==0 { self.restoreOutput() self.failed=self.output==nil - self.phase=self.output == nil ? "Terminó, pero no encontramos la IPA." : "Tu juego está listo para Sideloadly." + self.phase=self.output == nil ? "Terminó, pero no encontramos la app." : (self.isMac ? "Tu juego está listo para Mac." : "Tu juego está listo para Sideloadly.") self.detail=self.output == nil ? "Usa Ver error para revisar lo que ocurrió." : "Abre la IPA y arrástrala a Sideloadly. La firma se hace con tu cuenta personal." } else { self.failed=true;self.phase="No pudimos preparar el juego." @@ -150,7 +162,7 @@ import Darwin guard running else {return} let lower=text.lowercased() if lower.contains("pack ipa") || lower.contains("source ipa:") || lower.contains("procesados") || lower.contains("processed ") {phase="Creando tu IPA ligera…"} - else if lower.contains("xcodebuild") || lower.contains("building the ipa") {phase="Compilando la app para iPhone…"} + else if lower.contains("xcodebuild") || lower.contains("building the ipa") {phase=isMac ? "Compilando la app para Mac…" : "Compilando la app para iPhone…"} else if lower.contains("shader") {phase="Preparando los gráficos del juego…"} else if lower.contains("extract") {phase="Preparando los archivos del juego…"} } @@ -160,6 +172,7 @@ import Darwin if kill(-process.processIdentifier,SIGTERM) != 0 {process.terminate()} phase="Cancelando…" } + func openMacGame() {if let output, isMac {NSWorkspace.shared.open(output)}} func revealIPA() {if let output {NSWorkspace.shared.activateFileViewerSelecting([output])}} func revealAssets() {if let assets {NSWorkspace.shared.activateFileViewerSelecting([assets])}} var sideloadlyURL: URL? {NSWorkspace.shared.urlForApplication(withBundleIdentifier:"io.sideloadly.sideloadly")} @@ -200,6 +213,8 @@ struct SupportCarousel: View { }.offset(x:-offset).frame(width:geometry.size.width,alignment:.leading) } }.frame(height:103).clipped() + .accessibilityElement(children:.ignore) + .accessibilityLabel(games.map(\.name).joined(separator:", ")) .onHover { hovering in if hovering { pausedAt = Date() } else if let pause = pausedAt { @@ -222,7 +237,7 @@ struct CompactWindow: NSViewRepresentable { adjusted=true DispatchQueue.main.async { let width=max(760,window.contentLayoutRect.width) - window.setContentSize(NSSize(width:width,height:600)) + window.setContentSize(NSSize(width:width,height:650)) } } } @@ -259,7 +274,7 @@ struct ContentView: View { } VStack(alignment:.leading,spacing:6) { Text(model.text("Prepara tu juego.")).font(.system(size:32,weight:.bold,design:.rounded)).foregroundStyle(ink) - Text(model.text("Creamos la IPA. La instalas en tu iPhone con Sideloadly.")) + Text(model.text(model.isMac ? "Creamos una app para jugar en tu Mac." : "Elige dónde quieres jugar y añade tus archivos.")) .font(.system(size:14)).foregroundStyle(.secondary) } VStack(alignment:.leading,spacing:10) { @@ -277,6 +292,11 @@ struct ContentView: View { SupportCarousel(games:model.catalog) } VStack(alignment:.leading,spacing:18) { + Picker(model.text("¿Dónde quieres jugar?"),selection:$model.target) { + Text(model.text("Elige un dispositivo")).tag("") + Text("iPhone / iPad").tag("ios") + Text("Mac").tag("mac") + }.pickerStyle(.menu).disabled(model.running) HStack(spacing:12) { Image(systemName:"folder.fill").font(.system(size:24)).foregroundStyle(lime) VStack(alignment:.leading,spacing:4) { @@ -318,13 +338,24 @@ struct ContentView: View { Spacer() Image(systemName:"arrow.right") }.font(.system(size:15,weight:.bold)).padding(15).foregroundStyle(Color.black) - .background(model.folder == nil ? Color.gray : lime,in:RoundedRectangle(cornerRadius:10)) - }.buttonStyle(.plain).disabled(model.folder == nil) + .background(model.folder == nil || model.target.isEmpty ? Color.gray : lime,in:RoundedRectangle(cornerRadius:10)) + }.buttonStyle(.plain).disabled(model.folder == nil || model.target.isEmpty) } }.padding(22).background(Color.white.opacity(0.045),in:RoundedRectangle(cornerRadius:16)) if model.output != nil && !model.running && !model.failed { VStack(alignment:.leading,spacing:16) { - Label(model.text("Listo. Instálalo en tu iPhone."),systemImage:"checkmark.circle.fill") + if model.isMac { + Label(model.text("Listo. Juega en tu Mac."),systemImage:"checkmark.circle.fill") + .font(.system(size:20,weight:.semibold)).foregroundStyle(lime) + HStack { + Button(model.text("Abrir juego")){model.openMacGame()} + .buttonStyle(.borderedProminent).tint(lime).foregroundStyle(.black) + Button(model.text("Mostrar app")){model.revealIPA()} + } + Text(model.text("Tus partidas se guardan en este Mac y se conservan al crear otra versión.")) + .font(.system(size:12)).foregroundStyle(.secondary) + } else { + Label(model.text("Listo. Instálalo en tu iPhone / iPad."),systemImage:"checkmark.circle.fill") .font(.system(size:20,weight:.semibold)).foregroundStyle(lime) Text(model.text("1. Envía la IPA a Sideloadly y pulsa Start con tu cuenta personal.")) .font(.system(size:13)) @@ -337,6 +368,7 @@ struct ContentView: View { Button(model.text("Mostrar datos del juego")){model.revealAssets()} 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) }.padding(22).frame(maxWidth:.infinity,alignment:.leading) .background(lime.opacity(0.045),in:RoundedRectangle(cornerRadius:16)) @@ -424,6 +456,6 @@ struct GameCatalogView: View { NSApplication.shared.applicationIconImage=AppArtwork.icon } } - .defaultSize(width:820,height:600) + .defaultSize(width:820,height:650) } } diff --git a/docs/images/nxnative-easy-es.png b/docs/images/nxnative-easy-es.png index 7619018..d925011 100644 Binary files a/docs/images/nxnative-easy-es.png and b/docs/images/nxnative-easy-es.png differ diff --git a/docs/images/nxnative-easy.png b/docs/images/nxnative-easy.png index cd8cb1e..0df176c 100644 Binary files a/docs/images/nxnative-easy.png and b/docs/images/nxnative-easy.png differ diff --git a/docs/personal-ios.md b/docs/personal-ios.md index b3509bb..9f3a065 100644 --- a/docs/personal-ios.md +++ b/docs/personal-ios.md @@ -178,3 +178,18 @@ it does not click Start, select an account, sign or install on a phone. If Sidel is missing, the action is labelled Get Sideloadly and opens its official website. The normal build workflow still requires Xcode/Homebrew first and installs other missing dependencies. No Apple identity is selected by the desktop compiler. + +## One-command setup and destination selection + +`install.sh` installs missing Homebrew/build tools, prepares an isolated Python +runtime, compiles the desktop app locally and opens it. Full Xcode must already be +installed and opened once; the script checks this before downloading anything. +The install uses an independent source checkout and never resets local changes. +`--check` reports prerequisite status without installations or network access. + +Users must explicitly choose iPhone/iPad or Mac before preparing a game. The iOS +route preserves the lightweight IPA workflow and `nxnative-ios.json`. The Mac route +uses the upstream `--platform mac-app` packer with explicit ad-hoc signing, a unique +output path per build and a separate `nxnative-mac.json` output record. It uses the +upstream per-title save location without replacing iOS state. Apple silicon is +required. Successful packaging is not a new claim of gameplay verification. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..141d194 --- /dev/null +++ b/install.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Local, account-free desktop setup. Never modifies game data or resets a checkout. +set -euo pipefail +nxe_check=0 +case "${1:-}" in --check) nxe_check=1;; '') ;; *) echo 'Usage: bash install.sh [--check]' >&2; exit 2;; esac +[ "$(uname -s)" = Darwin ] || { echo 'This app requires macOS.' >&2; exit 1; } +[ "$(uname -m)" = arm64 ] || { echo 'An Apple silicon Mac (M1 or newer) is required.' >&2; exit 1; } +nxe_os=$(/usr/bin/sw_vers -productVersion) +[ "${nxe_os%%.*}" -ge 26 ] || { echo 'Update this Mac to macOS 26 or newer first.' >&2; exit 1; } +nxe_developer=${DEVELOPER_DIR:-$(/usr/bin/xcode-select -p 2>/dev/null || true)} +if [ ! -d "$nxe_developer/Platforms/MacOSX.platform" ]; then nxe_developer=/Applications/Xcode.app/Contents/Developer; fi +if [ ! -d "$nxe_developer/Platforms/MacOSX.platform" ]; then + echo 'First install full Xcode from the Mac App Store and open it once.' + echo 'Primero instala Xcode completo desde App Store y ábrelo una vez.' + echo 'https://apps.apple.com/app/xcode/id497799835' + echo 'Then run this same command again / Después repite este mismo comando.' + exit 1 +fi +export DEVELOPER_DIR="$nxe_developer" +/usr/bin/xcodebuild -version >/dev/null || { echo 'Open Xcode and finish its first-time setup, then retry.' >&2; exit 1; } +nxe_sdk=$(/usr/bin/xcrun --sdk macosx --show-sdk-version) +[ "${nxe_sdk%%.*}" -ge 26 ] || { echo 'Install Xcode 26 or newer, open it once and retry.' >&2; exit 1; } +nxe_brew=$(command -v brew || true) +if [ -z "$nxe_brew" ] && [ -x /opt/homebrew/bin/brew ]; then nxe_brew=/opt/homebrew/bin/brew; fi +if [ "$nxe_check" -eq 1 ]; then + echo 'Apple silicon, macOS and Xcode: ready.' + if [ -n "$nxe_brew" ]; then echo 'Homebrew: ready.'; else echo 'Homebrew: will be installed by setup.'; fi + exit 0 +fi +if [ -z "$nxe_brew" ]; then + echo 'Installing Homebrew / Instalando Homebrew. It may request your Mac password.' + /bin/bash -c "$(/usr/bin/curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + nxe_brew=/opt/homebrew/bin/brew +fi +[ -x "$nxe_brew" ] || { echo 'Homebrew installation did not complete; retry setup.' >&2; exit 1; } +export PATH="$("$nxe_brew" --prefix)/bin:$PATH" +nxe_base=${NXNATIVE_INSTALL_ROOT:-"$HOME/Library/Application Support/nxnative Easy"} +nxe_source=${NXNATIVE_SOURCE_DIR:-"$nxe_base/Checkout"} +nxe_tools=${NXNATIVE_TOOLS_DIR:-"$nxe_base/Tools"} +nxe_app=${NXNATIVE_APP_PATH:-"$HOME/Applications/nxnative Easy.app"} +mkdir -p "$nxe_base" "$nxe_tools" "$(dirname "$nxe_app")" +if [ -n "${NXNATIVE_SOURCE_DIR:-}" ]; then + [ -f "$nxe_source/scripts/build_desktop_app.py" ] || { echo 'Invalid source checkout.' >&2; exit 1; } +elif [ ! -e "$nxe_source" ]; then + /usr/bin/git clone --depth 1 https://github.com/nalancer08/nxnative-easy.git "$nxe_source" +elif [ -d "$nxe_source/.git" ]; then + nxe_remote=$(/usr/bin/git -C "$nxe_source" remote get-url origin) + [ "$nxe_remote" = https://github.com/nalancer08/nxnative-easy.git ] || { echo 'Unexpected checkout origin; preserved without changes.' >&2; exit 1; } + if [ -z "$(/usr/bin/git -C "$nxe_source" status --porcelain)" ]; then + /usr/bin/git -C "$nxe_source" pull --ff-only + else + echo 'Local edits preserved: building your existing checkout without updating it.' + fi +else + echo 'The setup folder already contains other files; preserved without changes.' >&2; exit 1 +fi +echo 'Preparing tools / Preparando herramientas…' +/bin/bash "$nxe_source/scripts/prepare_ios.sh" --target mac --setup-only "$nxe_tools" +echo 'Building your Mac app / Compilando tu app de Mac…' +"$nxe_tools/nxnative-tools/venv/bin/python" "$nxe_source/scripts/build_desktop_app.py" --output "$nxe_app" +echo "Ready / Lista: $nxe_app" +if [ "${NXNATIVE_NO_OPEN:-0}" != 1 ]; then /usr/bin/open "$nxe_app"; fi diff --git a/scripts/build_desktop_app.py b/scripts/build_desktop_app.py index 43d8cbd..d19212e 100755 --- a/scripts/build_desktop_app.py +++ b/scripts/build_desktop_app.py @@ -30,7 +30,7 @@ def main(): 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', CFBundleDisplayName='nxnative Easy',CFBundleExecutable='NxNativeEasy', - CFBundlePackageType='APPL',CFBundleShortVersionString='1.0',CFBundleVersion='1', + CFBundlePackageType='APPL',CFBundleShortVersionString='1.1',CFBundleVersion='2', 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/build_ios.py b/scripts/build_ios.py index 4f07d96..a49cfd5 100755 --- a/scripts/build_ios.py +++ b/scripts/build_ios.py @@ -151,7 +151,7 @@ def main(argv=None): if settings[key]: command += ['--'+key.replace('_','-'), settings[key]] command += ['--show-fps' if settings['show_fps'] else '--no-show-fps'] if args.package: command += ['--package',args.package] - revision = subprocess.run(['git','-C',str(REPO),'rev-parse','HEAD'],capture_output=True,text=True,check=True).stdout.strip() + revision = subprocess.run(['git','-C',str(REPO),'rev-parse','HEAD'],capture_output=True,text=True).stdout.strip() or 'downloaded-source' print('Source checkout:',REPO,flush=True) print('Source revision:',revision,flush=True) print('Output:',output,flush=True) diff --git a/scripts/build_mac.py b/scripts/build_mac.py new file mode 100644 index 0000000..9d7e8c8 --- /dev/null +++ b/scripts/build_mac.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Prepare a self-contained Mac game app from the user's own files.""" +import argparse +from datetime import datetime +import json +from pathlib import Path +import platform +import sys +from build_ios import REPO, build_lock, load_builder, select_keys + + +def main(argv=None): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('game_dir',type=Path) + parser.add_argument('--keys-folder',type=Path,required=True) + parser.add_argument('--dry-run',action='store_true') + args=parser.parse_args(argv) + game=args.game_dir.expanduser().resolve(strict=True) + if not game.is_dir() or game==REPO or REPO in game.parents: + raise ValueError('Keep game folders outside the source repository.') + packages=[p for p in game.iterdir() if p.is_file() and p.suffix.lower() in {'.nsp','.xci','.nsz','.xcz'}] + if not packages: raise ValueError('No NSP/XCI/NSZ/XCZ files directly in this game folder.') + output=game/('nxnative-mac-'+datetime.now().strftime('%Y%m%d-%H%M%S-%f')+'.app') + command=[str(game),'--platform','mac-app','--mac-sign','-','--team-id','0000000000', + '--progress','lines','-o',str(output)] + if args.dry_run: + print('Mac command:',command) + return 0 + if sys.platform!='darwin' or platform.machine()!='arm64': + raise ValueError('Mac games require an Apple silicon Mac.') + args.keys=None + keys=select_keys(args) + with build_lock(): + if any(p.suffix.lower() in {'.nsz','.xcz'} for p in packages): + import subprocess + subprocess.run([sys.executable,'-m','pip','install','nsz'],check=True) + code=load_builder().cli(command+['-k',str(keys)]) + if code: return code + if not (output/'Contents/MacOS/dmhost').is_file(): + raise ValueError('Build finished without a Mac game app.') + record={'schema':1,'app_path':str(output)} + state=game/'nxnative-mac.json' + temporary=state.with_suffix('.json.tmp') + temporary.write_text(json.dumps(record,indent=2)+'\n') + temporary.replace(state) + print('Mac app:',output,flush=True) + return 0 + +if __name__=='__main__': + try: sys.exit(main()) + except (ValueError,OSError) as error: + print('Error:',error,file=sys.stderr);sys.exit(1) diff --git a/scripts/prepare_ios.sh b/scripts/prepare_ios.sh index 714b18d..1fa22b4 100755 --- a/scripts/prepare_ios.sh +++ b/scripts/prepare_ios.sh @@ -5,10 +5,10 @@ set -euo pipefail nxi_repo=$(cd "$(dirname "$0")/.." && pwd -P) if [ "${1:-}" = --help ] || [ "${1:-}" = -h ]; then cat <<'HELP' -Usage: bash scripts/prepare_ios.sh [--setup-only] [game-folder] [build options] +Usage: bash scripts/prepare_ios.sh [--target ios|mac] [--setup-only] [game-folder] [build options] No folder: opens the macOS folder picker. Installs missing tools, creates a Python environment beside the game, and builds -an unsigned lightweight IPA using THIS checkout, including local control edits. +an unsigned lightweight IPA or Mac game app using THIS checkout. Build options: --keys-folder FOLDER (your own prod.keys) --keys PATH --language es-419 --bundle-id ID --app-name NAME --package v327680 --gpu none --full-only --app-only --dry-run Examples: @@ -18,6 +18,9 @@ Run scripts/build_ios.py --help for build details. HELP exit 0 fi +nxi_target=ios +if [ "${1:-}" = --target ]; then nxi_target=${2:-}; shift 2; fi +case "$nxi_target" in ios|mac) ;; *) echo 'Choose ios or mac.' >&2; exit 1;; esac nxi_setup=0 if [ "${1:-}" = --setup-only ]; then nxi_setup=1; shift; fi [ "$(uname -s)" = Darwin ] || { echo 'macOS is required.' >&2; exit 1; } @@ -34,17 +37,23 @@ if [ ! -d "$nxi_developer/Platforms/iPhoneOS.platform" ]; then nxi_developer=/Ap [ -d "$nxi_developer/Platforms/iPhoneOS.platform" ] || { echo 'Install and open full Xcode first.' >&2; exit 1; } export DEVELOPER_DIR="$nxi_developer" /usr/bin/xcodebuild -version -nxi_sdk=$(/usr/bin/xcrun --sdk iphoneos --show-sdk-version) -[ "${nxi_sdk%%.*}" -ge 26 ] || { echo 'iOS SDK 26 or newer is required.' >&2; exit 1; } +nxi_sdk_name=iphoneos +if [ "$nxi_target" = mac ]; then + [ "$(uname -m)" = arm64 ] || { echo 'Mac games require an Apple silicon Mac.' >&2; exit 1; } + nxi_sdk_name=macosx +else + nxi_sdk=$(/usr/bin/xcrun --sdk iphoneos --show-sdk-version) + [ "${nxi_sdk%%.*}" -ge 26 ] || { echo 'iOS SDK 26 or newer is required.' >&2; exit 1; } +fi nxi_brew=$(command -v brew || true) if [ -z "$nxi_brew" ] && [ -x /opt/homebrew/bin/brew ]; then nxi_brew=/opt/homebrew/bin/brew; fi if [ -z "$nxi_brew" ] && [ -x /usr/local/bin/brew ]; then nxi_brew=/usr/local/bin/brew; fi [ -n "$nxi_brew" ] || { echo 'Install Homebrew from https://brew.sh first.' >&2; exit 1; } nxi_prefix=$("$nxi_brew" --prefix) export PATH="$nxi_prefix/bin:$nxi_prefix/sbin:/usr/local/share/dotnet:$PATH" -if ! /usr/bin/xcrun --sdk iphoneos metal --version; then +if ! /usr/bin/xcrun --sdk "$nxi_sdk_name" metal --version; then /usr/bin/xcodebuild -downloadComponent MetalToolchain - /usr/bin/xcrun --sdk iphoneos metal --version + /usr/bin/xcrun --sdk "$nxi_sdk_name" metal --version fi for nxi_formula in python@3.13 xcodegen zstd spirv-cross glslang; do if ! "$nxi_brew" list --versions "$nxi_formula" >/dev/null 2>&1; then "$nxi_brew" install "$nxi_formula"; fi @@ -65,4 +74,6 @@ if [ "$nxi_setup" -eq 1 ]; then echo "Ready. Source checkout preserved: $nxi_repo" exit 0 fi -exec "$nxi_venv/bin/python" "$nxi_repo/scripts/build_ios.py" "$nxi_game" "$@" +nxi_builder=build_ios.py +if [ "$nxi_target" = mac ]; then nxi_builder=build_mac.py; fi +exec "$nxi_venv/bin/python" "$nxi_repo/scripts/$nxi_builder" "$nxi_game" "$@" diff --git a/scripts/tests/test_mac_pipeline.py b/scripts/tests/test_mac_pipeline.py new file mode 100644 index 0000000..32458b0 --- /dev/null +++ b/scripts/tests/test_mac_pipeline.py @@ -0,0 +1,39 @@ +"""Check destination isolation and successful Mac output discovery without a game.""" +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +sys.path.insert(0,str(Path(__file__).resolve().parents[1])) +import build_mac + +class MacPipeline(unittest.TestCase): + def test_mac_build_uses_adhoc_and_preserves_ios_state(self): + with tempfile.TemporaryDirectory() as temp: + root=Path(temp);(root/'base.nsp').write_bytes(b'synthetic') + (root/'prod.keys').write_bytes(b'synthetic') + (root/'nxnative-ios.json').write_text('{"existing":"preserve"}') + calls=[] + class Builder: + def cli(self,args): + calls.append(args) + host=Path(args[args.index('-o')+1])/'Contents/MacOS/dmhost' + host.parent.mkdir(parents=True);host.write_bytes(b'synthetic') + return 0 + with patch.object(build_mac,'load_builder',return_value=Builder()),patch.object(build_mac.platform,'machine',return_value='arm64'): + self.assertEqual(build_mac.main([temp,'--keys-folder',temp]),0) + self.assertEqual(calls[0][calls[0].index('--platform')+1],'mac-app') + self.assertEqual(calls[0][calls[0].index('--mac-sign')+1],'-') + self.assertTrue(Path(json.loads((root/'nxnative-mac.json').read_text())['app_path']).is_dir()) + self.assertEqual((root/'nxnative-ios.json').read_text(),'{"existing":"preserve"}') + + def test_failure_leaves_previous_mac_record(self): + with tempfile.TemporaryDirectory() as temp: + root=Path(temp);(root/'base.nsp').write_bytes(b'synthetic');(root/'prod.keys').write_bytes(b'synthetic') + (root/'nxnative-mac.json').write_text('{"app_path":"old"}') + class Builder: + def cli(self,args):return 1 + with patch.object(build_mac,'load_builder',return_value=Builder()),patch.object(build_mac.platform,'machine',return_value='arm64'): + self.assertEqual(build_mac.main([temp,'--keys-folder',temp]),1) + self.assertEqual((root/'nxnative-mac.json').read_text(),'{"app_path":"old"}')