Add personal game library and structured build activity workspace

This commit is contained in:
Erick Sanchez committed 2026-10-27 11:15:32 -06:00
1 parent 7e3fc97a18
commit 08f1d5665a
20 files changed
+642 -56

No files matched your search

+23 -4
View File
@@ -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.
![Nuestra app para preparar juegos](docs/images/nxnative-mac-es-v1.4.png)
![Biblioteca personal de juegos](docs/images/nxnative-library-es-v1.10.png)
*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).
![La compilación sigue visible al navegar](docs/images/nxnative-build-dock-es-v1.10.png)
*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
+23 -4
View File
@@ -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.
![The nxnative Easy desktop app](docs/images/nxnative-mac-v1.4.png)
![The personal game library](docs/images/nxnative-library-en-v1.10.png)
*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).
![Build activity remains visible while browsing](docs/images/nxnative-build-dock-en-v1.10.png)
*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
+34 -3
View File
@@ -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)}
}
+111
View File
@@ -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()
}
}
+208
View File
@@ -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)
}
}
+32
View File
@@ -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.",
+1 -1
View File
@@ -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)
+20 -40
View File
@@ -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")
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"version": "1.9.0",
"build": 14,
"version": "1.10.0",
"build": 15,
"minimumMacOSMajor": 26
}
+6
View File
@@ -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
+89
View File
@@ -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

+24
View File
@@ -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.
+1 -1
View File
@@ -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
+17
View File
@@ -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)