Add personal game library and structured build activity workspace
This commit is contained in:
1 parent
7e3fc97a18
commit
08f1d5665a
20 files changed
+642
-56
No files matched your search
+23
-4
@@ -20,9 +20,9 @@ Descarga nxnative Easy, instala Homebrew y las herramientas que falten, **compil
|
||||
|
||||
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**.
|
||||
1. En **Mi biblioteca**, pulsa **Añadir juego** y elige su carpeta.
|
||||
2. Elige **iPhone / iPad** o **Mac** y selecciona la carpeta de tus llaves.
|
||||
3. Pulsa **Preparar juego**. El juego queda en tu biblioteca para la próxima vez.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -32,7 +32,9 @@ Cuando abra 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.
|
||||
|
||||

|
||||

|
||||
|
||||
*Ejemplo de interfaz con registros de prueba. Cada usuario aporta sus juegos.*
|
||||
|
||||
## Qué aporta esta versión
|
||||
|
||||
@@ -78,6 +80,23 @@ El acceso de apertura usa el compilador Swift de tu Xcode. La firma de la app es
|
||||
|
||||
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.
|
||||
|
||||
## Tu biblioteca de juegos
|
||||
|
||||
**Mi biblioteca** es el inicio: tus propias carpetas, imágenes, buscador y estados
|
||||
separados para Mac e iPhone/iPad. Abre un juego de Mac o instala una IPA preparada
|
||||
desde su tarjeta. **Preparar o actualizar** usa el mismo flujo de compilación;
|
||||
quitar una tarjeta sólo elimina el acceso, nunca los archivos ni las partidas.
|
||||
El catálogo de juegos compatibles está separado de tu colección.
|
||||
|
||||
Mientras navegas, una barra mantiene visible la compilación con la etapa,
|
||||
contadores y ETA informados. **Ver actividad** abre la línea de etapas y los logs.
|
||||
El porcentaje corresponde a la etapa actual; no inventamos un porcentaje global.
|
||||
[Funcionamiento y contrato del frontend](docs/frontend-library.md).
|
||||
|
||||

|
||||
|
||||
*Ejemplo de progreso simulado; en la app, los contadores y ETA vienen de la compilación real.*
|
||||
|
||||
## Instalar y renovar desde la app (experimental)
|
||||
|
||||
La versión 1.8 agrega **Instalar en iPhone / iPad**: inicio de sesión y 2FA de tu
|
||||
|
||||
@@ -20,9 +20,9 @@ It downloads nxnative Easy, installs Homebrew and the missing build tools, **com
|
||||
|
||||
When the app opens:
|
||||
|
||||
1. Choose **iPhone / iPad** or **Mac**.
|
||||
2. Choose your own **game folder** and **keys folder**.
|
||||
3. Click **Prepare game**.
|
||||
1. In **My library**, click **Add game** and select your game folder.
|
||||
2. Choose **iPhone / iPad** or **Mac**, then select your keys folder.
|
||||
3. Click **Prepare game**. The game stays in your library for next time.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -32,7 +32,9 @@ When the app opens:
|
||||
|
||||
**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 window shows the current stage, real counters when available, elapsed time and a live log. **Show saved logs** keeps the session output accessible; **Show compiler logs** opens the original tool logs.
|
||||
|
||||

|
||||

|
||||
|
||||
*Interface example with synthetic library records. Games are supplied by each user.*
|
||||
|
||||
## What this version adds
|
||||
|
||||
@@ -76,6 +78,23 @@ The launcher uses Xcode's Swift compiler. The desktop app is explicitly **ad-hoc
|
||||
|
||||
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.
|
||||
|
||||
## Your game library
|
||||
|
||||
**My library** opens first: your own game folders, artwork, search and separate
|
||||
Mac/iPhone readiness. Open a prepared Mac game or install an existing IPA directly
|
||||
from its card. **Prepare or update** uses the same build flow; removing a card
|
||||
only removes the shortcut, never game files or saves. The compatible-game catalog
|
||||
is separate from your collection.
|
||||
|
||||
Compilation stays visible while browsing: a dock shows the current stage,
|
||||
reported counts and ETA, with **View activity** for the timeline and original logs.
|
||||
It shows real stage progress, not a guessed overall percentage.
|
||||
[Library behavior and frontend contract](docs/frontend-library.md).
|
||||
|
||||

|
||||
|
||||
*Synthetic progress example; counts and ETA in the app come from the real build stream.*
|
||||
|
||||
## Integrated installation and refresh (experimental)
|
||||
|
||||
Version 1.8 adds **Install on iPhone / iPad**: personal Apple login/2FA, explicit
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
struct BuildStage:Identifiable {
|
||||
enum State {case active,done,failed,cancelled}
|
||||
let id:String
|
||||
var number:Int
|
||||
var name:String
|
||||
var state:State
|
||||
}
|
||||
|
||||
// Parse complete UTF-8 lines: pipe reads may split a JSON record or a multibyte character.
|
||||
struct BuildActivity {
|
||||
private var pending=Data()
|
||||
@@ -12,6 +20,9 @@ struct BuildActivity {
|
||||
private(set) var fraction:Double?
|
||||
private(set) var count=""
|
||||
private(set) var latest=""
|
||||
private(set) var history:[BuildStage]=[]
|
||||
private(set) var eta:Double?
|
||||
private(set) var estimated=false
|
||||
mutating func receive(_ data:Data) {
|
||||
pending.append(data)
|
||||
while let end=pending.firstIndex(of:10) {
|
||||
@@ -28,11 +39,23 @@ struct BuildActivity {
|
||||
defer {try? handle.close()}
|
||||
if let size=try? handle.seekToEnd() {
|
||||
try? handle.seek(toOffset:size>524288 ? size-524288 : 0)
|
||||
if let data=try? handle.readToEnd() {self=BuildActivity();receive(data)}
|
||||
if let data=try? handle.readToEnd() {
|
||||
let previous=history
|
||||
self=BuildActivity();receive(data)
|
||||
let recovered=history
|
||||
history=previous.filter {old in !recovered.contains(where:{$0.id==old.id})}+recovered
|
||||
}
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
mutating func complete(exitCode:Int32,cancelled:Bool=false) {
|
||||
for index in history.indices where history[index].state == .active {
|
||||
history[index].state=cancelled ? .cancelled : exitCode==0 ? .done : .failed
|
||||
}
|
||||
eta=nil
|
||||
if exitCode != 0 || cancelled {fraction=nil}
|
||||
}
|
||||
private mutating func consume(_ line:String) {
|
||||
guard !line.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty else {return}
|
||||
guard let data=line.data(using:.utf8),let obj=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any],let event=obj["ev"] as? String else {latest=String(line.suffix(300));record(line);return}
|
||||
@@ -40,8 +63,10 @@ struct BuildActivity {
|
||||
if let value=obj["step"] as? String {step=value}
|
||||
if let k=obj["k"] as? Int {stageNumber=k}
|
||||
if let n=obj["n"] as? Int {stages=n}
|
||||
if event=="stage" || event=="step" {fraction=nil;count=""}
|
||||
if event=="stage" || event=="step" {fraction=nil;count="";eta=nil;estimated=false}
|
||||
if event=="progress" {
|
||||
eta=(obj["eta"] as? Double).flatMap {$0.isFinite && $0>=0 && $0<Double(Int.max) ? $0 : nil}
|
||||
estimated=(obj["est"] as? Bool)==true
|
||||
fraction=(obj["frac"] as? Double).flatMap {$0.isFinite && $0>=0 && $0<=1 ? $0 : nil}
|
||||
if let done=obj["done"] as? Double,done.isFinite,done>=0 {
|
||||
let unit=obj["unit"] as? String ?? ""
|
||||
@@ -50,9 +75,15 @@ struct BuildActivity {
|
||||
else {count=String(format:"%.0f %@",done,unit)}
|
||||
}
|
||||
}
|
||||
if ["stage","step","progress","stage_end"].contains(event),!stage.isEmpty {
|
||||
let key=stageNumber>0 ? "stage-\(stageNumber)" : "extra-"+stage
|
||||
let state:BuildStage.State=event=="stage_end" ? ((obj["ok"] as? Bool)==true ? .done : .failed) : .active
|
||||
if let index=history.firstIndex(where:{$0.id==key}) {history[index].name=stage;history[index].state=state}
|
||||
else {history.append(BuildStage(id:key,number:stageNumber,name:stage,state:state))}
|
||||
}
|
||||
if ["stage","step","stage_end"].contains(event) {record("[\(stageNumber)/\(stages)] \(stage) · \(step)")}
|
||||
if event=="progress" && !count.isEmpty {record(count)}
|
||||
if event=="stage_end" {fraction=(obj["ok"] as? Bool)==true ? 1 : nil}
|
||||
if event=="stage_end" {eta=nil;fraction=(obj["ok"] as? Bool)==true ? 1 : nil}
|
||||
if let skipped=obj["skipped"] as? Int,skipped>0 {record("Live preview skipped \(skipped) older bytes; the original compiler log is complete.")}
|
||||
if let message=obj["msg"] as? String,let last=message.split(whereSeparator:\.isNewline).last {latest=String(last.suffix(300));record((obj["source"] as? String).map {"["+$0+"]\n"} ?? "");record(message)}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
private func boundedLibraryData(_ url:URL)throws->Data {
|
||||
let stream=try FileHandle(forReadingFrom:url)
|
||||
defer {try? stream.close()}
|
||||
let data=try stream.read(upToCount:1048577) ?? Data()
|
||||
guard data.count<=1048576 else {throw CocoaError(.fileReadCorruptFile)}
|
||||
return data
|
||||
}
|
||||
|
||||
struct OwnedGame:Codable,Identifiable,Equatable {
|
||||
var id:String
|
||||
var folder:String
|
||||
var added:Date
|
||||
}
|
||||
struct OwnedGameOutput:Identifiable {
|
||||
let item:OwnedGame
|
||||
var id:String {item.id}
|
||||
var folder:URL {URL(fileURLWithPath:item.folder)}
|
||||
var titleID:String?
|
||||
var macApp:URL?
|
||||
var iosIPA:URL?
|
||||
var romfs:URL?
|
||||
var version:String?
|
||||
var missing=false
|
||||
var ready:Bool {macApp != nil || iosIPA != nil}
|
||||
static func read(_ item:OwnedGame)->OwnedGameOutput {
|
||||
var output=OwnedGameOutput(item:item)
|
||||
let fm=FileManager.default
|
||||
var directory:ObjCBool=false
|
||||
guard fm.fileExists(atPath:item.folder,isDirectory:&directory),directory.boolValue else {output.missing=true;return output}
|
||||
func record(_ name:String)->[String:Any] {
|
||||
guard let data=try? boundedLibraryData(output.folder.appendingPathComponent(name)),data.count<=1048576,
|
||||
let value=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any],(value["schema"] as? Int ?? 1)==1 else {return [:]}
|
||||
return value
|
||||
}
|
||||
let mac=record("nxnative-mac.json"),ios=record("nxnative-ios.json")
|
||||
if let path=mac["app_path"] as? String {
|
||||
let bundle=URL(fileURLWithPath:path)
|
||||
if let data=try? boundedLibraryData(bundle.appendingPathComponent("Contents/Info.plist")),
|
||||
let info=(try? PropertyListSerialization.propertyList(from:data,format:nil)) as? [String:Any],
|
||||
let identifier=info["CFBundleIdentifier"] as? String,!identifier.isEmpty,
|
||||
(mac["bundle_id"] as? String).map({$0==identifier}) ?? true,
|
||||
fm.fileExists(atPath:bundle.appendingPathComponent("Contents/MacOS/dmhost").path),
|
||||
(try? bundle.appendingPathComponent("Contents/Resources/romfs").resourceValues(forKeys:[.isDirectoryKey]).isDirectory)==true {
|
||||
output.macApp=bundle;output.titleID=info["DMTitleID"] as? String ?? mac["title_id"] as? String
|
||||
output.version=info["CFBundleShortVersionString"] as? String ?? mac["version"] as? String
|
||||
}
|
||||
}
|
||||
if let id=output.titleID,id.range(of:"^[0-9A-Fa-f]{16}$",options:.regularExpression)==nil {output.titleID=nil}
|
||||
if output.titleID==nil {
|
||||
let ids=((try? fm.contentsOfDirectory(at:output.folder,includingPropertiesForKeys:nil)) ?? []).compactMap {url->String? in
|
||||
url.lastPathComponent.range(of:"^[0-9A-Fa-f]{16}_build$",options:.regularExpression) != nil ? String(url.lastPathComponent.prefix(16)).uppercased() : nil
|
||||
}
|
||||
if ids.count==1 {output.titleID=ids[0]}
|
||||
}
|
||||
output.titleID=output.titleID?.uppercased()
|
||||
let split=(ios["packaging"] as? String) != "full"
|
||||
if let identifier=ios["bundle_id"] as? String,!identifier.isEmpty,
|
||||
let prepared=IOSPackageOutput.load(record:ios,split:split),
|
||||
prepared.romfs.map({fm.fileExists(atPath:$0.path)}) ?? true {
|
||||
output.iosIPA=prepared.ipa;output.romfs=prepared.romfs
|
||||
if output.version==nil {output.version=ios["version"] as? String}
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor final class GameLibrary:ObservableObject {
|
||||
private struct Document:Codable {let schema:Int;let games:[OwnedGame]}
|
||||
@Published private(set) var games:[OwnedGameOutput]=[]
|
||||
@Published var error:String?
|
||||
private var items:[OwnedGame]=[]
|
||||
private var writable=true
|
||||
private let file:URL
|
||||
init(file:URL?=nil) {
|
||||
self.file=file ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/nxnative Easy/library.json")
|
||||
if FileManager.default.fileExists(atPath:self.file.path) {
|
||||
do {
|
||||
let data=try boundedLibraryData(self.file)
|
||||
guard data.count<=1048576 else {throw CocoaError(.fileReadCorruptFile)}
|
||||
let document=try JSONDecoder().decode(Document.self,from:data)
|
||||
guard document.schema==1,Set(document.games.map(\.id)).count==document.games.count,document.games.allSatisfy({!$0.id.isEmpty && $0.folder.hasPrefix("/") && !$0.folder.contains("\0")}) else {throw CocoaError(.fileReadCorruptFile)}
|
||||
items=document.games
|
||||
}catch{writable=false;self.error="No pudimos leer tu biblioteca. Conservamos el archivo original sin sobrescribirlo."}
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
func refresh() {games=items.map(OwnedGameOutput.read)}
|
||||
@discardableResult func add(_ folder:URL)throws->String {
|
||||
guard writable else {throw CocoaError(.fileReadCorruptFile)}
|
||||
let path=folder.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
if let known=items.first(where:{$0.folder==path}) {refresh();return known.id}
|
||||
let url=URL(fileURLWithPath:path)
|
||||
let files=try FileManager.default.contentsOfDirectory(at:url,includingPropertiesForKeys:nil)
|
||||
guard files.contains(where:{["nsp","xci","nsz","xcz"].contains($0.pathExtension.lowercased()) || ["nxnative-mac.json","nxnative-ios.json"].contains($0.lastPathComponent)}) else {throw CocoaError(.fileReadUnknown)}
|
||||
let item=OwnedGame(id:UUID().uuidString,folder:path,added:Date())
|
||||
try persist(items+[item]);return item.id
|
||||
}
|
||||
func remove(_ id:String) {do{try persist(items.filter{$0.id != id})}catch{self.error="No pudimos guardar la biblioteca. Tus archivos no se modificaron."}}
|
||||
private func persist(_ proposed:[OwnedGame])throws {
|
||||
guard writable else {throw CocoaError(.fileReadCorruptFile)}
|
||||
let parent=file.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at:parent,withIntermediateDirectories:true,attributes:[.posixPermissions:0o700])
|
||||
try FileManager.default.setAttributes([.posixPermissions:0o700],ofItemAtPath:parent.path)
|
||||
try JSONEncoder().encode(Document(schema:1,games:proposed)).write(to:file,options:.atomic)
|
||||
try FileManager.default.setAttributes([.posixPermissions:0o600],ofItemAtPath:file.path)
|
||||
items=proposed;error=nil;refresh()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct LibraryWorkspace:View {
|
||||
@ObservedObject var model:BuildModel
|
||||
@ObservedObject var library:GameLibrary
|
||||
@ObservedObject var updater:AppUpdater
|
||||
@ObservedObject var sideload:SideloadModel
|
||||
@State private var preparing=false
|
||||
@State private var query=""
|
||||
@State private var removing:OwnedGameOutput?
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
private let accent=Color(red:0.79,green:0.95,blue:0.37)
|
||||
private var occupied:Bool {model.running || updater.busy || sideload.busy}
|
||||
private func name(_ game:OwnedGameOutput)->String {model.catalog.first(where:{$0.id==game.titleID})?.name ?? game.folder.lastPathComponent}
|
||||
private var results:[OwnedGameOutput] {library.games.filter {query.isEmpty || (name($0)+" "+($0.titleID ?? "")+" "+$0.item.folder).localizedCaseInsensitiveContains(query)}}
|
||||
private func configure(_ game:OwnedGameOutput) {
|
||||
guard !occupied else {return}
|
||||
model.folder=game.folder;model.defaults.set(game.item.folder,forKey:"lastGameFolder")
|
||||
model.output=nil;model.assets=nil;model.target=game.macApp != nil ? "mac" : game.iosIPA != nil ? "ios" : ""
|
||||
if model.target=="ios" {model.splitIOS=game.romfs != nil}
|
||||
model.restoreOutput();preparing=true
|
||||
}
|
||||
private func install(_ game:OwnedGameOutput) {
|
||||
guard !occupied,let ipa=game.iosIPA else {return}
|
||||
sideload.configure(ipa:ipa,folder:game.folder,romfs:game.romfs);model.showSideload=true
|
||||
}
|
||||
private func add() {
|
||||
guard !occupied else {return}
|
||||
if model.chooseFolder() {remember();preparing=true}
|
||||
}
|
||||
private func remember() {
|
||||
guard let folder=model.folder else {return}
|
||||
do {try library.add(folder)}catch{library.error="No pudimos añadir esa carpeta a la biblioteca. Tus archivos siguen intactos."}
|
||||
}
|
||||
var body:some View {
|
||||
VStack(spacing:0) {
|
||||
HStack(spacing:0) {
|
||||
VStack(alignment:.leading,spacing:22) {
|
||||
HStack(spacing:8) {Image(nsImage:AppArtwork.icon).resizable().frame(width:30,height:30);Text("nxnative\neasy").font(.headline).lineSpacing(-2)}.foregroundStyle(accent).padding(.bottom,12)
|
||||
nav("Mi biblioteca",icon:"square.grid.2x2",selected:!preparing){preparing=false}
|
||||
nav("Preparar juego",icon:"hammer",selected:preparing){preparing=true}
|
||||
Divider()
|
||||
nav("Dispositivos",icon:"ipad.and.iphone",selected:false){model.showDeviceLibrary=true}.disabled(occupied)
|
||||
nav("Compatibilidad",icon:"checklist",selected:false){model.openCatalog()}
|
||||
Spacer()
|
||||
Picker(model.text("Idioma de la interfaz"),selection:$model.language){Text("English").tag(EasyLanguage.english);Text("Español").tag(EasyLanguage.spanish)}.labelsHidden()
|
||||
Text(Bundle.main.object(forInfoDictionaryKey:"CFBundleShortVersionString") as? String ?? "nxnative Easy").font(.caption).foregroundStyle(.secondary)
|
||||
}.padding(20).frame(width:190).frame(maxHeight:.infinity).background(.white.opacity(0.025))
|
||||
Divider()
|
||||
ZStack {
|
||||
// Keep one preparation model/view alive: its task, logs, cancellation,
|
||||
// update checks and sheets continue when browsing the library.
|
||||
ContentView(updater:updater,sideload:sideload,model:model)
|
||||
.opacity(preparing ? 1 : 0).allowsHitTesting(preparing).accessibilityHidden(!preparing)
|
||||
if !preparing {
|
||||
VStack(alignment:.leading,spacing:20) {
|
||||
HStack(alignment:.top) {
|
||||
VStack(alignment:.leading,spacing:7) {Text(model.text("Mi biblioteca")).font(.system(size:30,weight:.bold,design:.rounded));Text(model.text("Tus juegos. Tus archivos. Todo en un lugar.")).foregroundStyle(.secondary)}
|
||||
Spacer();Button {add()} label:{Label(model.text("Añadir juego"),systemImage:"plus")}.buttonStyle(LibraryPrimaryButtonStyle()).disabled(occupied)
|
||||
}
|
||||
HStack {
|
||||
TextField(model.text("Buscar en mis juegos"),text:$query).textFieldStyle(.roundedBorder)
|
||||
Text(String(library.games.count)+" "+model.text("juegos")).font(.caption).foregroundStyle(.secondary)
|
||||
Button {library.refresh()} label:{Image(systemName:"arrow.clockwise")}.help(model.text("Actualizar biblioteca")).accessibilityLabel(model.text("Actualizar biblioteca"))
|
||||
}
|
||||
if updater.visible {
|
||||
Button {preparing=true} label:{Label(model.text(updater.busy ? updater.phaseText : "Hay una nueva versión"),systemImage:"arrow.down.circle")}.buttonStyle(.link)
|
||||
}
|
||||
if let error=library.error {Text(model.text(error)).font(.callout).foregroundStyle(.orange).textSelection(.enabled)}
|
||||
if sideload.showsRefreshNotice {Button(model.text("Revisar renovación")){model.showDeviceLibrary=true}.disabled(sideload.busy)}
|
||||
if library.games.isEmpty {
|
||||
VStack(spacing:16) {
|
||||
Image(systemName:"square.stack.3d.up").font(.system(size:42,weight:.light)).foregroundStyle(accent)
|
||||
Text(model.text("Tu colección empieza con tu primer juego")).font(.title2.bold())
|
||||
Text(model.text("Añade su carpeta. Conservamos los archivos donde están y recordamos tus versiones para Mac, iPhone y iPad.")).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth:440)
|
||||
Button(model.text("Añadir juego")){add()}.buttonStyle(LibraryPrimaryButtonStyle()).disabled(occupied)
|
||||
Button(model.text("Ver juegos compatibles")){model.openCatalog()}.buttonStyle(.link)
|
||||
}.frame(maxWidth:.infinity,maxHeight:.infinity)
|
||||
} else if results.isEmpty {Text(model.text("No hay juegos que coincidan con tu búsqueda.")).foregroundStyle(.secondary).frame(maxWidth:.infinity,maxHeight:.infinity)}
|
||||
else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns:[GridItem(.adaptive(minimum:230,maximum:340),spacing:18)],alignment:.leading,spacing:18) {
|
||||
ForEach(results) {game in
|
||||
GameLibraryCard(game:game,title:name(game),catalog:model.catalog.first(where:{$0.id==game.titleID}),text:model.text,building:model.running && model.folder?.resolvingSymlinksInPath().path==game.folder.resolvingSymlinksInPath().path,disabled:occupied,open:{
|
||||
if let mac=game.macApp {NSWorkspace.shared.open(mac)}
|
||||
else if game.iosIPA != nil {install(game)}
|
||||
else {configure(game)}
|
||||
},prepare:{configure(game)},install:{install(game)},remove:{removing=game})
|
||||
}
|
||||
}.padding(.bottom,8)
|
||||
}
|
||||
}
|
||||
Text(model.text("Esta biblioteca sólo contiene las carpetas que tú añades. No incluye ni descarga juegos.")).font(.caption).foregroundStyle(.secondary)
|
||||
}.padding(28).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
if model.running || model.failed || !model.activity.history.isEmpty {
|
||||
Divider()
|
||||
BuildDock(model:model,show:{preparing=true;model.showDetails=true})
|
||||
}
|
||||
}.frame(minWidth:1000,minHeight:640).tint(accent).background(Color(red:0.065,green:0.075,blue:0.07)).preferredColorScheme(.dark)
|
||||
.task {if model.folder != nil && library.games.isEmpty && library.error==nil {remember()}}
|
||||
.onChange(of:model.folder){_,_ in if model.folder != nil {remember()}}
|
||||
.onChange(of:model.running){old,new in if old && !new {library.refresh()}}
|
||||
.onChange(of:scenePhase){_,phase in if phase == .active {library.refresh()}}
|
||||
.alert(model.text("¿Quitar de la biblioteca?"),isPresented:Binding(get:{removing != nil},set:{if !$0 {removing=nil}})) {
|
||||
Button(model.text("Cancelar"),role:.cancel){removing=nil}
|
||||
Button(model.text("Quitar de esta lista"),role:.destructive){if let removing {library.remove(removing.id)};removing=nil}
|
||||
} message:{Text(model.text("Sólo quitamos el acceso de la biblioteca. No borramos juegos, cachés, llaves ni partidas."))}
|
||||
}
|
||||
private func nav(_ title:String,icon:String,selected:Bool,action:@escaping()->Void)->some View {
|
||||
Button(action:action){Label(model.text(title),systemImage:icon).font(.system(size:12,weight:selected ? .semibold : .regular)).frame(maxWidth:.infinity,alignment:.leading).padding(.vertical,10).padding(.horizontal,9).background(selected ? accent.opacity(0.1) : .clear,in:RoundedRectangle(cornerRadius:9)).foregroundStyle(selected ? accent : Color.primary)}.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct GameLibraryCard:View {
|
||||
let game:OwnedGameOutput
|
||||
let title:String
|
||||
let catalog:CatalogGame?
|
||||
let text:(String)->String
|
||||
let building:Bool
|
||||
let disabled:Bool
|
||||
let open:()->Void
|
||||
let prepare:()->Void
|
||||
let install:()->Void
|
||||
let remove:()->Void
|
||||
private let accent=Color(red:0.79,green:0.95,blue:0.37)
|
||||
var body:some View {
|
||||
VStack(alignment:.leading,spacing:12) {
|
||||
ZStack(alignment:.bottomLeading) {
|
||||
if let catalog {GameArtworkView(game:catalog)}else{Rectangle().fill(accent.opacity(0.08)).overlay(Image(systemName:"gamecontroller").font(.system(size:38,weight:.light)).foregroundStyle(accent))}
|
||||
LinearGradient(colors:[.clear,.black.opacity(0.75)],startPoint:.center,endPoint:.bottom)
|
||||
if building {Label(text("Compilando"),systemImage:"hammer.fill").font(.caption.bold()).padding(10).foregroundStyle(.white)}
|
||||
}.frame(height:132).clipped().clipShape(RoundedRectangle(cornerRadius:10))
|
||||
Text(title).font(.headline).lineLimit(2).frame(height:40,alignment:.topLeading)
|
||||
HStack(spacing:8) {
|
||||
if game.macApp != nil {Label("Mac",systemImage:"desktopcomputer").foregroundStyle(accent)}
|
||||
if game.iosIPA != nil {Label("iPhone / iPad",systemImage:"iphone").foregroundStyle(accent)}
|
||||
if !game.ready {Label(text(game.missing ? "Carpeta no disponible" : "Sin preparar"),systemImage:game.missing ? "externaldrive.badge.exclamationmark" : "circle.dashed").foregroundStyle(.secondary)}
|
||||
}.font(.caption)
|
||||
HStack {
|
||||
Button(text(game.macApp != nil ? "Abrir juego" : game.iosIPA != nil ? "Instalar" : "Preparar"),action:open).buttonStyle(LibraryPrimaryButtonStyle()).disabled(disabled || game.missing)
|
||||
Spacer()
|
||||
Menu {
|
||||
if game.macApp != nil && game.iosIPA != nil {Button(text("Instalar en iPhone / iPad"),action:install).disabled(disabled)}
|
||||
Button(text("Preparar o actualizar"),action:prepare).disabled(disabled || game.missing)
|
||||
Button(text("Mostrar carpeta")){NSWorkspace.shared.activateFileViewerSelecting([game.folder])}
|
||||
Button(text("Quitar de esta lista"),role:.destructive,action:remove).disabled(disabled)
|
||||
} label:{Image(systemName:"ellipsis")}.menuStyle(.borderlessButton).fixedSize().accessibilityLabel(text("Acciones del juego"))
|
||||
}
|
||||
}.padding(13).background(.white.opacity(0.035),in:RoundedRectangle(cornerRadius:15)).overlay(RoundedRectangle(cornerRadius:15).strokeBorder(.white.opacity(0.055)))
|
||||
}
|
||||
}
|
||||
|
||||
struct BuildDock:View {
|
||||
@ObservedObject var model:BuildModel
|
||||
let show:()->Void
|
||||
var body:some View {
|
||||
HStack(spacing:20) {
|
||||
VStack(alignment:.leading,spacing:5) {
|
||||
HStack {Image(systemName:model.failed ? "exclamationmark.triangle" : "hammer");Text(model.gameName).font(.headline);Text(model.isMac ? "Mac" : "iPhone / iPad").font(.caption).foregroundStyle(.secondary)}
|
||||
Text(!model.running ? model.text(model.phase) : [model.activity.stage,model.activity.step,model.activity.count].filter{!$0.isEmpty}.joined(separator:" · ")).font(.caption).lineLimit(1).foregroundStyle(.secondary)
|
||||
}.frame(maxWidth:.infinity,alignment:.leading)
|
||||
if model.running {
|
||||
VStack(alignment:.leading,spacing:5) {
|
||||
if let fraction=model.activity.fraction {ProgressView(value:fraction)}else{ProgressView().progressViewStyle(.linear)}
|
||||
Text(model.text("Etapa actual")+(model.activity.fraction.map{" · "+String(Int($0*100))+" %"} ?? "")+(model.activity.eta.map{" · ~"+String(Int($0))+" s"} ?? "")).font(.caption2).foregroundStyle(.secondary)
|
||||
}.frame(width:220)
|
||||
}
|
||||
Button(model.text("Ver actividad"),action:show)
|
||||
if model.running {Button(model.text("Cancelar")){model.cancel()}}
|
||||
}.padding(.horizontal,24).padding(.vertical,16)
|
||||
}
|
||||
}
|
||||
|
||||
struct BuildTimeline:View {
|
||||
let activity:BuildActivity
|
||||
let text:(String)->String
|
||||
var body:some View {
|
||||
VStack(alignment:.leading,spacing:12) {
|
||||
HStack {Text(text("Etapas de compilación")).font(.headline);Spacer();if activity.stages>0 {Text(String(activity.history.filter{$0.state == .done && $0.number>0}.count)+" / "+String(activity.stages)).font(.caption).monospacedDigit().foregroundStyle(.secondary)}}
|
||||
ForEach(activity.history) {stage in
|
||||
HStack(alignment:.top,spacing:10) {
|
||||
VStack(spacing:4) {Image(systemName:stage.state == .done ? "checkmark.circle.fill" : stage.state == .failed ? "xmark.circle.fill" : stage.state == .cancelled ? "pause.circle" : "circle.inset.filled").foregroundStyle(stage.state == .failed ? Color.orange : stage.state == .done ? Color.green : Color.primary)
|
||||
if stage.id != activity.history.last?.id {Rectangle().fill(.secondary.opacity(0.3)).frame(width:1,height:14)}
|
||||
}
|
||||
VStack(alignment:.leading,spacing:3) {Text(stage.name).font(.callout.weight(stage.state == .active ? .semibold : .regular));if stage.state == .active && !activity.step.isEmpty {Text(activity.step).font(.caption).foregroundStyle(.secondary)}}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
if let eta=activity.eta {Text(text("Tiempo restante informado")+": ~"+String(Int(eta))+" s").font(.caption).foregroundStyle(.secondary)}
|
||||
}.padding(16).background(.white.opacity(0.035),in:RoundedRectangle(cornerRadius:12))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct LibraryPrimaryButtonStyle:ButtonStyle {
|
||||
@Environment(\.isEnabled) private var enabled
|
||||
func makeBody(configuration:Configuration)->some View {
|
||||
configuration.label.font(.system(size:13,weight:.semibold))
|
||||
.padding(.horizontal,13).padding(.vertical,8).frame(minHeight:34)
|
||||
.foregroundStyle(enabled ? Color.black : Color.secondary)
|
||||
.background(enabled ? Color(red:0.79,green:0.95,blue:0.37) : Color.white.opacity(0.065),in:RoundedRectangle(cornerRadius:8))
|
||||
.opacity(configuration.isPressed ? 0.8 : 1)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,38 @@ enum EasyLanguage: String, CaseIterable {
|
||||
|
||||
struct EasyStrings {
|
||||
static let english: [String:String] = [
|
||||
"Dispositivos":"Devices",
|
||||
"Compatibilidad":"Compatibility",
|
||||
"Juegos compatibles":"Compatible games",
|
||||
"Mi biblioteca":"My library",
|
||||
"Tus juegos. Tus archivos. Todo en un lugar.":"Your games. Your files. One place.",
|
||||
"Añadir juego":"Add game",
|
||||
"Buscar en mis juegos":"Search my games",
|
||||
"juegos":"games",
|
||||
"Actualizar biblioteca":"Refresh library",
|
||||
"Tu colección empieza con tu primer juego":"Your collection starts with your first game",
|
||||
"Añade su carpeta. Conservamos los archivos donde están y recordamos tus versiones para Mac, iPhone y iPad.":"Add its folder. Files stay where they are, and your Mac, iPhone and iPad builds stay together.",
|
||||
"Ver juegos compatibles":"View compatible games",
|
||||
"No hay juegos que coincidan con tu búsqueda.":"No games match your search.",
|
||||
"Esta biblioteca sólo contiene las carpetas que tú añades. No incluye ni descarga juegos.":"This library only contains folders you add. It does not include or download games.",
|
||||
"¿Quitar de la biblioteca?":"Remove from library?",
|
||||
"Sólo quitamos el acceso de la biblioteca. No borramos juegos, cachés, llaves ni partidas.":"This removes the library shortcut only. Games, caches, keys and saves stay intact.",
|
||||
"No pudimos leer tu biblioteca. Conservamos el archivo original sin sobrescribirlo.":"Could not read your library. The original file was preserved without overwriting it.",
|
||||
"No pudimos guardar la biblioteca. Tus archivos no se modificaron.":"Could not save the library. Your files were not changed.",
|
||||
"No pudimos añadir esa carpeta a la biblioteca. Tus archivos siguen intactos.":"Could not add that folder to the library. Your files remain intact.",
|
||||
"Compilando":"Building",
|
||||
"Carpeta no disponible":"Folder unavailable",
|
||||
"Sin preparar":"Not prepared",
|
||||
"Instalar":"Install",
|
||||
"Preparar":"Prepare",
|
||||
"Preparar o actualizar":"Prepare or update",
|
||||
"Mostrar carpeta":"Show folder",
|
||||
"Acciones del juego":"Game actions",
|
||||
"Etapa actual":"Current stage",
|
||||
"Ver actividad":"View activity",
|
||||
"Etapas de compilación":"Build stages",
|
||||
"Tiempo restante informado":"Reported time remaining",
|
||||
|
||||
"Mostrar más apps":"Show more apps",
|
||||
"Conecta tu dispositivo e inicia sesión. Firmar e instalar requiere tu confirmación.":"Connect your device and sign in. Signing and installing require your confirmation.",
|
||||
"Guarda y cierra el juego antes de instalar. Firma, copia y renovación nativas aún requieren validación completa con una cuenta y dispositivo reales.":"Save and close the game before installing. Native signing, transfer and refresh still need complete validation with a real account and device.",
|
||||
|
||||
@@ -406,7 +406,7 @@ struct SideloadLibraryView:View {
|
||||
VStack(spacing:0) {
|
||||
HStack(alignment:.top) {
|
||||
VStack(alignment:.leading,spacing:6) {
|
||||
Label(text("Mis iPhones y iPads"),systemImage:"iphone.and.ipad").font(.title2.bold())
|
||||
Label(text("Mis iPhones y iPads"),systemImage:"ipad.and.iphone").font(.title2.bold())
|
||||
Text(text("Conecta. Elige tu juego. Instala o actualiza.")).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer();Button(text("Cerrar")){dismiss()}.disabled(model.busy)
|
||||
|
||||
@@ -6,13 +6,13 @@ import Darwin
|
||||
|
||||
@MainActor final class BuildModel: ObservableObject {
|
||||
@Published var language: EasyLanguage = EasyLanguage.load() {
|
||||
didSet {language.save()}
|
||||
didSet {language.save(defaults:defaults)}
|
||||
}
|
||||
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()}}
|
||||
@Published var splitIOS=true {didSet {defaults.set(splitIOS,forKey:"iosSplitGameData");output=nil;assets=nil;restoreOutput()}}
|
||||
var isMac: Bool {target == "mac"}
|
||||
@Published var folder: URL?
|
||||
@Published var keysFolder: URL?
|
||||
@@ -41,14 +41,18 @@ import Darwin
|
||||
private var process: Process?
|
||||
private var cancelled = false
|
||||
let repo: URL
|
||||
init() {
|
||||
let defaults:UserDefaults
|
||||
init(defaults:UserDefaults = .standard,sourceRepo:URL?=nil) {
|
||||
self.defaults=defaults
|
||||
language=EasyLanguage.load(defaults:defaults)
|
||||
let configured=Bundle.main.object(forInfoDictionaryKey:"NxNativeSource") as? String ?? ""
|
||||
repo=URL(fileURLWithPath:configured)
|
||||
repo=sourceRepo ?? URL(fileURLWithPath:configured)
|
||||
splitIOS=defaults.object(forKey:"iosSplitGameData") as? Bool ?? true
|
||||
refreshCatalog()
|
||||
if let path=UserDefaults.standard.string(forKey:"keysDirectory"), FileManager.default.fileExists(atPath:path+"/prod.keys") {
|
||||
if let path=defaults.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) {
|
||||
if let path = defaults.string(forKey:"lastGameFolder"), FileManager.default.fileExists(atPath:path) {
|
||||
folder=URL(fileURLWithPath:path)
|
||||
restoreOutput()
|
||||
}
|
||||
@@ -77,7 +81,7 @@ import Darwin
|
||||
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")
|
||||
folder=url;defaults.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 }
|
||||
@@ -94,7 +98,7 @@ import Darwin
|
||||
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")
|
||||
keysFolder=resolved;defaults.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() {
|
||||
@@ -147,7 +151,7 @@ import Darwin
|
||||
let status=task.terminationStatus
|
||||
Task { @MainActor in
|
||||
guard let self else{return}
|
||||
self.activity.finish(logURL:self.logURL);self.transcript=self.activity.transcript
|
||||
self.activity.finish(logURL:self.logURL);self.activity.complete(exitCode:status,cancelled:self.cancelled);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."
|
||||
@@ -300,7 +304,7 @@ enum AppArtwork {
|
||||
struct ContentView: View {
|
||||
@ObservedObject var updater: AppUpdater
|
||||
@ObservedObject var sideload:SideloadModel
|
||||
@StateObject private var model=BuildModel()
|
||||
@ObservedObject 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)
|
||||
@@ -310,19 +314,6 @@ struct ContentView: View {
|
||||
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) {
|
||||
@@ -363,20 +354,6 @@ struct ContentView: View {
|
||||
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))
|
||||
@@ -518,6 +495,7 @@ struct ContentView: View {
|
||||
}.padding(22).frame(maxWidth:.infinity,alignment:.leading)
|
||||
.background(lime.opacity(0.045),in:RoundedRectangle(cornerRadius:16))
|
||||
}
|
||||
if !model.activity.history.isEmpty {BuildTimeline(activity:model.activity,text:model.text)}
|
||||
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) {
|
||||
@@ -533,7 +511,7 @@ struct ContentView: View {
|
||||
}
|
||||
}.padding(28)
|
||||
}
|
||||
}.frame(minWidth:760,minHeight:530).background(CompactWindow(height:(model.isMac ? 740 : 680)+(updater.visible ? 95 : 0)+(sideload.showsRefreshNotice ? 70 : 0))).preferredColorScheme(.dark)
|
||||
}.frame(minWidth:760,minHeight:530).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
|
||||
@@ -614,15 +592,17 @@ struct GameCatalogView: View {
|
||||
@main struct NxNativeEasyApp: App {
|
||||
@NSApplicationDelegateAdaptor(LauncherDelegate.self) private var delegate
|
||||
@StateObject private var updater=AppUpdater()
|
||||
@StateObject private var model=BuildModel()
|
||||
@StateObject private var library=GameLibrary()
|
||||
@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 {
|
||||
LibraryWorkspace(model:model,library:library,updater:updater,sideload:sideload).onAppear {
|
||||
NSApplication.shared.applicationIconImage=AppArtwork.icon
|
||||
}
|
||||
}
|
||||
.defaultSize(width:820,height:740)
|
||||
.defaultSize(width:1060,height:700)
|
||||
.commands {
|
||||
CommandMenu(EasyStrings.text("Herramientas de análisis",language:EasyLanguage(rawValue:appLanguage) ?? .english)) {
|
||||
Button(EasyStrings.text("Analizar con REA",language:EasyLanguage(rawValue:appLanguage) ?? .english)) {
|
||||
|
||||
@@ -34,6 +34,15 @@ import Foundation
|
||||
assert(PreviousPreparation.load(folder:folder,target:"mac")?.bundleID=="org.mac.same")
|
||||
try Data("{\"schema\":99,\"bundle_id\":\"unknown\"}".utf8).write(to:folder.appendingPathComponent("nxnative-ios.json"))
|
||||
assert(PreviousPreparation.load(folder:folder,target:"ios")==nil)
|
||||
print("Build activity PASS: split UTF-8/JSON, real fractions, bounded transcript, partial final line and target-specific previous preparation")
|
||||
var stream=BuildActivity()
|
||||
stream.receive(Data("{\"ev\":\"stage\",\"k\":1,\"n\":2,\"name\":\"Extract\"}\n{\"ev\":\"stage_end\",\"ok\":true}\n{\"ev\":\"stage\",\"k\":2,\"n\":3,\"name\":\"Rewrite\"}\n{\"ev\":\"progress\",\"frac\":0.25,\"eta\":42,\"est\":true}\n".utf8))
|
||||
assert(stream.stages==3 && stream.history.count==2 && stream.history[0].state == .done && stream.eta==42 && stream.estimated)
|
||||
stream.receive(Data("{\"ev\":\"progress\",\"eta\":1e100}\n".utf8));assert(stream.eta==nil)
|
||||
stream.receive(Data("{\"ev\":\"step\",\"step\":\"Next step\"}\n".utf8));assert(stream.eta==nil && stream.fraction==nil && !stream.estimated)
|
||||
try Data("{\"ev\":\"say\",\"msg\":\"Tail only\"}\n".utf8).write(to:log)
|
||||
stream.finish(logURL:log);assert(stream.history.count==2 && stream.history[0].state == .done)
|
||||
stream.complete(exitCode:1);assert(stream.history[1].state == .failed)
|
||||
var cancelled=BuildActivity();cancelled.receive(Data("{\"ev\":\"stage\",\"k\":1,\"n\":2,\"name\":\"Compile\"}\n".utf8));cancelled.complete(exitCode:130,cancelled:true);assert(cancelled.history[0].state == .cancelled)
|
||||
print("Build activity PASS: dynamic stage totals, real ETA, reset between steps, retained timeline after tail replay, failed/cancelled outcomes; split UTF-8/JSON, real fractions, bounded transcript, partial final line and target-specific previous preparation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
@main struct LibraryCheck {
|
||||
@MainActor static func main() throws {
|
||||
let root=FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
|
||||
defer {try? FileManager.default.removeItem(at:root)}
|
||||
let folder=root.appendingPathComponent("Dread"),storage=root.appendingPathComponent("private/library.json")
|
||||
try FileManager.default.createDirectory(at:folder.appendingPathComponent("010093801237C000_build"),withIntermediateDirectories:true)
|
||||
try Data("synthetic input".utf8).write(to:folder.appendingPathComponent("owned.nsp"))
|
||||
let original=try Data(contentsOf:folder.appendingPathComponent("owned.nsp"))
|
||||
let library=GameLibrary(file:storage)
|
||||
let id=try library.add(folder)
|
||||
let alias=root.appendingPathComponent("alias");try FileManager.default.createSymbolicLink(at:alias,withDestinationURL:folder)
|
||||
let duplicate=try library.add(alias);assert(duplicate==id)
|
||||
assert(library.games.count==1 && library.games[0].titleID=="010093801237C000" && !library.games[0].ready)
|
||||
let ipa=folder.appendingPathComponent("game.ipa");try Data("synthetic archive".utf8).write(to:ipa)
|
||||
try JSONSerialization.data(withJSONObject:["schema":1,"packaging":"full","full_ipa":ipa.path,"bundle_id":"org.synthetic.dread"]).write(to:folder.appendingPathComponent("nxnative-ios.json"))
|
||||
library.refresh();assert(library.games[0].iosIPA==ipa && library.games[0].romfs==nil)
|
||||
let app=folder.appendingPathComponent("Dread.app")
|
||||
try FileManager.default.createDirectory(at:app.appendingPathComponent("Contents/MacOS"),withIntermediateDirectories:true)
|
||||
try Data().write(to:app.appendingPathComponent("Contents/MacOS/dmhost"))
|
||||
try FileManager.default.createDirectory(at:app.appendingPathComponent("Contents/Resources/romfs"),withIntermediateDirectories:true)
|
||||
try PropertyListSerialization.data(fromPropertyList:["CFBundleIdentifier":"org.synthetic.dread","DMTitleID":"010093801237C000"],format:.xml,options:0).write(to:app.appendingPathComponent("Contents/Info.plist"))
|
||||
try JSONSerialization.data(withJSONObject:["schema":1,"app_path":app.path,"bundle_id":"org.synthetic.dread"]).write(to:folder.appendingPathComponent("nxnative-mac.json"))
|
||||
let restored=GameLibrary(file:storage)
|
||||
assert(restored.games.count==1 && restored.games[0].macApp?.path==app.path && restored.games[0].iosIPA==ipa)
|
||||
try FileManager.default.removeItem(at:ipa);restored.refresh();assert(restored.games[0].iosIPA==nil && restored.games[0].macApp?.path==app.path)
|
||||
let moved=root.appendingPathComponent("moved");try FileManager.default.moveItem(at:folder,to:moved)
|
||||
restored.refresh();assert(restored.games.count==1 && restored.games[0].missing)
|
||||
restored.remove(id);assert(restored.games.isEmpty)
|
||||
let retained=try Data(contentsOf:moved.appendingPathComponent("owned.nsp"));assert(retained==original)
|
||||
assert(FileManager.default.fileExists(atPath:moved.appendingPathComponent("Dread.app").path))
|
||||
let permissions=try FileManager.default.attributesOfItem(atPath:storage.path)[.posixPermissions] as! NSNumber
|
||||
assert(permissions.intValue==0o600)
|
||||
try Data("broken private metadata".utf8).write(to:storage)
|
||||
let broken=GameLibrary(file:storage);assert(broken.error != nil)
|
||||
do {_=try broken.add(moved);assertionFailure("must preserve malformed library")}catch{}
|
||||
let preserved=try String(contentsOf:storage,encoding:.utf8);assert(preserved=="broken private metadata")
|
||||
print("Library PASS: canonical folder deduplication, persisted Mac+iOS outputs, missing-file refresh, unavailable-folder retention, metadata-only removal and malformed-file preservation")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.9.0",
|
||||
"build": 14,
|
||||
"version": "1.10.0",
|
||||
"build": 15,
|
||||
"minimumMacOSMajor": 26
|
||||
}
|
||||
@@ -20,6 +20,12 @@ appear everywhere without being forgotten. No recurring schedule is implied.
|
||||
ARM64 signing/install helper and explicit personal-team selection, explicit
|
||||
external key selection, unsigned/lightweight iOS output and complete Mac apps.
|
||||
Keep personal commit attribution, original upstream authors and licensing.
|
||||
Preserve the personal folder library, both-platform output discovery and
|
||||
metadata-only removal. Keep one shared preparation model alive while browsing.
|
||||
Use structured build events for stages/counts/ETA; never guess overall progress
|
||||
or runtime state from diagnostic log wording. Retain history on log-tail replay.
|
||||
Follow the optional launcher-metadata contract; keep game settings delegated to
|
||||
the built app unless implementing that contract explicitly.
|
||||
Preserve the isideload exact-identifier patch, signed/profile identity checks,
|
||||
Keychain session storage, redacted stdio and RomFS-only transfer capability.
|
||||
Preserve UDID deduplication (USB preferred), device metadata/search/update
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Personal library and structured build activity
|
||||
|
||||
## Reviewed references
|
||||
|
||||
The owner requested a library inspired by the organization of
|
||||
[NX2Apple](https://github.com/LNPANDY/NX2Apple/tree/0313148688ec6e7954e4d299d26a9a91d19fe7dc)
|
||||
and the upstream [frontend contract](https://github.com/seedds/nxnative/blob/ceaf6d6ee21bdfb986e9b6f97cbb1a7ce539991d/docs/frontends.md).
|
||||
The review covered NX2Apple's Swift library/launcher, progress model, screenshot
|
||||
and MIT license. The library, navigation and timeline here are our own SwiftUI
|
||||
implementation: no source, fonts, artwork, layout or visual assets were copied
|
||||
from that frontend. Cards reuse this fork's existing catalog artwork.
|
||||
These are reviewed reference revisions, not new integrated engine revisions.
|
||||
No upstream runtime merge or unsupported game announcement is implied.
|
||||
|
||||
The useful separation is a persistent collection, a preparation workspace and
|
||||
activity visible while browsing. Upstream's JSON stream, not human log wording,
|
||||
is the interface for stages/counts/ETA. A Mac game is opened as its built app;
|
||||
its own launcher remains responsible for supported settings and stored choices.
|
||||
We do not set DM_LAUNCHER=0, synthesize settings for missing launcher metadata or
|
||||
write another preference store. Game-runtime text logs remain diagnostics rather
|
||||
than an API for live game/controller state.
|
||||
|
||||
## Player flow
|
||||
|
||||
1. **Add game** selects one existing game folder. The same preparation form asks
|
||||
for Mac or iPhone/iPad and the user's own keys. This is not a downloader.
|
||||
2. The personal library remembers folders across launches, including the previous
|
||||
game folder from an older launcher. It only links paths and small metadata;
|
||||
it never moves game inputs, build caches, keys or saves.
|
||||
3. Each card resolves its title/art from the local accepted profile catalog when
|
||||
possible and shows the outputs actually found: complete Mac app, iOS IPA or
|
||||
unprepared/unavailable folder. A prepared IPA means ready for installation,
|
||||
not proof that the game is installed or plays on a phone.
|
||||
4. **Open game** opens an existing Mac game. **Install** opens our native installer
|
||||
for an existing iOS output. If both platforms exist, the menu also offers iOS
|
||||
installation. **Prepare or update** returns to the common form and preserves
|
||||
existing preparation identities. Native authentication/install validation
|
||||
limits in [sideloading](sideloading.md) still apply.
|
||||
5. Browsing and searching remain available while a build runs; switching the
|
||||
active folder, rebuilding, removing entries, signing or updating the launcher
|
||||
waits for the current operation. There is one active build, not a hidden queue.
|
||||
6. **Remove from library** removes only the shortcut after confirmation. Source
|
||||
folders, outputs, save data and caches remain. Unavailable external drives or
|
||||
deleted outputs stay listed honestly; refreshing/reopening rechecks readiness.
|
||||
|
||||
The compatibility catalog is a separate action. An accepted game profile does
|
||||
not automatically appear as an owned game, and the library supplies no game files.
|
||||
|
||||
## Persistence
|
||||
|
||||
`~/Library/Application Support/nxnative Easy/library.json` is a schema-1 private
|
||||
index of item IDs, canonical folder paths and dates. No game bytes, encryption
|
||||
keys, Apple credentials or cloud-save packages are stored there. Writes are
|
||||
atomic with a private directory and 0600 file. Symlink aliases deduplicate to
|
||||
one folder. Invalid/unsupported metadata is preserved and disables overwriting
|
||||
that index; the app does not silently replace it with an empty library.
|
||||
|
||||
Output discovery reads our per-folder `nxnative-mac.json`/`nxnative-ios.json`,
|
||||
bounded metadata and Mac bundle identity/host/embedded-data presence. It does not recursively
|
||||
scan drives or open packages from unrelated folders. Both platform outputs can
|
||||
coexist; a missing output is no longer offered as ready. The library adds no
|
||||
new game settings or cloud synchronization.
|
||||
|
||||
## Build activity
|
||||
|
||||
The dock stays visible on the library and preparation pages with the current
|
||||
game/platform, stage, counters, current-stage fraction and callback ETA. A fraction
|
||||
is explicitly for the current stage, not a fabricated overall percentage. Unknown
|
||||
work is indeterminate. Finished/cancelled/failed results stay reachable through
|
||||
**View activity** until a new build begins.
|
||||
|
||||
The preparation timeline uses `stage`, `step`, `progress` and `stage_end` events;
|
||||
`n` may grow and a new step resets counters/ETA. Only reported completions are
|
||||
marked done while running. Process exit/cancellation closes the remaining active
|
||||
stages appropriately. Non-finite/out-of-range fractions and unsafe ETA values are
|
||||
ignored. Retained log-tail replay merges the known timeline, rather than forgetting
|
||||
earlier stages. The existing worker still captures stderr, forwards cancellation
|
||||
to the whole process group and preserves original compiler logs. No new inferred
|
||||
runtime events or game-log text parsing were added.
|
||||
|
||||
## Validation boundary
|
||||
|
||||
Foundation fixtures cover private index persistence, folder alias deduplication,
|
||||
both platform outputs, missing output/folder handling, metadata-only removal and
|
||||
malformed-index preservation. Structured progress fixtures cover growing totals,
|
||||
actual ETA, resets, retained history, invalid values and failed/cancelled exit.
|
||||
Offscreen visual checks use synthetic folder/output metadata and existing artwork;
|
||||
they do not build, install or play a game. Production compilation/signature checks
|
||||
validate the launcher bundle, not Apple login or game/device compatibility.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 884 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 892 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 858 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 866 KiB |
@@ -461,3 +461,27 @@ login, registration query, signing or installation was performed. The Mac
|
||||
regression harness was invoked but found no registered Mac Dread build; no
|
||||
physical gameplay or full/light migration comparison is claimed. The shader
|
||||
chain is unchanged. Cloud work remains paused; saves remain local/manual.
|
||||
|
||||
## Personal library and frontend progress (1.10)
|
||||
|
||||
Reviewed NX2Apple's library/task organization at 0313148688ec and upstream's
|
||||
frontend contract at ceaf6d6ee21b; no engine revision was integrated by this
|
||||
frontend change. The [library guide](frontend-library.md) records references,
|
||||
own implementation, data boundaries and deferred settings/runtime-event work.
|
||||
The launcher now starts at a personal folder library, with accepted local artwork,
|
||||
search, separate Mac/iOS output readiness and open/install/update actions.
|
||||
Preparation remains one live model across navigation. The dock and timeline use
|
||||
structured counts, dynamic stage totals, source ETA and process exit/cancellation;
|
||||
log-tail replay retains known stages. Library removal never deletes game files.
|
||||
|
||||
Validation: all 65 script cases completed with one optional identity-dependency
|
||||
skip. Compiled Foundation fixtures exercise persistence, deduplication, both
|
||||
platform outputs, unavailable/missing paths, malformed-index preservation and
|
||||
non-destructive removal, plus dynamic progress/ETA/exit/tail cases. The production
|
||||
SwiftUI bundle compiled and its ad-hoc signature verified. English/Spanish
|
||||
offscreen renders used synthetic metadata, not live game builds; both READMEs
|
||||
include current, explicitly labelled examples. Documentation/catalog gates passed
|
||||
and Python lint introduced no new findings. The game regression harness was
|
||||
invoked but found no registered Mac Dread build, so no new gameplay result is
|
||||
claimed. No host, shader-chain, game, key or save data was changed; native signing
|
||||
and cloud-sync validation limits remain unchanged.
|
||||
@@ -49,7 +49,7 @@ def install_bundle(staged,output):
|
||||
def build_bundle(output):
|
||||
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/BuildActivity.swift'),str(REPO/'desktop/NxNativeEasy/GameAnalysis.swift'),str(REPO/'desktop/NxNativeEasy/Sideloading.swift'),str(REPO/'desktop/NxNativeEasy/SavesHelp.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'),
|
||||
str(REPO/'desktop/NxNativeEasy/main.swift'),str(REPO/'desktop/NxNativeEasy/GameLibrary.swift'),str(REPO/'desktop/NxNativeEasy/LibraryWorkspace.swift'),str(REPO/'desktop/NxNativeEasy/Localization.swift'),str(REPO/'desktop/NxNativeEasy/BuildActivity.swift'),str(REPO/'desktop/NxNativeEasy/GameAnalysis.swift'),str(REPO/'desktop/NxNativeEasy/Sideloading.swift'),str(REPO/'desktop/NxNativeEasy/SavesHelp.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)
|
||||
from build_sideload_helper import build as build_helper
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Exercise the real local library store with synthetic folders; never launch a game."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
|
||||
@unittest.skipUnless(sys.platform=='darwin','Swift Foundation model')
|
||||
class GameLibrary(unittest.TestCase):
|
||||
def test_persistence_outputs_and_non_destructive_removal(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
executable=Path(temporary)/'library'
|
||||
subprocess.run(['xcrun','swiftc','-parse-as-library','-swift-version','5',
|
||||
str(ROOT/'desktop/NxNativeEasy/BuildActivity.swift'),str(ROOT/'desktop/NxNativeEasy/GameLibrary.swift'),
|
||||
str(ROOT/'desktop/NxNativeEasy/tests/library_check.swift'),'-o',str(executable)],check=True)
|
||||
subprocess.run([str(executable)],check=True,timeout=20)
|
||||
Reference in new issue
Block a user