Add iPhone and iPad installation library and manual signature renewal
This commit is contained in:
1 parent
1d68110b87
commit
d93640bb78
9 files changed
+246
-31
No files matched your search
+30
-10
@@ -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,
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."))}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0",
|
||||
"build": 12,
|
||||
"version": "1.8.1",
|
||||
"build": 13,
|
||||
"minimumMacOSMajor": 26
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user