Files
nxnative-easy/desktop/NxNativeEasy/main.swift
T

640 lines
43 KiB
Swift

import SwiftUI
import Combine
import AppKit
import Foundation
import Darwin
@MainActor final class BuildModel: ObservableObject {
@Published var language: EasyLanguage = EasyLanguage.load() {
didSet {language.save()}
}
func text(_ source: String) -> String {EasyStrings.text(source, language:language)}
@Published var target = "" {
didSet { output=nil;assets=nil;failed=false;restoreOutput() }
}
@Published var splitIOS=(UserDefaults.standard.object(forKey:"iosSplitGameData") as? Bool ?? true) {didSet {UserDefaults.standard.set(splitIOS,forKey:"iosSplitGameData");output=nil;assets=nil;restoreOutput()}}
var isMac: Bool {target == "mac"}
@Published var folder: URL?
@Published var keysFolder: URL?
@Published var running = false
@Published var phase = "Tu próximo juego empieza aquí."
@Published var detail = "Elige su carpeta. Nosotros preparamos la IPA ligera."
@Published var transcript = ""
@Published var activity=BuildActivity()
@Published var startedAt:Date?
@Published var lastOutputAt:Date?
@Published var logURL:URL?
@Published var previous:PreviousPreparation?
@Published var output: URL?
@Published var assets: URL?
@Published var failed = false
@Published var showDetails = false
@Published var showCatalog = false
@Published var showAnalysis = false
@Published var showSaves = false
@Published var showSideload = false
@Published var showDeviceLibrary = false
@Published var installerReady = false
var chooseGameAfterDeviceLibrary=false
@Published var catalog: [CatalogGame] = []
@Published var catalogFailed = false
private var process: Process?
private var cancelled = false
let repo: URL
init() {
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)
}
if let path = UserDefaults.standard.string(forKey:"lastGameFolder"), FileManager.default.fileExists(atPath:path) {
folder=URL(fileURLWithPath:path)
restoreOutput()
}
}
var gameName: String { folder?.lastPathComponent ?? text("Ningún juego seleccionado") }
private func refreshCatalog() {
do {catalog=try GameCatalog.load(repo:repo);catalogFailed=false}
catch {catalog=[];catalogFailed=true}
}
func openCatalog() {
refreshCatalog()
showCatalog=true
}
@discardableResult func chooseFolder()->Bool {
let panel=NSOpenPanel();panel.canChooseDirectories=true;panel.canChooseFiles=false
panel.allowsMultipleSelection=false;panel.prompt=text("Elegir juego")
panel.message=text("Selecciona la carpeta con el juego base y sus actualizaciones NSP/XCI.")
guard panel.runModal() == .OK, let url=panel.url else { return false }
do {
let files=try FileManager.default.contentsOfDirectory(at:url,includingPropertiesForKeys:nil)
guard files.contains(where:{["nsp","xci","nsz","xcz"].contains($0.pathExtension.lowercased())}) else {
output=nil;assets=nil;phase="No encontramos el juego en esa carpeta."
detail="Elige la carpeta que contiene los archivos NSP o XCI directamente."
failed=true;return false
}
guard !url.standardizedFileURL.path.hasPrefix(repo.standardizedFileURL.path+"/"), url.standardizedFileURL != repo.standardizedFileURL else {
phase="Guarda el juego fuera del repositorio.";detail="Así tus juegos y llaves se quedan fuera de GitHub.";failed=true;return false
}
folder=url;UserDefaults.standard.set(url.path,forKey:"lastGameFolder")
phase="Listo para preparar el juego.";detail="Un clic más. Los archivos anteriores se conservan."
failed=false;output=nil;assets=nil;transcript="";restoreOutput();return true
} catch { phase="No pudimos leer esa carpeta.";detail=error.localizedDescription;failed=true;return false }
}
func chooseKeysFolder() {
let panel=NSOpenPanel();panel.canChooseDirectories=true;panel.canChooseFiles=false
panel.allowsMultipleSelection=false;panel.prompt=text("Usar mis llaves")
panel.message=text("Elige tu carpeta con prod.keys. No copiamos las llaves ni las incluimos en la app.")
guard panel.runModal() == .OK, let url=panel.url else {return}
let resolved=url.resolvingSymlinksInPath().standardizedFileURL
let root=repo.resolvingSymlinksInPath().standardizedFileURL
let key=resolved.appendingPathComponent("prod.keys")
guard resolved != root, !resolved.path.hasPrefix(root.path+"/"),
FileManager.default.isReadableFile(atPath:key.path) else {
failed=true;phase="Elige una carpeta externa con tu prod.keys.";detail="No se proporcionan llaves. Debes seleccionar las tuyas.";return
}
keysFolder=resolved;UserDefaults.standard.set(resolved.path,forKey:"keysDirectory")
failed=false;phase="Tus llaves están seleccionadas.";detail="Sólo guardamos la ruta en este Mac, nunca su contenido."
}
func restoreOutput() {
previous=folder.flatMap {PreviousPreparation.load(folder:$0,target:target)}
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 prepared=IOSPackageOutput.load(record:record,split:splitIOS) else {return}
output=prepared.ipa;assets=prepared.romfs
}
func build() {
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
}
if keysFolder == nil || !FileManager.default.isReadableFile(atPath:keysFolder!.appendingPathComponent("prod.keys").path) {chooseKeysFolder()}
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,"--target",target,folder.path]
arguments += ["--keys-folder",keysFolder.path]
if target=="ios" && !splitIOS {arguments.append("--full-only")}
task.arguments=arguments;task.currentDirectoryURL=repo
task.standardOutput=pipe;task.standardError=pipe
var environment=ProcessInfo.processInfo.environment
environment["PYTHONUNBUFFERED"]="1"
environment["NXE_PROGRESS"]="json"
let log=folder.appendingPathComponent("nxnative-tools/logs/build-"+UUID().uuidString+".log")
logURL=log;environment["NXE_BUILD_LOG"]=log.path;environment["NXE_GAME_DIR"]=folder.path
startedAt=Date();lastOutputAt=nil;activity=BuildActivity()
task.environment=environment
installerReady=false;running=true;cancelled=false;failed=false;showDetails=false;output=nil;assets=nil;transcript=""
phase="Preparando herramientas…";detail="Puedes dejar esta ventana abierta. No necesitas usar Terminal."
pipe.fileHandleForReading.readabilityHandler={ [weak self] handle in
let data=handle.availableData
guard !data.isEmpty else { handle.readabilityHandler=nil;return }
Task { @MainActor in self?.append(data) }
}
task.terminationHandler={ [weak self] task in
let status=task.terminationStatus
Task { @MainActor in
guard let self else{return}
self.activity.finish(logURL:self.logURL);self.transcript=self.activity.transcript
self.running=false;self.process=nil
if self.cancelled {
self.phase="Preparación cancelada.";self.detail="Tus archivos y partidas anteriores se conservaron."
} else if status==0 {
self.restoreOutput()
self.failed=self.output==nil
self.installerReady = !self.isMac && self.output != nil
self.phase=self.output == nil ? "Terminó, pero no encontramos la app." : (self.isMac ? "Tu juego está listo para Mac." : "Tu juego está listo para instalar en iPhone / iPad.")
self.detail=self.output == nil ? "Usa Ver error para revisar lo que ocurrió." : "Pulsa Instalar en iPhone / iPad. Inicia sesión con tu cuenta personal para firmar e instalar el juego y sus datos."
} else {
self.failed=true;self.showDetails=true;self.phase="No pudimos preparar el juego."
let log=self.transcript.lowercased()
if log.contains("install and open full xcode") {
self.detail="Instala Xcode y ábrelo una vez. Después vuelve a intentar."
} else if log.contains("install homebrew") {
self.detail="Instala Homebrew desde brew.sh y vuelve a intentar."
} else if log.contains("ios sdk 26 or newer") {
self.detail="Actualiza Xcode: necesitas el SDK de iOS 26 o posterior."
} else {
self.detail="Tus archivos anteriores se conservaron. Puedes ver o copiar el error para revisarlo."
}
}
}
}
do { process=task;try task.run() }
catch { running=false;process=nil;failed=true;phase="No pudimos iniciar la preparación.";detail=error.localizedDescription }
}
func append(_ data:Data) {
guard running else {return}
activity.receive(data);transcript=activity.transcript;lastOutputAt=Date()
let lower=activity.stage.lowercased()
if lower.contains("pack") || lower.contains("export") {phase=isMac ? "Compilando la app para Mac…" : "Creando tu IPA ligera…"}
else if lower.contains("app") || lower.contains("host") {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…"}
}
func revealLog() {if let logURL {NSWorkspace.shared.activateFileViewerSelecting([logURL])}}
func revealCompilerLogs() {
guard let folder else {return}
let paths=((try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:nil)) ?? [])
.filter {$0.lastPathComponent.range(of:"^[0-9A-Fa-f]{16}_build$",options:.regularExpression) != nil}
.map {$0.appendingPathComponent("logs")}.filter {FileManager.default.fileExists(atPath:$0.path)}
NSWorkspace.shared.activateFileViewerSelecting(paths.isEmpty ? [folder] : paths)
}
func cancel() {
guard let process,process.isRunning else{return}
cancelled=true
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")}
func openSideloadly() {
if let app=sideloadlyURL, let output {
NSWorkspace.shared.open([output],withApplicationAt:app,configuration:NSWorkspace.OpenConfiguration())
} else {NSWorkspace.shared.open(URL(string:"https://sideloadly.io")!)}
}
}
@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
@State private var started = Date()
@State private var pausedAt: Date?
var body: some View {
GeometryReader { geometry in
TimelineView(.animation(minimumInterval:1.0 / 30.0,paused:pausedAt != nil || reduceMotion)) { context in
let width = CGFloat(games.count) * 177
let elapsed = max(0,(pausedAt ?? context.date).timeIntervalSince(started))
let offset = width > 0 && !reduceMotion ? CGFloat(elapsed * 22).truncatingRemainder(dividingBy:width) : 0
HStack(spacing:12) {
ForEach(0..<2,id:\.self) { copy in
ForEach(Array(games.enumerated()),id:\.element.id) { index, game in
VStack(alignment:.leading,spacing:7) {
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)
}.frame(width:165).accessibilityHidden(copy == 1)
}
}
}.offset(x:-offset).frame(width:geometry.size.width,alignment:.leading)
}
}.frame(height:129).clipped()
.accessibilityElement(children:.ignore)
.accessibilityLabel(games.map(\.name).joined(separator:", "))
.onHover { hovering in
if hovering { pausedAt = Date() }
else if let pause = pausedAt {
started = started.addingTimeInterval(Date().timeIntervalSince(pause))
pausedAt = nil
}
}
}
}
// Apply the compact launch height even when macOS restores an older window size.
struct CompactWindow: NSViewRepresentable {
let height: CGFloat
func makeNSView(context:Context) -> NSView {let view=WindowView();view.height=height;return view}
func updateNSView(_ nsView:NSView,context:Context) {(nsView as? WindowView)?.height=height}
private final class WindowView: NSView {
var height: CGFloat=650 {didSet {if height != oldValue {resize()}}}
private var lastHeight: CGFloat?
override func viewDidMoveToWindow() {super.viewDidMoveToWindow();resize()}
private func resize() {
guard let window=window, lastHeight != height else {return}
let target=height;lastHeight=target
DispatchQueue.main.async {
guard !window.styleMask.contains(.fullScreen) else {return}
window.setContentSize(NSSize(width:max(760,window.contentLayoutRect.width),height:target))
}
}
}
}
enum AppArtwork {
static let icon: NSImage = {
guard let url=Bundle.main.url(forResource:"BrandIcon",withExtension:"png"),
let image=NSImage(contentsOf:url) else {return NSImage(size:NSSize(width:32,height:32))}
return image
}()
}
struct ContentView: View {
@ObservedObject var updater: AppUpdater
@ObservedObject var sideload:SideloadModel
@StateObject private var model=BuildModel()
@StateObject private var controllerMonitor=ControllerMonitor()
@State private var showControllerTest=false
private let ink=Color(red:0.93,green:0.94,blue:0.90)
private let lime=Color(red:0.79,green:0.95,blue:0.37)
var body: some View {
ZStack {
Color(red:0.065,green:0.075,blue:0.07).ignoresSafeArea()
ScrollView {
VStack(alignment:.leading,spacing:22) {
HStack {
HStack(spacing:9) {
Image(nsImage:AppArtwork.icon).resizable().interpolation(.high)
.frame(width:34,height:34).accessibilityHidden(true)
Text("nxnative easy").font(.system(size:15,weight:.semibold)).foregroundStyle(lime)
}
Spacer()
Button(model.text("Mis iPhones y iPads")){model.showDeviceLibrary=true}.disabled(model.running || updater.busy || sideload.busy)
Picker(model.text("Idioma de la interfaz"),selection:$model.language) {
Text("English").tag(EasyLanguage.english)
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 || sideload.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))
}
if sideload.showsRefreshNotice {
HStack {
Text(model.text(sideload.error ?? sideload.phase)).font(.system(size:12)).textSelection(.enabled)
Spacer()
if !sideload.busy {Button(model.text("Revisar renovación")){model.showDeviceLibrary=true}}
}.padding(14).background(Color.orange.opacity(0.08),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."))
.font(.system(size:14)).foregroundStyle(.secondary)
}
VStack(alignment:.leading,spacing:10) {
HStack {
Text(model.text("Juegos con soporte en la herramienta"))
.font(.system(size:14,weight:.semibold)).foregroundStyle(ink)
Spacer()
Button {model.openCatalog()} label: {
Label(model.text("Ver todos"),systemImage:"magnifyingglass")
.font(.system(size:12,weight:.semibold))
}.buttonStyle(.plain).foregroundStyle(lime).accessibilityIdentifier("view-all-games")
}
Text(model.text("No incluye ni descarga juegos. Debes añadir tus propios archivos."))
.font(.system(size:12)).foregroundStyle(.secondary)
SupportCarousel(games:model.catalog)
}
VStack(alignment:.leading,spacing:18) {
HStack {
Text(model.text("¿Dónde quieres jugar?")).font(.system(size:13,weight:.medium))
Spacer()
ForEach(["ios","mac"],id:\.self) { target in
Button {model.target=target} label: {
Text(target == "ios" ? "iPhone / iPad" : "Mac")
.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 || updater.busy || sideload.busy).accessibilityIdentifier("target-"+target)
}
}
if model.isMac {
HStack(alignment:.top,spacing:10) {
Image(systemName:controllerMonitor.controllers.contains(where:{$0.supported}) ? "checkmark.circle.fill" : "gamecontroller")
.foregroundStyle(controllerMonitor.controllers.contains(where:{$0.supported}) ? lime : .gray)
VStack(alignment:.leading,spacing:4) {
if controllerMonitor.controllers.isEmpty {
Text(model.text("Sin control conectado en este Mac")).font(.system(size:13,weight:.medium))
Text(model.text("Conéctalo por Bluetooth y aparecerá aquí.")).font(.system(size:12)).foregroundStyle(.secondary)
} else {
ForEach(controllerMonitor.controllers) { controller in
Text((controller.name.isEmpty ? model.text("Control") : controller.name)+" · "+model.text(controller.supported ? "Listo en este Mac" : "Sin perfil compatible"))
.font(.system(size:13,weight:.medium))
}
}
}
Spacer()
Button(model.text("Probar control")){showControllerTest=true}
.disabled(model.running || updater.busy || sideload.busy).accessibilityIdentifier("test-controller")
}.accessibilityIdentifier("controller-status")
}
HStack(spacing:12) {
Image(systemName:"folder.fill").font(.system(size:24)).foregroundStyle(lime)
VStack(alignment:.leading,spacing:4) {
Text(model.folder == nil ? model.text("Carpeta de tu juego") : model.gameName)
.font(.system(size:16,weight:.semibold)).foregroundStyle(ink)
if model.folder == nil {
Text(model.text("Con el juego base y sus actualizaciones.")).font(.system(size:12)).foregroundStyle(.secondary)
}
}
Spacer()
Button(model.folder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseFolder()}.disabled(model.running || updater.busy || sideload.busy).accessibilityLabel(model.text("Carpeta de tu juego"))
}
HStack(spacing:12) {
Image(systemName:"key.horizontal").font(.system(size:19)).foregroundStyle(lime).frame(width:24)
VStack(alignment:.leading,spacing:4) {
Text(model.text("Carpeta de llaves")).font(.system(size:14,weight:.medium))
Text(model.keysFolder?.lastPathComponent ?? model.text("Selecciona tus propias prod.keys."))
.font(.system(size:12)).foregroundStyle(.secondary)
}
Spacer()
Button(model.keysFolder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseKeysFolder()}.disabled(model.running || updater.busy || sideload.busy).accessibilityLabel(model.text("Carpeta de llaves"))
}
if model.target=="ios" {
Toggle(model.text("IPA ligera: separar los datos del juego"),isOn:$model.splitIOS).disabled(model.running || updater.busy || sideload.busy)
Text(model.text(model.splitIOS ? "Recomendado: la app pesa menos y el instalador copia romfs por separado." : "IPA completa: incluye el juego dentro. Ocupa más y cada renovación vuelve a transferir toda la app.")).font(.caption).foregroundStyle(.secondary)
Label(model.text("Carpeta y llaves → Preparar → Cuenta Apple → Instalar y copiar datos"),systemImage:"arrow.right.circle").font(.caption).foregroundStyle(.secondary)
}
if model.running {
Divider().overlay(Color.white.opacity(0.08))
HStack {
Text(model.text(model.phase)).font(.system(size:14,weight:.medium))
Spacer()
Button(model.text("Cancelar")){model.cancel()}
}
if let fraction=model.activity.fraction {ProgressView(value:fraction).tint(lime)}
else {ProgressView().progressViewStyle(.linear).tint(lime)}
if !model.activity.stage.isEmpty {
Text((model.activity.stages>0 ? "\(model.activity.stageNumber)/\(model.activity.stages) · " : "")+model.activity.stage+(model.activity.step.isEmpty ? "" : " · "+model.activity.step))
.font(.system(size:12)).foregroundStyle(.secondary)
}
if !model.activity.count.isEmpty {Text(model.activity.count).font(.system(size:12,design:.monospaced))}
TimelineView(.periodic(from:.now,by:1)) {context in
let elapsed=Int(context.date.timeIntervalSince(model.startedAt ?? context.date))
let quiet=Int(context.date.timeIntervalSince(model.lastOutputAt ?? model.startedAt ?? context.date))
Text(model.text("Tiempo transcurrido")+String(format:" %d:%02d",max(0,elapsed)/60,max(0,elapsed)%60)+(quiet>=15 ? " · "+model.text("Sin salida nueva; el proceso sigue activo.") : ""))
.font(.system(size:11)).foregroundStyle(.secondary)
}
Text(model.activity.latest.isEmpty ? model.text("Esperando la primera salida del compilador…") : model.activity.latest)
.font(.system(size:11,design:.monospaced)).lineLimit(2).textSelection(.enabled)
} else if model.failed {
Text(model.text(model.phase)).font(.system(size:14,weight:.semibold)).foregroundStyle(.orange)
Text(model.text(model.detail)).font(.system(size:12)).foregroundStyle(.secondary)
}
if model.previous != nil && !model.running {
Label(model.text("Ya preparaste este juego antes"),systemImage:"arrow.triangle.2.circlepath").font(.system(size:13,weight:.medium))
if let version=model.previous?.version {Text(model.text("Versión anterior")+": "+version).font(.system(size:12)).foregroundStyle(.secondary)}
Text(model.text(model.isMac ? "Crearás una app nueva. Tus partidas siguen en este Mac, fuera de la app. Cierra el juego antes de abrir la versión nueva." : "Crearás una IPA para actualizar tu app. En Instalar en iPhone / iPad, elige la app existente y su misma cuenta. No desinstales el juego. La preparación no comprueba qué está instalado en tu iPhone."))
.font(.system(size:12)).foregroundStyle(.secondary)
}
if !model.running && (model.output == nil || model.failed) {
Button {model.build()} label: {
HStack {
Text(model.text(model.failed ? "Intentar de nuevo" : (model.previous == nil ? "Preparar juego" : "Preparar actualización")))
Spacer()
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 || updater.busy || sideload.busy)
}
}.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) {
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("Todo el juego va dentro de la app. No necesitas copiar carpetas."))
.font(.system(size:12)).foregroundStyle(.secondary)
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("Instala y copia los datos desde aquí con tu cuenta personal, o usa Sideloadly como alternativa."))
.font(.system(size:13))
Button(model.text("Instalar en iPhone / iPad")){if let ipa=model.output,let folder=model.folder {sideload.configure(ipa:ipa,folder:folder,romfs:model.assets);model.showSideload=true}}
.disabled(updater.busy || sideload.busy).buttonStyle(.borderedProminent).tint(lime).foregroundStyle(.black)
DisclosureGroup(model.text("Alternativa: Sideloadly")) {
VStack(alignment:.leading,spacing:10) {
HStack {
Button(model.text("Usar Sideloadly")){model.openSideloadly()}
Button(model.text("Mostrar IPA")){model.revealIPA()}
}
Text(model.text("2. Copia el nuevo romfs por Finder → iPhone → Archivos → tu juego. Reemplaza sólo romfs; conserva save y save_device.")).font(.system(size:12))
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)
}.padding(.top,8)
}.font(.system(size:12))
}
Button(model.text("Tus partidas")){model.showSaves=true}
Button(model.text(model.previous == nil ? "Preparar de nuevo" : "Preparar actualización")){model.build()}.buttonStyle(.link).disabled(updater.busy || sideload.busy)
}.padding(22).frame(maxWidth:.infinity,alignment:.leading)
.background(lime.opacity(0.045),in:RoundedRectangle(cornerRadius:16))
}
if !model.transcript.isEmpty || model.running {
DisclosureGroup(model.text(model.failed ? "Ver error" : "Ver compilación en vivo"),isExpanded:$model.showDetails) {
VStack(alignment:.leading,spacing:10) {
ScrollViewReader {proxy in
ScrollView {Text(model.transcript).font(.system(size:10,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading);Color.clear.frame(height:1).id("log-bottom")}
.frame(height:150).onChange(of:model.transcript) {_,_ in if model.running {proxy.scrollTo("log-bottom",anchor:.bottom)}}
}
if model.logURL != nil {Button(model.text("Mostrar logs guardados")){model.revealLog()}}
Button(model.text("Mostrar logs del compilador")){model.revealCompilerLogs()}
Button(model.text("Copiar diagnóstico")){NSPasteboard.general.clearContents();NSPasteboard.general.setString(model.transcript,forType:.string)}
}.padding(.top,8)
}.font(.system(size:12)).foregroundStyle(.secondary)
}
}.padding(28)
}
}.frame(minWidth:760,minHeight:530).background(CompactWindow(height:(model.isMac ? 740 : 680)+(updater.visible ? 95 : 0)+(sideload.showsRefreshNotice ? 70 : 0))).preferredColorScheme(.dark)
.onReceive(NotificationCenter.default.publisher(for:Notification.Name("nxnative.openAnalysis"))) {_ in if !model.running && !updater.busy {model.showAnalysis=true}}
.sheet(isPresented:$model.showSaves) {SavesHelpView(folder:model.folder,isMac:model.isMac,text:model.text,language:model.language)}
.onChange(of:model.installerReady) {_,ready in
if ready,let ipa=model.output,let folder=model.folder {sideload.configure(ipa:ipa,folder:folder,romfs:model.assets);model.showSideload=true;model.installerReady=false}
}
.sheet(isPresented:$model.showDeviceLibrary,onDismiss:{if model.chooseGameAfterDeviceLibrary {model.chooseGameAfterDeviceLibrary=false;model.target="ios";if !model.chooseFolder() {sideload.cancelPendingUpdate()}}}) {SideloadLibraryView(model:sideload,text:model.text,canInstall:!model.running && !updater.busy,prepareGame:{model.chooseGameAfterDeviceLibrary=true})}
.sheet(isPresented:$model.showSideload) {SideloadView(model:sideload,text:model.text)}
.sheet(isPresented:$model.showAnalysis) {if let game=model.folder {GameAnalysisView(repo:model.repo,game:game,text:model.text)} else {Text(model.text("Primero selecciona la carpeta de un juego preparado.")).padding(24)}}
.sheet(isPresented:$model.showCatalog) {GameCatalogView(model:model)}
.sheet(isPresented:$showControllerTest) {ControllerTestView(text:model.text)}
.task {sideload.canRefresh={ !model.running && !updater.busy };await updater.check();sideload.checkRefresh()}
.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()}
}
}
struct GameCatalogView: View {
@ObservedObject var model: BuildModel
@State private var query=""
@FocusState private var searchFocused: Bool
private let lime=Color(red:0.79,green:0.95,blue:0.37)
private var results: [CatalogGame] {GameCatalog.search(model.catalog,query:query)}
var body: some View {
VStack(alignment:.leading,spacing:18) {
HStack {
VStack(alignment:.leading,spacing:5) {
Text(model.text("Todos los juegos")).font(.system(size:26,weight:.bold,design:.rounded))
HStack(spacing:5) {
Text("\(results.count) / \(model.catalog.count)").monospacedDigit()
Text(model.text("juegos en la lista"))
}.font(.system(size:12)).foregroundStyle(.secondary)
}
Spacer()
Button(model.text("Cerrar")) {model.showCatalog=false}.keyboardShortcut(.cancelAction)
}
Text(model.text("Juegos del repositorio. Consulta cada ficha para ver dispositivos probados y versiones."))
.font(.system(size:12)).foregroundStyle(.secondary).fixedSize(horizontal:false,vertical:true)
HStack {
Image(systemName:"magnifyingglass").foregroundStyle(lime)
TextField(model.text("Buscar un juego"),text:$query)
.textFieldStyle(.plain).focused($searchFocused).accessibilityIdentifier("game-search")
if !query.isEmpty {Button {query=""} label: {Image(systemName:"xmark.circle.fill")}.buttonStyle(.plain).accessibilityLabel(model.text("Limpiar búsqueda"))}
}.padding(12).background(Color.white.opacity(0.06),in:RoundedRectangle(cornerRadius:10))
if model.catalogFailed {
Text(model.text("No pudimos leer los perfiles. Comprueba que el repositorio siga en su ubicación original."))
.foregroundStyle(.orange).frame(maxWidth:.infinity,maxHeight:.infinity)
} else if results.isEmpty {
VStack(spacing:8) {
Image(systemName:"magnifyingglass").font(.system(size:32)).foregroundStyle(.secondary)
Text(model.text("No encontramos juegos con esa búsqueda."))
}.frame(maxWidth:.infinity,maxHeight:.infinity)
} else {
ScrollView {
LazyVStack(spacing:8) {
ForEach(results) {game in
HStack(spacing:12) {
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 {
Text(model.text("Versiones")+": "+game.versions.joined(separator:", "))
.font(.system(size:11)).foregroundStyle(.secondary)
}
}
Spacer()
Link(model.text("Ficha del juego"),destination:game.notesURL).font(.system(size:12))
}.padding(14).background(Color.white.opacity(0.04),in:RoundedRectangle(cornerRadius:12))
}
}
}
}
}.padding(26).frame(width:760,height:620).preferredColorScheme(.dark)
.onAppear {searchFocused=true}
}
}
@main struct NxNativeEasyApp: App {
@NSApplicationDelegateAdaptor(LauncherDelegate.self) private var delegate
@StateObject private var updater=AppUpdater()
@StateObject private var sideload=SideloadModel()
@AppStorage("interfaceLanguage") private var appLanguage="en"
var body: some Scene {
Window("nxnative Easy",id:"main") {
ContentView(updater:updater,sideload:sideload).onAppear {
NSApplication.shared.applicationIconImage=AppArtwork.icon
}
}
.defaultSize(width:820,height:740)
.commands {
CommandMenu(EasyStrings.text("Herramientas de análisis",language:EasyLanguage(rawValue:appLanguage) ?? .english)) {
Button(EasyStrings.text("Analizar con REA",language:EasyLanguage(rawValue:appLanguage) ?? .english)) {
NotificationCenter.default.post(name:Notification.Name("nxnative.openAnalysis"),object:nil)
}
}
CommandGroup(after:.appInfo) {
Button(EasyStrings.text("Buscar actualizaciones…",language:EasyLanguage(rawValue:appLanguage) ?? .english)) {
Task {await updater.check(manual:true)}
}.disabled(updater.busy || sideload.busy)
}
}
}
}