Add iPhone and iPad installation library and manual signature renewal

This commit is contained in:
Erick Sanchez committed 2026-10-27 08:16:30 -06:00
1 parent 1d68110b87
commit d93640bb78
9 files changed
+246 -31

No files matched your search

+30 -10
View File
@@ -28,7 +28,7 @@ Cuando abra la app:
**Mac:** crea una sola `.app` completa, con todos los datos del juego dentro; pulsa **Abrir juego**. No necesitas copiar una carpeta RomFS aparte. No necesitas Sideloadly ni un iPhone. Las partidas quedan en `~/Library/Application Support/nxnative/<title ID>/`.
**iPhone / iPad:** crea una IPA ligera sin firmar. Instálala con Sideloadly y copia los datos del juego como te indica la app. [Ver pantalla de preparación para iPhone/iPad](docs/images/nxnative-ios-es-v1.4.png).
**iPhone / iPad:** crea una IPA ligera sin firmar. Pulsa **Instalar en iPhone / iPad** para firmarla, instalarla y copiar los datos desde nuestra app. El flujo nativo es experimental; Sideloadly sigue disponible como alternativa probada. [Ver pantalla de preparación para iPhone/iPad](docs/images/nxnative-ios-es-v1.4.png).
**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.
@@ -38,13 +38,13 @@ Cuando abra la app:
Partimos de [seedds/nxnative](https://github.com/seedds/nxnative): el motor, los perfiles y la compatibilidad son trabajo del proyecto original. **nxnative Easy** añade:
- **App para Mac:** elegir juego, avance, cancelación y acceso a IPA, datos y Sideloadly.
- **App para Mac:** preparación del juego, avance, cancelación e instalador integrado con gestión de juegos y dispositivos.
- **Controles editables en iPhone:** posición, tamaño y opacidad, guardados por juego y layout.
- **IPA ligera sólo para iPhone/iPad:** los datos `romfs` van aparte; se conservan salidas y cachés válidas.
- **Llaves del usuario:** debes seleccionar la carpeta de tus propias `prod.keys`. No incluimos, descargamos ni distribuimos llaves o juegos.
- **Preparación local:** sin cuenta Apple, sin certificados de empresa y sin servicio que reciba tus archivos.
Entregamos **código, una interfaz de preparación y herramientas de compilación**. El resultado es una app local para Mac o una IPA para iPhone/iPad sin firmar, que firmas e instalas con Sideloadly. No ofrecemos juegos, llaves, firma de Apple ni compatibilidad garantizada con cualquier juego.
Entregamos **código, una interfaz de preparación y herramientas de compilación**. El resultado es una app local para Mac o una IPA para iPhone/iPad sin firmar, que firmas e instalas con tu cuenta mediante el instalador integrado experimental o Sideloadly. No ofrecemos juegos, llaves, firma de Apple ni compatibilidad garantizada con cualquier juego.
## Actualizaciones del motor y multijugador
@@ -68,13 +68,13 @@ La actualización usa una carpeta separada y guarda la app anterior como respald
## ¿Qué pasa al pulsar Preparar juego?
La app prepara las herramientas y compila para el dispositivo que elegiste. **Mac** genera una app del juego con los botones **Abrir juego** y **Mostrar app**. **iPhone / iPad** genera una IPA ligera y muestra los pasos de Sideloadly y Finder al terminar. Tus archivos y salidas anteriores se conservan en la carpeta del juego.
La app prepara las herramientas y compila para el dispositivo que elegiste. **Mac** genera una app del juego con los botones **Abrir juego** y **Mostrar app**. **iPhone / iPad** genera una IPA ligera y ofrece **Instalar en iPhone / iPad**, incluyendo la copia de datos. Tus archivos y salidas anteriores se conservan en la carpeta del juego.
La preparación conserva una copia separada del código y respeta los cambios locales. Para desarrollo sigue disponible [`scripts/Abrir-interfaz.command`](scripts/Abrir-interfaz.command).
## Cómo se compila la app de Mac
El acceso de apertura usa el compilador Swift de tu Xcode. La firma de la app es **ad hoc**, local y sin cuenta, certificado ni equipo Apple (`TeamIdentifier` no asignado). No usa cuentas de empresa. Sólo al instalar la **IPA de iPhone con Sideloadly** introduces tu cuenta personal, dentro de Sideloadly.
El acceso de apertura usa el compilador Swift de tu Xcode. La firma de la app es **ad hoc**, local y sin cuenta, certificado ni equipo Apple (`TeamIdentifier` no asignado). No usa cuentas de empresa. Sólo al firmar e instalar la **IPA de iPhone/iPad** introduces tu cuenta personal en el instalador integrado, o en Sideloadly si eliges esa alternativa.
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.
@@ -97,27 +97,47 @@ ciertos juegos, `save_device`. En Mac están fuera de la `.app`; en iPhone/iPad,
dentro de cada juego en ese dispositivo. No son `romfs`, ni los perfiles de
controles, ni los archivos JSON de preparación. [Guía detallada de partidas y respaldos](docs/saves-es.md).
## Instalar: Sideloadly + Finder
## Pon tu juego en iPhone o iPad
1. Prepara tu juego y pulsa **Instalar en iPhone / iPad**.
2. Conecta y desbloquea tu dispositivo. Confía en el Mac y activa Modo de desarrollador cuando iOS lo pida.
3. Inicia sesión con tu cuenta Apple, introduce el código y selecciona tu equipo personal.
4. Si ya tienes el juego instalado, elige esa app existente para conservar su identidad. Pulsa **Firmar y actualizar**; no desinstales el juego anterior.
5. Mantén cerrado el juego hasta que termine la firma, instalación y copia de datos. La renovación opcional recuerda la sesión en el Llavero.
En **Mis iPhones y iPads**, arriba en la ventana principal, ves las instalaciones
registradas y la fecha real de vencimiento de su firma. Pulsa **Renovar ahora**
sin recompilar ni copiar RomFS otra vez; también puedes pausar la renovación
por juego. **Revisar cuenta e instalación** recupera su dispositivo, equipo e
identificador. **Usar sesión del Llavero** permite volver a entrar sin escribir
la contraseña cuando esa sesión todavía sea válida. Quitar un registro sólo
detiene su renovación; no desinstala el juego ni elimina partidas.
Este flujo nativo todavía necesita validación con una cuenta y dispositivo
reales. Reemplaza el flujo para nuestros juegos preparados; no es una copia de
todas las funciones propietarias de Sideloadly. La alternativa sigue disponible.
## Alternativa: Sideloadly + Finder
1. Con la IPA lista, conecta tu iPhone y pulsa **Enviar a Sideloadly**. La app abre allí el archivo preparado.
2. Dentro de Sideloadly pulsa **Start** con tu cuenta Apple **personal**. Si hace falta buscar el archivo manualmente, usa **Mostrar IPA**.
3. La primera vez, pulsa **Mostrar datos del juego** y copia la carpeta `romfs` completa por **Finder → iPhone → Archivos → tu juego**.
Para actualizar, usa la misma cuenta y el mismo bundle ID efectivo de Sideloadly. **Instala encima; no desinstales la app**, para conservar tus partidas. Si los datos del juego no cambiaron, puedes reutilizar el `romfs` existente. La app instala las herramientas de compilación que falten después de tener Xcode y Homebrew, pero **no firma ni instala el juego en el iPhone**. Esos pasos los hace Sideloadly. La app los explica cuando la IPA está lista.
Para actualizar, usa la misma cuenta y el mismo bundle ID efectivo de Sideloadly. **Instala encima; no desinstales la app**, para conservar tus partidas. Si los datos del juego no cambiaron, puedes reutilizar el `romfs` existente. En esta alternativa la firma se hace en Sideloadly y la copia en Finder; el instalador integrado realiza esas operaciones desde nuestra app.
## Actualizar un juego preparado
Añade tus propios archivos de actualización a la misma carpeta del juego y
prepáralo de nuevo. La app reconoce la preparación anterior y explica que será
una actualización; no puede inspeccionar lo instalado en tu iPhone. Una versión
una actualización. La preparación no consulta el iPhone; el instalador sí puede listar las apps del dispositivo conectado. Una versión
nueva del juego sigue necesitando soporte compatible del motor y su perfil.
- **Mac:** se crea otra `.app` completa y se conserva la anterior. Cierra el juego
viejo y abre la nueva. Se mantienen el Title ID y la identidad de la app; las
partidas siguen fuera de ella, en Application Support.
- **iPhone / iPad:** instala la IPA encima usando la misma cuenta e identificador
efectivo de Sideloadly. Si cambió el contenido del juego, reemplaza sólo
`romfs` por los datos nuevos desde Finder. Conserva `save`, `save_device` y la
efectivo. El instalador integrado copia el RomFS nuevo mediante preparación
temporal y recuperación; con la alternativa de Sideloadly, usa Finder. Conserva `save`, `save_device` y la
app instalada; no los borres.
Durante la preparación puedes ver la etapa, contadores reales cuando existen,
+29 -9
View File
@@ -38,7 +38,7 @@ When the app opens:
Built on [seedds/nxnative](https://github.com/seedds/nxnative). The original project provides the engine, game profiles and compatibility work. **nxnative Easy** adds:
- **Native Mac app:** game-folder selection, progress, cancellation, and buttons for the IPA, RomFS data and Sideloadly.
- **Native Mac app:** game preparation, progress, cancellation and an integrated iPhone/iPad installer with device/game management.
- **Editable iPhone touch controls:** position, size and opacity, saved per game and 2D/3D layout.
- **Lightweight IPA export for iPhone/iPad only:** RomFS data live separately; existing outputs and valid caches are preserved.
- **Your own keys:** explicitly select the external folder containing your `prod.keys`. No games or keys are included, downloaded or distributed.
@@ -66,13 +66,13 @@ The update uses a separate source folder and keeps the previous app as a backup.
## What happens after Prepare game?
The app prepares the tools and builds for your chosen device. **Mac** produces a game app with **Open game** and **Show app** buttons. **iPhone / iPad** produces a lightweight IPA and shows the Sideloadly and Finder steps when ready. Your own files and previous outputs stay in the game folder.
The app prepares the tools and builds for your chosen device. **Mac** produces a game app with **Open game** and **Show app** buttons. **iPhone / iPad** produces a lightweight IPA and offers **Install on iPhone / iPad**, including game-data transfer. Your own files and previous outputs stay in the game folder.
Setup keeps a separate source checkout and preserves local edits. Developers can still use [`scripts/Abrir-interfaz.command`](scripts/Abrir-interfaz.command).
## How the Mac app is built
The launcher uses Xcode's Swift compiler. The desktop app is explicitly **ad-hoc signed**, locally, with no Apple account, certificate or team (`TeamIdentifier` is unset). No company account is used. You enter your personal Apple account separately in Sideloadly when signing the **iPhone IPA**.
The launcher uses Xcode's Swift compiler. The desktop app is explicitly **ad-hoc signed**, locally, with no Apple account, certificate or team (`TeamIdentifier` is unset). No company account is used. You enter your personal Apple account in the integrated installer when signing the **iPhone/iPad IPA**. Sideloadly remains an alternative.
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.
@@ -91,27 +91,47 @@ paired device. Keep the original IPA. [Implementation and current limits](docs/s
iPhone/iPad Finder instructions. Progress is local to the device, separate from
RomFS and control profiles. [Save locations and backup limits](docs/saves.md).
## Install with Sideloadly and Finder
## Put your game on iPhone or iPad
1. Prepare your game, then choose **Install on iPhone / iPad**.
2. Connect and unlock your device. Trust the Mac and enable Developer Mode when iOS asks.
3. Sign in with your own Apple account, enter the verification code and choose your personal team.
4. If the game is already installed, select that existing app to retain its identity. Choose **Sign and update**; keep the previous app installed.
5. Keep the game closed while signing, installation and game-data copying finish. Optional refresh remembers the session in Keychain.
**My iPhones and iPads**, at the top of the main window, lists our recorded
installations and actual signature expiry dates. Use **Refresh now** without
recompiling or copying RomFS again, or pause automatic renewal per game.
**Review account and installation** restores that game's original device/team/ID;
**Use Keychain session** signs back in without entering the password again when
the remembered session is still valid. Removing a record stops its renewal;
it does not uninstall the game or remove saves.
This native flow still requires real-account/device validation. This is a
replacement workflow for our prepared games, not a copy of all proprietary
Sideloadly features. The fallback below remains available.
## Alternative: Sideloadly and Finder
1. When the IPA is ready, connect your iPhone and click **Send to Sideloadly**. The app opens the prepared IPA there.
2. In Sideloadly, click **Start** using your **personal** Apple account. If needed, use **Show IPA** to find the file manually.
3. On the first installation, click **Show game data** and copy the complete `romfs` folder using **Finder → iPhone → Files → your game**.
For updates, keep the same Apple account and effective bundle ID used in Sideloadly. **Install over the existing app; do not uninstall it**, to retain its saves. Reuse the existing `romfs` only when the game data have not changed. The app installs missing build dependencies after Xcode and Homebrew are available, but **does not sign or install the iPhone game**. Those steps happen in Sideloadly. The app explains them only when the IPA is ready. See the [official Sideloadly FAQ](https://sideloadly.io/faq).
For updates, keep the same Apple account and effective bundle ID used in Sideloadly. **Install over the existing app; do not uninstall it**, to retain its saves. Reuse the existing `romfs` only when the game data have not changed. When using this alternative, signing happens in Sideloadly and copying happens in Finder; the integrated installer performs those operations itself. See the [official Sideloadly FAQ](https://sideloadly.io/faq).
## Updating a prepared game
Add your own update files to the same game folder and prepare it again. The app
recognizes its previous preparation and explains that this is an update; it
cannot inspect what is installed on an iPhone. A new game version still needs
recognizes its previous preparation and explains that this is an update; preparation itself
does not inspect the phone; the connected-device installer lists existing apps. A new game version still needs
compatible engine/profile support.
- **Mac:** a new complete `.app` is created; the previous app remains available.
Close the old game and open the new app. The Title ID and bundle identity are
retained; saves stay outside the app in Application Support.
- **iPhone / iPad:** install the new IPA over the existing app using the same
Sideloadly account and effective identifier. For a game-content update, replace
only `romfs` with the newly prepared data via Finder. Keep `save`, `save_device`
Apple account/team and effective identifier. The integrated installer copies the
updated RomFS with staging/recovery; the Sideloadly alternative uses Finder. Keep `save`, `save_device`
and the installed app; do not delete them.
Optional developer analysis is under **Analysis tools → Analyze with REA**.
+23
View File
@@ -12,6 +12,29 @@ enum EasyLanguage: String, CaseIterable {
struct EasyStrings {
static let english: [String:String] = [
"Mis iPhones y iPads":"My iPhones and iPads",
"Usar sesión del Llavero":"Use Keychain session",
"Inicia sesión con la cuenta que instaló este juego para renovarlo.":"Sign in with the account that installed this game to refresh it.",
"Inicia sesión para renovar este juego.":"Sign in to refresh this game.",
"Falta el registro de la IPA. Prepara y aprueba la actualización antes de renovarla.":"The IPA record is missing. Prepare and approve the update before refreshing.",
"Conecta y desbloquea el dispositivo donde instalaste este juego.":"Connect and unlock the device where you installed this game.",
"La cuenta actual no tiene el equipo original de este juego. No cambiamos su identidad.":"This account does not have the original team for this game. Its identity was preserved.",
"Administra los juegos instalados desde esta app. Renovar la firma no recompila el juego ni copia sus datos otra vez.":"Manage games installed through this app. Refreshing the signature does not rebuild the game or copy its data again.",
"Conecta tu iPhone o iPad":"Connect your iPhone or iPad",
"Dispositivos disponibles":"Available devices",
"Aún no hay juegos registrados. Prepara tu juego y pulsa Instalar en iPhone / iPad. Para una app de Sideloadly, elige su instalación existente y conserva su identidad.":"No games are registered yet. Prepare your game and choose Install on iPhone / iPad. For a Sideloadly app, select its existing installation and keep its identity.",
"Firma vencida":"Signature expired",
"La firma vence":"Signature expires",
"Renovar ahora":"Refresh now",
"Revisar cuenta e instalación":"Review account and installation",
"Quitar de esta lista":"Remove from this list",
"Guarda y cierra el juego antes de renovar manualmente. La renovación automática requiere este Mac despierto, la app abierta, Internet y el dispositivo accesible.":"Save and close the game before refreshing manually. Automatic refresh needs this Mac awake, the app open, Internet and the device reachable.",
"¿Quitar este registro?":"Remove this record?",
"Sólo detiene la renovación y quita el registro de este Mac. El juego, sus archivos y sus partidas siguen en tu dispositivo.":"This only stops refresh and removes the record from this Mac. The game, its files and saves remain on your device.",
"Tu juego está listo para instalar en iPhone / iPad.":"Your game is ready to install on iPhone / iPad.",
"Pulsa Instalar en iPhone / iPad. Inicia sesión con tu cuenta personal para firmar e instalar el juego y sus datos.":"Choose Install on iPhone / iPad. Sign in with your personal account to sign and install the game and its data.",
"Crearás una IPA para actualizar tu app. En Instalar en iPhone / iPad, elige la app existente y su misma cuenta. No desinstales el juego. La preparación no comprueba qué está instalado en tu iPhone.":"You will create an IPA to update your app. In Install on iPhone / iPad, select the existing app and the same account. Keep the game installed. Preparation does not check what is installed on your iPhone.",
"El juego sigue abierto. La renovación espera para no interrumpir tu partida.":"The game is still running. Refresh is postponed to avoid interrupting your progress.",
"No pudimos comprobar si el juego está abierto. Guarda, ciérralo y renueva manualmente.":"Could not verify whether the game is running. Save, close it and refresh manually.",
"Alternativa: Sideloadly":"Alternative: Sideloadly",
+92 -4
View File
@@ -65,6 +65,7 @@ enum RefreshPolicy {
private var installIntent:RefreshJob?
private var activeRefresh:RefreshJob?
private var refreshAuthenticated=false
private var manualRefresh=false
private var pendingRemember:Bool?
var showsRefreshNotice:Bool {jobs.contains(where:{$0.enabled}) && (busy || error != nil)}
private let stateURL:URL
@@ -137,7 +138,7 @@ enum RefreshPolicy {
guard FileManager.default.isReadableFile(atPath:job.ipa) else {error="No encontramos la IPA original. Conserva los archivos de preparación para renovarla.";activeRefresh=nil;return}
if let content,!FileManager.default.fileExists(atPath:content.path) {error="Faltan los datos romfs. Prepara el juego de nuevo antes de instalarlo.";activeRefresh=nil;return}
installIntent=job;busy=true;error=nil;startedAt=Date();phase="Firmando e instalando…";fraction=nil
var value:[String:Any]=["op":"install","ipa":job.ipa,"original_id":job.originalID,"team":job.team,"udid":job.udid,"work":URL(fileURLWithPath:job.gameFolder).appendingPathComponent("nxnative-tools/sideloading").path,"update":updating,"automatic":activeRefresh != nil]
var value:[String:Any]=["op":"install","ipa":job.ipa,"original_id":job.originalID,"team":job.team,"udid":job.udid,"work":URL(fileURLWithPath:job.gameFolder).appendingPathComponent("nxnative-tools/sideloading").path,"update":updating,"automatic":activeRefresh != nil && !manualRefresh]
if updating {value["expected_id"]=job.effectiveID}
if !job.sha256.isEmpty {value["sha256"]=job.sha256}
if let content {value["romfs"]=content.path}
@@ -204,6 +205,33 @@ enum RefreshPolicy {
try FileManager.default.setAttributes([.posixPermissions:0o600],ofItemAtPath:stateURL.path)
} catch {self.error="La instalación terminó, pero no pudimos guardar su renovación automática."}
}
func restoreSession() {
guard !busy,!email.isEmpty,defaults.string(forKey:"sideloadEmail")==email else {return}
busy=true;error=nil;startedAt=Date();phase="Renovando sesión guardada…"
send(["op":"restore","email":email,"anisette_url":anisette])
}
var hasRememberedSession:Bool {defaults.string(forKey:"sideloadEmail")==email && !email.isEmpty}
func select(_ job:RefreshJob) {
guard !busy else {return}
ipa=URL(fileURLWithPath:job.ipa);gameFolder=URL(fileURLWithPath:job.gameFolder);romfs=nil
originalID=job.originalID;device=job.udid;team=job.team;existing=job.effectiveID
}
func setRefresh(_ job:RefreshJob,enabled:Bool) {
guard !busy,let index=jobs.firstIndex(where:{$0.id==job.id}) else {return}
jobs[index].enabled=enabled;saveJobs()
}
func forget(_ job:RefreshJob) {
guard !busy else {return}
jobs.removeAll {$0.id==job.id};saveJobs()
}
func renew(_ job:RefreshJob) {
guard !busy,canRefresh() else {return}
guard job.account==RefreshPolicy.account(email) else {error="Inicia sesión con la cuenta que instaló este juego para renovarlo.";return}
guard hasRememberedSession || authenticated else {error="Inicia sesión para renovar este juego.";return}
guard job.sha256.count==64 else {error="Falta el registro de la IPA. Prepara y aprueba la actualización antes de renovarla.";return}
select(job);activeRefresh=job;manualRefresh=true;refreshAuthenticated=authenticated
error=nil;startedAt=Date();listDevices()
}
func reviewRefresh() {
guard !busy,let job=jobs.first(where:{$0.enabled}) else {return}
ipa=URL(fileURLWithPath:job.ipa);gameFolder=URL(fileURLWithPath:job.gameFolder);romfs=nil;originalID=job.originalID;device=job.udid;team=job.team;existing=job.effectiveID
@@ -212,11 +240,11 @@ enum RefreshPolicy {
func checkRefresh() {
guard !busy,activeRefresh==nil,canRefresh(),!email.isEmpty,defaults.string(forKey:"sideloadEmail") != nil else {return}
guard let job=jobs.first(where:{RefreshPolicy.due($0,now:Date(),account:RefreshPolicy.account(email))}) else {return}
activeRefresh=job;refreshAuthenticated=false;listDevices()
activeRefresh=job;manualRefresh=false;refreshAuthenticated=false;listDevices()
}
private func continueRefresh(_ job:RefreshJob) {
guard devices.contains(where:{$0.id==job.udid}) else {activeRefresh=nil;return}
guard teams.contains(where:{$0.id==job.team}) || !refreshAuthenticated else {activeRefresh=nil;return}
guard devices.contains(where:{$0.id==job.udid}) else {if manualRefresh {error="Conecta y desbloquea el dispositivo donde instalaste este juego."};activeRefresh=nil;return}
guard teams.contains(where:{$0.id==job.team}) || !refreshAuthenticated else {error="La cuenta actual no tiene el equipo original de este juego. No cambiamos su identidad.";activeRefresh=nil;return}
if !refreshAuthenticated {busy=true;phase="Renovando sesión guardada…";send(["op":"restore","email":email,"anisette_url":anisette])}
else {startInstall(job,updating:true,content:nil)}
}
@@ -253,6 +281,7 @@ struct SideloadView:View {
Text(text("La contraseña y el código se envían a Apple. El servicio anisette recibe los datos técnicos que requiere la autenticación, no tu contraseña.")) .font(.caption)
TextField("Anisette HTTPS",text:$model.anisette).disabled(model.busy)
}
if model.hasRememberedSession {Button(text("Usar sesión del Llavero")){model.restoreSession()}.disabled(model.busy)}
Button(text("Iniciar sesión")){model.signIn(password:password,remember:remember);password=""}.disabled(model.busy || model.email.isEmpty || password.isEmpty)
} else {
Label(text("Sesión iniciada"),systemImage:"person.crop.circle.badge.checkmark")
@@ -278,3 +307,62 @@ struct SideloadView:View {
}.frame(width:720,height:690).interactiveDismissDisabled(model.busy)
}
}
struct SideloadLibraryView:View {
@ObservedObject var model:SideloadModel
let text:(String)->String
let canInstall:Bool
@Environment(\.dismiss) private var dismiss
@State private var showInstaller=false
@State private var forgotten:RefreshJob?
var body:some View {
ScrollView {
VStack(alignment:.leading,spacing:18) {
HStack {Text(text("Mis iPhones y iPads")).font(.title2.bold());Spacer();Button(text("Cerrar")){dismiss()}.disabled(model.busy)}
Text(text("Administra los juegos instalados desde esta app. Renovar la firma no recompila el juego ni copia sus datos otra vez."))
.font(.callout).foregroundStyle(.secondary)
HStack {
Label(text(model.devices.isEmpty ? "Conecta tu iPhone o iPad" : "Dispositivos disponibles"),systemImage:"iphone.and.ipad")
Spacer();Button(text("Buscar dispositivos")){model.listDevices()}.disabled(model.busy || !canInstall)
}
ForEach(model.devices) {device in Label(device.name,systemImage:"checkmark.circle").font(.callout)}
if model.jobs.isEmpty {
Text(text("Aún no hay juegos registrados. Prepara tu juego y pulsa Instalar en iPhone / iPad. Para una app de Sideloadly, elige su instalación existente y conserva su identidad."))
.foregroundStyle(.secondary)
}
ForEach(model.jobs.sorted {$0.expires<$1.expires}) {job in
VStack(alignment:.leading,spacing:10) {
Text(URL(fileURLWithPath:job.ipa).deletingPathExtension().lastPathComponent).font(.headline)
Text(job.effectiveID).font(.caption).textSelection(.enabled)
HStack {
Text(text("Dispositivo")+": "+(model.devices.first(where:{$0.id==job.udid})?.name ?? String(job.udid.suffix(8))))
Spacer()
Text(text(job.expires<=Date() ? "Firma vencida" : "La firma vence")+" "+job.expires.formatted(date:.abbreviated,time:.shortened))
}.font(.caption).foregroundStyle(job.expires<=Date() ? .orange : .secondary)
if job.needsLogin {Text(text("Inicia sesión para renovar este juego.")).foregroundStyle(.orange)}
Toggle(text("Renovar automáticamente antes de que caduque"),isOn:Binding(get:{model.jobs.first(where:{$0.id==job.id})?.enabled ?? false},set:{model.setRefresh(job,enabled:$0)}))
HStack {
Button(text("Renovar ahora")){model.renew(job)}.buttonStyle(.borderedProminent)
Button(text("Revisar cuenta e instalación")){model.select(job);showInstaller=true}
Spacer();Button(text("Quitar de esta lista")){forgotten=job}
}
}.padding(16).background(.white.opacity(0.045),in:RoundedRectangle(cornerRadius:12)).disabled(model.busy || !canInstall)
}
Text(text("Guarda y cierra el juego antes de renovar manualmente. La renovación automática requiere este Mac despierto, la app abierta, Internet y el dispositivo accesible."))
.font(.caption).foregroundStyle(.secondary)
if model.busy {
if let fraction=model.fraction {ProgressView(value:fraction)}else{ProgressView().progressViewStyle(.linear)}
Text(text(model.phase));Button(text("Cancelar")){model.cancel()}
}
if let error=model.error {Text(text(error)).foregroundStyle(.orange).textSelection(.enabled)}
}.padding(24)
}.frame(width:760,height:650).interactiveDismissDisabled(model.busy)
.task {if canInstall && !model.busy {model.listDevices()}}
.sheet(isPresented:$showInstaller){SideloadView(model:model,text:text)}
.alert(text("¿Quitar este registro?"),isPresented:Binding(get:{forgotten != nil},set:{if !$0 {forgotten=nil}})) {
Button(text("Cancelar"),role:.cancel){forgotten=nil}
Button(text("Quitar de esta lista"),role:.destructive){if let job=forgotten {model.forget(job)};forgotten=nil}
} message:{Text(text("Sólo detiene la renovación y quita el registro de este Mac. El juego, sus archivos y sus partidas siguen en tu dispositivo."))}
}
}
+7 -4
View File
@@ -32,6 +32,7 @@ import Darwin
@Published var showAnalysis = false
@Published var showSaves = false
@Published var showSideload = false
@Published var showDeviceLibrary = false
@Published var catalog: [CatalogGame] = []
@Published var catalogFailed = false
private var process: Process?
@@ -150,8 +151,8 @@ import Darwin
} else if status==0 {
self.restoreOutput()
self.failed=self.output==nil
self.phase=self.output == nil ? "Terminó, pero no encontramos la app." : (self.isMac ? "Tu juego está listo para Mac." : "Tu juego está listo para Sideloadly.")
self.detail=self.output == nil ? "Usa Ver error para revisar lo que ocurrió." : "Abre la IPA y arrástrala a Sideloadly. La firma se hace con tu cuenta personal."
self.phase=self.output == nil ? "Terminó, pero no encontramos la app." : (self.isMac ? "Tu juego está listo para Mac." : "Tu juego está listo para instalar en iPhone / iPad.")
self.detail=self.output == nil ? "Usa Ver error para revisar lo que ocurrió." : "Pulsa Instalar en iPhone / iPad. Inicia sesión con tu cuenta personal para firmar e instalar el juego y sus datos."
} else {
self.failed=true;self.showDetails=true;self.phase="No pudimos preparar el juego."
let log=self.transcript.lowercased()
@@ -312,6 +313,7 @@ struct ContentView: View {
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)
@@ -349,7 +351,7 @@ struct ContentView: View {
HStack {
Text(model.text(sideload.error ?? sideload.phase)).font(.system(size:12)).textSelection(.enabled)
Spacer()
if !sideload.busy {Button(model.text("Revisar renovación")){sideload.reviewRefresh();model.showSideload=true}}
if !sideload.busy {Button(model.text("Revisar renovación")){model.showDeviceLibrary=true}}
}.padding(14).background(Color.orange.opacity(0.08),in:RoundedRectangle(cornerRadius:12))
}
VStack(alignment:.leading,spacing:6) {
@@ -455,7 +457,7 @@ struct ContentView: View {
if model.previous != nil && !model.running {
Label(model.text("Ya preparaste este juego antes"),systemImage:"arrow.triangle.2.circlepath").font(.system(size:13,weight:.medium))
if let version=model.previous?.version {Text(model.text("Versión anterior")+": "+version).font(.system(size:12)).foregroundStyle(.secondary)}
Text(model.text(model.isMac ? "Crearás una app nueva. Tus partidas siguen en este Mac, fuera de la app. Cierra el juego antes de abrir la versión nueva." : "Crearás una IPA para actualizar tu app. Usa la misma cuenta e identificador en Sideloadly e instala encima, sin desinstalar. La preparación no comprueba qué está instalado en tu iPhone."))
Text(model.text(model.isMac ? "Crearás una app nueva. Tus partidas siguen en este Mac, fuera de la app. Cierra el juego antes de abrir la versión nueva." : "Crearás una IPA para actualizar tu app. En Instalar en iPhone / iPad, elige la app existente y su misma cuenta. No desinstales el juego. La preparación no comprueba qué está instalado en tu iPhone."))
.font(.system(size:12)).foregroundStyle(.secondary)
}
if !model.running && (model.output == nil || model.failed) {
@@ -525,6 +527,7 @@ struct ContentView: View {
}.frame(minWidth:760,minHeight:530).background(CompactWindow(height:(model.isMac ? 740 : 680)+(updater.visible ? 95 : 0)+(sideload.showsRefreshNotice ? 70 : 0))).preferredColorScheme(.dark)
.onReceive(NotificationCenter.default.publisher(for:Notification.Name("nxnative.openAnalysis"))) {_ in if !model.running && !updater.busy {model.showAnalysis=true}}
.sheet(isPresented:$model.showSaves) {SavesHelpView(folder:model.folder,isMac:model.isMac,text:model.text,language:model.language)}
.sheet(isPresented:$model.showDeviceLibrary) {SideloadLibraryView(model:sideload,text:model.text,canInstall:!model.running && !updater.busy)}
.sheet(isPresented:$model.showSideload) {SideloadView(model:sideload,text:model.text)}
.sheet(isPresented:$model.showAnalysis) {if let game=model.folder {GameAnalysisView(repo:model.repo,game:game,text:model.text)} else {Text(model.text("Primero selecciona la carpeta de un juego preparado.")).padding(24)}}
.sheet(isPresented:$model.showCatalog) {GameCatalogView(model:model)}
@@ -11,7 +11,7 @@ import Foundation
let helper=root.appendingPathComponent("helper")
try """
#!/usr/bin/python3
import sys,json
import sys,json,time
def emit(value):print(json.dumps(value),flush=True)
emit({'event':'hello','protocol':1,'architecture':'aarch64'})
for line in sys.stdin:
@@ -24,6 +24,12 @@ import Foundation
elif value['op']=='code':
assert value['code']=='123456'
emit({'event':'signed_in','teams':[{'id':'ABCDE12345','name':'Personal test team'}]})
elif value['op']=='restore':emit({'event':'signed_in','teams':[{'id':'ABCDE12345','name':'Personal test team'}]})
elif value['op']=='install':
assert value['automatic']==False and value['update']==True
assert value['expected_id']=='org.game.custom'
assert value['sha256']=='a'*64 and 'romfs' not in value
emit({'event':'installed','effective_id':value['expected_id'],'expires':time.time()+86400,'sha256':'a'*64})
elif value['op']=='logout':emit({'event':'signed_out'})
elif value['op']=='quit':break
""".write(to:helper,atomically:true,encoding:.utf8)
@@ -49,7 +55,23 @@ import Foundation
let folder=root.appendingPathComponent("game");try FileManager.default.createDirectory(at:folder.appendingPathComponent("010093801237c000_build"),withIntermediateDirectories:true)
assert(SaveLocations.titleID(folder:folder)=="010093801237C000")
assert(SaveLocations.macRoot(titleID:"010093801237C000").path.hasSuffix("Library/Application Support/nxnative/010093801237C000"))
print("Sideload client PASS: private stdio login/2FA, explicit team selection, logout, refresh account/expiry/backoff guards, custom update identity and save-location mapping")
let ipa=root.appendingPathComponent("Test game.ipa");try Data("synthetic-only".utf8).write(to:ipa)
job.id="SYNTHETIC-DEVICE:org.game.custom";job.ipa=ipa.path;job.udid="SYNTHETIC-DEVICE";job.sha256=String(repeating:"a",count:64);job.needsLogin=false;job.enabled=false
var second=job;second.id="SECOND-DEVICE:org.other";second.udid="SECOND-DEVICE";second.originalID="org.other";second.effectiveID="org.other"
model.jobs=[job,second]
model.setRefresh(job,enabled:true);assert(model.jobs[0].enabled)
model.setRefresh(job,enabled:false);assert(!model.jobs[0].enabled)
let reloaded=SideloadModel(stateURL:root.appendingPathComponent("jobs.json"),defaults:defaults,helperURL:helper)
assert(reloaded.jobs.count==2 && !reloaded.jobs[0].enabled)
model.email="different@example.org";model.renew(job);assert(!model.busy && model.error != nil)
model.email="test@example.org";defaults.set(model.email,forKey:"sideloadEmail")
model.restoreSession();try await wait {model.authenticated && !model.busy}
model.renew(job);try await wait {!model.busy && model.jobs.first(where:{$0.id==job.id})?.expires != job.expires}
assert(model.error==nil && model.jobs.count==2 && !model.jobs.first(where:{$0.id==job.id})!.enabled && model.jobs.first(where:{$0.id==job.id})!.sha256==job.sha256)
model.forget(job);assert(model.jobs.count==1 && model.jobs[0].id==second.id && FileManager.default.fileExists(atPath:ipa.path))
let empty=SideloadModel(stateURL:root.appendingPathComponent("jobs.json"),defaults:defaults,helperURL:helper);assert(empty.jobs.count==1 && empty.jobs[0].id==second.id)
model.cancel()
print("Sideload client PASS: multi-game persistence, explicit manual refresh with original identity/hash and no RomFS recopy, account guard, metadata-only removal; private stdio login/2FA, explicit team selection, logout, refresh account/expiry/backoff guards, custom update identity and save-location mapping")
}
@MainActor static func wait(_ condition:()->Bool) async throws {
let end=Date().addingTimeInterval(5)
+2 -2
View File
@@ -1,5 +1,5 @@
{
"version": "1.8.0",
"build": 12,
"version": "1.8.1",
"build": 13,
"minimumMacOSMajor": 26
}
+16
View File
@@ -419,3 +419,19 @@ harness could not discover a registered Mac Dread build (`tools/dmhost` absent),
so no gameplay comparison is claimed. Runtime and shader chain are unchanged.
Real account authentication, signing, installation, AFC recovery and scheduled
renewal still require physical-device validation.
## Device/game installation library (1.8.1)
The main window now opens My iPhones and iPads independently of preparation.
Recorded installations expose actual expiry, per-game schedule controls, manual
renewal, Keychain session reuse and metadata-only removal. Manual renewal retains
the original source hash and identity, does not recopy RomFS or enable a paused
schedule, and refuses a different account. Both player READMEs describe the native
flow first and retain the Sideloadly fallback and physical-validation boundary.
The expanded compiled Swift IPC fixture passed with synthetic login/2FA/restore
and installation events, multi-game metadata persistence, manual-refresh protocol
assertions, account rejection and non-destructive record removal. The production
ARM64 bundle compiled and its signature verified; localization, documentation and
78-profile catalog checks passed. No runtime, shader or Rust helper change was
needed. No real account login, signing or physical-device install was performed.
+23
View File
@@ -139,3 +139,26 @@ system-library linkage checks. The native CI job performs no Apple login,
Keychain credential creation, signing of game IPAs or device installation.
Cloud-save work remains separate and inactive until native backup/restore is
implemented and validated.
## Installation library (1.8.1)
**My iPhones and iPads** is available independently of the current preparation.
It lists all our recorded installations, their devices, exact effective IDs,
actual expiry dates and per-game refresh toggles. It does not import another
installer's private credentials or claim to discover all its scheduled jobs.
For a previously Sideloadly-installed game, prepare the same game and select its
actual installed app in our installer; retain the same Apple account/team.
Manual **Refresh now** preserves the recorded IPA hash and identity, checks the
account and connected device, signs/installs the unchanged app and omits RomFS.
It can reuse an authenticated session or restore a remembered Keychain session.
Save and close the game before manual refresh; automated refresh retains its
fail-closed running-process guard. Manual refresh does not enable a previously
paused schedule. Record removal only changes local scheduling metadata, never
uninstalls an app or deletes source game files. Login, expiry and USB/AFC behavior
remain subject to the physical validation boundary above.
The implementation covers our prepared-game installation lifecycle. It is not a
full binary/source migration of Sideloadly: no proprietary code is available to
port, and arbitrary IPA injection/plugins and all of its advanced options are
not implemented. Intel Sideloadly is not required by the native code path.