Guide device installation with app discovery, account inventory, logs and full IPA mode
This commit is contained in:
1 parent
b861784375
commit
7e3fc97a18
16 files changed
+553
-104
No files matched your search
+9
-4
@@ -99,16 +99,21 @@ controles, ni los archivos JSON de preparación. [Guía detallada de partidas y
|
|||||||
|
|
||||||
## Pon tu juego en iPhone o iPad
|
## Pon tu juego en iPhone o iPad
|
||||||
|
|
||||||
1. Prepara tu juego y pulsa **Instalar en iPhone / iPad**.
|
1. Elige iPhone/iPad, tu carpeta del juego y tus llaves. Deja IPA ligera o desactívala para incluir todo en la IPA; pulsa **Preparar juego**. El instalador se abre al terminar correctamente.
|
||||||
2. Conecta y desbloquea tu dispositivo. Confía en el Mac y activa Modo de desarrollador cuando iOS lo pida.
|
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.
|
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.
|
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.
|
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
|
En **Mis iPhones y iPads**, arriba en la ventana principal, ves los dispositivos conectados (identificador de modelo, sistema y USB/Wi-Fi),
|
||||||
registradas y la fecha real de vencimiento de su firma. Pulsa **Renovar ahora**
|
las apps instaladas con buscador, el acceso a tu cuenta Apple y las firmas
|
||||||
|
registradas. **Actualizar con mis archivos** inicia la preparación para una app
|
||||||
|
existente. Pulsa **Renovar ahora**
|
||||||
sin recompilar ni copiar RomFS otra vez; también puedes pausar la renovación
|
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
|
por juego. **Leer dispositivos de mi cuenta Apple** consulta los registros del
|
||||||
|
equipo después de iniciar sesión; es distinto a ver dispositivos conectados.
|
||||||
|
**Actividad de instalación** muestra avance, tiempo y logs copiables/guardados
|
||||||
|
sin credenciales. **Revisar cuenta e instalación** recupera su dispositivo, equipo e
|
||||||
identificador. **Usar sesión del Llavero** permite volver a entrar sin escribir
|
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
|
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.
|
detiene su renovación; no desinstala el juego ni elimina partidas.
|
||||||
|
|||||||
@@ -93,15 +93,19 @@ RomFS and control profiles. [Save locations and backup limits](docs/saves.md).
|
|||||||
|
|
||||||
## Put your game on iPhone or iPad
|
## Put your game on iPhone or iPad
|
||||||
|
|
||||||
1. Prepare your game, then choose **Install on iPhone / iPad**.
|
1. Choose iPhone/iPad, your game folder and keys. Keep lightweight mode or turn it off for a complete IPA, then press **Prepare game**. The installer opens automatically when preparation succeeds.
|
||||||
2. Connect and unlock your device. Trust the Mac and enable Developer Mode when iOS asks.
|
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.
|
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.
|
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.
|
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
|
**My iPhones and iPads**, at the top of the main window, shows connected devices (model identifier, OS and USB/Wi-Fi), searchable installed
|
||||||
installations and actual signature expiry dates. Use **Refresh now** without
|
apps, your Apple-account sign-in and our recorded signature expiry dates. Choose
|
||||||
|
**Update using my files** on an installed app to begin a game update. Use **Refresh now** without
|
||||||
recompiling or copying RomFS again, or pause automatic renewal per game.
|
recompiling or copying RomFS again, or pause automatic renewal per game.
|
||||||
|
**Read my Apple account devices** queries registrations after sign-in/team selection;
|
||||||
|
these are separate from currently connected devices. **Installation activity** shows
|
||||||
|
progress, elapsed time and copyable/retained logs without credentials.
|
||||||
**Review account and installation** restores that game's original device/team/ID;
|
**Review account and installation** restores that game's original device/team/ID;
|
||||||
**Use Keychain session** signs back in without entering the password again when
|
**Use Keychain session** signs back in without entering the password again when
|
||||||
the remembered session is still valid. Removing a record stops its renewal;
|
the remembered session is still valid. Removing a record stops its renewal;
|
||||||
|
|||||||
@@ -76,3 +76,19 @@ struct PreviousPreparation {
|
|||||||
return Self(target:target,version:record["version"] as? String ?? info?["CFBundleShortVersionString"] as? String,bundleID:record["bundle_id"] as? String ?? info?["CFBundleIdentifier"] as? String)
|
return Self(target:target,version:record["version"] as? String ?? info?["CFBundleShortVersionString"] as? String,bundleID:record["bundle_id"] as? String ?? info?["CFBundleIdentifier"] as? String)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Full mode is explicit: older preparation records must be rebuilt so their
|
||||||
|
// runtime understands bundled-data priority over an older Documents/romfs.
|
||||||
|
enum IOSPackageOutput {
|
||||||
|
static func load(record:[String:Any],split:Bool)->(ipa:URL,romfs:URL?)? {
|
||||||
|
if !split {
|
||||||
|
guard record["packaging"] as? String=="full",let path=record["full_ipa"] as? String,FileManager.default.isReadableFile(atPath:path) else {return nil}
|
||||||
|
return (URL(fileURLWithPath:path),nil)
|
||||||
|
}
|
||||||
|
guard let path=record["light_dir"] as? String else {return nil}
|
||||||
|
let root=URL(fileURLWithPath:path)
|
||||||
|
guard let files=try? FileManager.default.contentsOfDirectory(at:root,includingPropertiesForKeys:nil),let ipa=files.sorted(by:{$0.path<$1.path}).first(where:{$0.pathExtension.lowercased()=="ipa"}) else {return nil}
|
||||||
|
return (ipa,root.appendingPathComponent("romfs"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,55 @@ enum EasyLanguage: String, CaseIterable {
|
|||||||
|
|
||||||
struct EasyStrings {
|
struct EasyStrings {
|
||||||
static let english: [String:String] = [
|
static let english: [String:String] = [
|
||||||
|
"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.",
|
||||||
|
|
||||||
|
"No pudimos leer las apps. Desbloquea el dispositivo, confía en este Mac y vuelve a buscar.":"Could not read installed apps. Unlock the device, trust this Mac and search again.",
|
||||||
|
"No pudimos buscar dispositivos. Revisa la conexión y vuelve a intentar.":"Could not search for devices. Check the connection and try again.",
|
||||||
|
"Apple no devolvió los dispositivos de este equipo. Revisa tu sesión y vuelve a consultar.":"Apple did not return this team’s devices. Check your session and try again.",
|
||||||
|
"Tipo de equipo según Apple":"Team type reported by Apple",
|
||||||
|
|
||||||
|
"IPA ligera: separar los datos del juego":"Lightweight IPA: separate game data",
|
||||||
|
"Recomendado: la app pesa menos y el instalador copia romfs por separado.":"Recommended: a smaller app, with romfs copied separately by the installer.",
|
||||||
|
"IPA completa: incluye el juego dentro. Ocupa más y cada renovación vuelve a transferir toda la app.":"Complete IPA: includes the game inside. Uses more space and transfers the entire app on every refresh.",
|
||||||
|
"Carpeta y llaves → Preparar → Cuenta Apple → Instalar y copiar datos":"Folder and keys → Prepare → Apple account → Install and copy data",
|
||||||
|
"Consultando dispositivos registrados en Apple…":"Reading devices registered with Apple…",
|
||||||
|
"Buscando dispositivos conectados…":"Looking for connected devices…",
|
||||||
|
"Leyendo apps instaladas…":"Reading installed apps…",
|
||||||
|
"Búsqueda de dispositivos terminada.":"Device search finished.",
|
||||||
|
"Lista de apps instalada actualizada.":"Installed app list updated.",
|
||||||
|
"Dispositivos registrados consultados.":"Registered devices retrieved.",
|
||||||
|
"La operación se detuvo. Revisa el mensaje de error.":"The operation stopped. Review the error message.",
|
||||||
|
"Operación cancelada; puedes volver a intentar.":"Operation cancelled; you can try again.",
|
||||||
|
"Registrando el dispositivo con Apple…":"Registering the device with Apple…",
|
||||||
|
"Verificando firma y vencimiento…":"Verifying signature and expiry…",
|
||||||
|
"Conecta. Elige tu juego. Instala o actualiza.":"Connect. Choose your game. Install or update.",
|
||||||
|
"Dispositivos conectados":"Connected devices",
|
||||||
|
"Información del dispositivo no disponible":"Device information unavailable",
|
||||||
|
"Desbloquea y confía en este Mac para leer las apps.":"Unlock and trust this Mac to read installed apps.",
|
||||||
|
"Tu cuenta Apple":"Your Apple account",
|
||||||
|
"Inicia sesión aquí para firmar e instalar.":"Sign in here to sign and install.",
|
||||||
|
"Ver cuenta y equipos":"View account and teams",
|
||||||
|
"Instalar otro juego":"Install another game",
|
||||||
|
"Elige tus archivos; prepararemos el juego y abriremos el instalador al terminar.":"Choose your files; we will prepare the game and open the installer when ready.",
|
||||||
|
"Elegir carpeta de juego":"Choose game folder",
|
||||||
|
"Apps instaladas en este dispositivo":"Apps installed on this device",
|
||||||
|
"Buscar por nombre o identificador":"Search by name or identifier",
|
||||||
|
"No se leyeron apps. Desbloquea el dispositivo y vuelve a buscar.":"No apps were retrieved. Unlock the device and search again.",
|
||||||
|
"Actualizar con mis archivos":"Update using my files",
|
||||||
|
"Actualizar requiere tus archivos del mismo juego y la cuenta/equipo originales. No desinstalamos la app existente.":"Updating needs your files for the same game and the original account/team. The existing app is kept installed.",
|
||||||
|
"Firmas y renovación":"Signatures and refresh",
|
||||||
|
"Actividad de instalación":"Installation activity",
|
||||||
|
"Copiar registro":"Copy activity log",
|
||||||
|
"Mostrar registro guardado":"Show saved activity log",
|
||||||
|
"1 · Dispositivo":"1 · Device",
|
||||||
|
"2 · Cuenta Apple":"2 · Apple account",
|
||||||
|
"3 · Instalar":"3 · Install",
|
||||||
|
"Leer dispositivos de mi cuenta Apple":"Read my Apple account devices",
|
||||||
|
"Dispositivos registrados en el equipo":"Devices registered with the team",
|
||||||
|
"Cuenta gratuita: Apple indica hasta 3 dispositivos y perfiles de 7 días. Esta lista muestra registros reales; no calcula cupos libres ni elimina dispositivos.":"Free account: Apple documents up to 3 devices and 7-day profiles. This list shows actual registrations; it does not calculate available slots or remove devices.",
|
||||||
|
|
||||||
"Mis iPhones y iPads":"My iPhones and iPads",
|
"Mis iPhones y iPads":"My iPhones and iPads",
|
||||||
"Usar sesión del Llavero":"Use Keychain session",
|
"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 con la cuenta que instaló este juego para renovarlo.":"Sign in with the account that installed this game to refresh it.",
|
||||||
|
|||||||
@@ -5,10 +5,25 @@ import CryptoKit
|
|||||||
import ServiceManagement
|
import ServiceManagement
|
||||||
import Darwin
|
import Darwin
|
||||||
|
|
||||||
struct SideloadDevice:Identifiable {let id:String;let name:String}
|
struct SideloadDevice:Identifiable {
|
||||||
|
let id:String;let name:String
|
||||||
|
var model:String="";var os:String="";var kind:String="";var ready:Bool=true;var connections:[String]=[]
|
||||||
|
var detail:String {[model,os.isEmpty ? "" : (kind=="iPad" ? "iPadOS " : "iOS ")+os,connections.joined(separator:" + ")].filter{!$0.isEmpty}.joined(separator:" · ")}
|
||||||
|
static func decode(_ items:[[String:Any]])->[SideloadDevice] {
|
||||||
|
var result:[String:SideloadDevice]=[:]
|
||||||
|
for item in items {
|
||||||
|
guard let id=item["udid"] as? String,!id.isEmpty,let name=item["name"] as? String else {continue}
|
||||||
|
let value=SideloadDevice(id:id,name:name,model:item["model"] as? String ?? "",os:item["os"] as? String ?? "",kind:item["kind"] as? String ?? "",ready:item["ready"] as? Bool ?? true,connections:item["connections"] as? [String] ?? [])
|
||||||
|
if result[id]==nil || value.connections.contains("USB") {result[id]=value}
|
||||||
|
}
|
||||||
|
return result.values.sorted {$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct AccountDevice:Identifiable {let id:String;let name:String;let status:String}
|
||||||
|
struct InstallLog:Identifiable {let id=UUID();let date=Date();let message:String;var progress:Double?=nil}
|
||||||
struct TrustedSMS:Identifiable {let id:Int;let digits:String}
|
struct TrustedSMS:Identifiable {let id:Int;let digits:String}
|
||||||
struct SideloadTeam:Identifiable {let id:String;let name:String}
|
struct SideloadTeam:Identifiable {let id:String;let name:String;var kind:String=""}
|
||||||
struct InstalledGame:Identifiable {let id:String;let name:String}
|
struct InstalledGame:Identifiable {let id:String;let name:String;var version:String=""}
|
||||||
struct RefreshJob:Codable,Identifiable {
|
struct RefreshJob:Codable,Identifiable {
|
||||||
var id:String
|
var id:String
|
||||||
var ipa:String
|
var ipa:String
|
||||||
@@ -38,6 +53,13 @@ enum RefreshPolicy {
|
|||||||
@Published var devices:[SideloadDevice]=[]
|
@Published var devices:[SideloadDevice]=[]
|
||||||
@Published var teams:[SideloadTeam]=[]
|
@Published var teams:[SideloadTeam]=[]
|
||||||
@Published var apps:[InstalledGame]=[]
|
@Published var apps:[InstalledGame]=[]
|
||||||
|
@Published var accountDevices:[AccountDevice]=[]
|
||||||
|
@Published var accountDeviceTeam=""
|
||||||
|
@Published var logs:[InstallLog]=[]
|
||||||
|
@Published var logURL:URL?
|
||||||
|
private var pendingUpdate:(device:String,id:String)?
|
||||||
|
private var loggedPhase=""
|
||||||
|
private var loggedBucket = -1
|
||||||
@Published var device=""
|
@Published var device=""
|
||||||
@Published var team=""
|
@Published var team=""
|
||||||
@Published var existing=""
|
@Published var existing=""
|
||||||
@@ -82,13 +104,43 @@ enum RefreshPolicy {
|
|||||||
func configure(ipa:URL,folder:URL,romfs:URL?) {
|
func configure(ipa:URL,folder:URL,romfs:URL?) {
|
||||||
self.ipa=ipa;gameFolder=folder;self.romfs=romfs
|
self.ipa=ipa;gameFolder=folder;self.romfs=romfs
|
||||||
originalID=PreviousPreparation.load(folder:folder,target:"ios")?.bundleID ?? ""
|
originalID=PreviousPreparation.load(folder:folder,target:"ios")?.bundleID ?? ""
|
||||||
existing=""
|
existing=pendingUpdate?.id ?? ""
|
||||||
if let known=jobs.first(where:{$0.originalID==originalID && $0.gameFolder==folder.path && $0.account==RefreshPolicy.account(email)}) {
|
if let pendingUpdate {device=pendingUpdate.device;self.pendingUpdate=nil}
|
||||||
|
if existing.isEmpty,let known=jobs.first(where:{$0.originalID==originalID && $0.gameFolder==folder.path && $0.account==RefreshPolicy.account(email)}) {
|
||||||
if device.isEmpty {device=known.udid};if team.isEmpty {team=known.team}
|
if device.isEmpty {device=known.udid};if team.isEmpty {team=known.team}
|
||||||
if device==known.udid {existing=known.effectiveID}
|
if device==known.udid {existing=known.effectiveID}
|
||||||
}
|
}
|
||||||
connect();listDevices()
|
connect();listDevices()
|
||||||
}
|
}
|
||||||
|
func cancelPendingUpdate() {pendingUpdate=nil}
|
||||||
|
func prepareUpdate(_ app:InstalledGame) {guard !busy,!device.isEmpty else {return};pendingUpdate=(device,app.id)}
|
||||||
|
func openDevice(_ id:String) {
|
||||||
|
guard !busy else {return}
|
||||||
|
device=id;existing="";apps=[];inspectApps()
|
||||||
|
}
|
||||||
|
func inspectAccountDevices() {
|
||||||
|
guard !busy,authenticated,!team.isEmpty else {return}
|
||||||
|
busy=true;error=nil;startedAt=Date();fraction=nil;phase="Consultando dispositivos registrados en Apple…";accountDevices=[];accountDeviceTeam="";record(phase)
|
||||||
|
send(["op":"account_devices","team":team])
|
||||||
|
}
|
||||||
|
func copyLogs(text:(String)->String) {
|
||||||
|
let output=logs.map {$0.date.formatted(date:.omitted,time:.standard)+" · "+text($0.message)+($0.progress.map{" · "+String(Int($0*100))+" %"} ?? "")}.joined(separator:"\n")
|
||||||
|
NSPasteboard.general.clearContents();NSPasteboard.general.setString(output,forType:.string)
|
||||||
|
}
|
||||||
|
private func record(_ message:String,progress:Double?=nil) {
|
||||||
|
// Only local fixed messages enter this log. Never serialize helper events,
|
||||||
|
// Apple responses, identifiers, account names, passwords or tokens.
|
||||||
|
if logs.count>=400 {logs.removeFirst(logs.count-399)}
|
||||||
|
logs.append(InstallLog(message:message,progress:progress))
|
||||||
|
if logURL==nil,let folder=gameFolder {
|
||||||
|
let root=folder.appendingPathComponent("nxnative-tools/logs")
|
||||||
|
do {try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true,attributes:[.posixPermissions:0o700]);logURL=root.appendingPathComponent("install-"+UUID().uuidString+".log")}catch{}
|
||||||
|
}
|
||||||
|
if let logURL {
|
||||||
|
let output=logs.map {$0.date.ISO8601Format()+" · "+$0.message+($0.progress.map{" · "+String(Int($0*100))+" %"} ?? "")}.joined(separator:"\n")+"\n"
|
||||||
|
do {try Data(output.utf8).write(to:logURL,options:.atomic);try FileManager.default.setAttributes([.posixPermissions:0o600],ofItemAtPath:logURL.path)}catch{}
|
||||||
|
}
|
||||||
|
}
|
||||||
func connect() {
|
func connect() {
|
||||||
if let process,process.isRunning {return}
|
if let process,process.isRunning {return}
|
||||||
process=nil;input=nil
|
process=nil;input=nil
|
||||||
@@ -112,12 +164,13 @@ enum RefreshPolicy {
|
|||||||
do {var data=try JSONSerialization.data(withJSONObject:value);data.append(10);try input.write(contentsOf:data)}
|
do {var data=try JSONSerialization.data(withJSONObject:value);data.append(10);try input.write(contentsOf:data)}
|
||||||
catch {self.error="No pudimos comunicarnos con el instalador.";busy=false}
|
catch {self.error="No pudimos comunicarnos con el instalador.";busy=false}
|
||||||
}
|
}
|
||||||
func listDevices() {guard !busy else {return};busy=true;send(["op":"devices"])}
|
func listDevices() {guard !busy else {return};error=nil;busy=true;startedAt=Date();fraction=nil;phase="Buscando dispositivos conectados…";record(phase);send(["op":"devices"])}
|
||||||
func inspectApps() {guard !busy,!device.isEmpty else {return};busy=true;send(["op":"installed_apps","udid":device])}
|
func inspectApps() {guard !busy,!device.isEmpty else {return};busy=true;startedAt=Date();fraction=nil;phase="Leyendo apps instaladas…";record(phase);send(["op":"installed_apps","udid":device])}
|
||||||
func signIn(password:String,remember:Bool) {
|
func signIn(password:String,remember:Bool) {
|
||||||
guard !busy,!email.isEmpty,!password.isEmpty else {return}
|
guard !busy,!email.isEmpty,!password.isEmpty else {return}
|
||||||
email=email.trimmingCharacters(in:.whitespacesAndNewlines).lowercased()
|
email=email.trimmingCharacters(in:.whitespacesAndNewlines).lowercased()
|
||||||
busy=true;error=nil;startedAt=Date();phase="Iniciando sesión con Apple…"
|
busy=true;error=nil;startedAt=Date();fraction=nil;phase="Iniciando sesión con Apple…"
|
||||||
|
record("Iniciando sesión con Apple…")
|
||||||
pendingRemember=remember
|
pendingRemember=remember
|
||||||
send(["op":"login","email":email,"password":password,"remember":remember,"anisette_url":anisette])
|
send(["op":"login","email":email,"password":password,"remember":remember,"anisette_url":anisette])
|
||||||
}
|
}
|
||||||
@@ -126,7 +179,7 @@ enum RefreshPolicy {
|
|||||||
func sendSMS(_ number:Int) {codeRequired=false;send(["op":"sms","number":number])}
|
func sendSMS(_ number:Int) {codeRequired=false;send(["op":"sms","number":number])}
|
||||||
func resend() {codeRequired=false;send(["op":"resend"])}
|
func resend() {codeRequired=false;send(["op":"resend"])}
|
||||||
func signOut() {guard !busy else {return};send(["op":"logout","email":email]);defaults.removeObject(forKey:"sideloadEmail");for index in jobs.indices {jobs[index].enabled=false};saveJobs()}
|
func signOut() {guard !busy else {return};send(["op":"logout","email":email]);defaults.removeObject(forKey:"sideloadEmail");for index in jobs.indices {jobs[index].enabled=false};saveJobs()}
|
||||||
func cancel() {if let refresh=activeRefresh,let index=jobs.firstIndex(where:{$0.id==refresh.id}) {jobs[index].retryAfter=Date().addingTimeInterval(6*3600);saveJobs()};if let process,process.isRunning {if kill(-process.processIdentifier,SIGTERM) != 0 {process.terminate()}}}
|
func cancel() {record("Operación cancelada; puedes volver a intentar.");if let refresh=activeRefresh,let index=jobs.firstIndex(where:{$0.id==refresh.id}) {jobs[index].retryAfter=Date().addingTimeInterval(6*3600);saveJobs()};if let process,process.isRunning {if kill(-process.processIdentifier,SIGTERM) != 0 {process.terminate()}}}
|
||||||
func install(autoRefresh:Bool) {
|
func install(autoRefresh:Bool) {
|
||||||
guard !busy,authenticated,let ipa,let gameFolder,!originalID.isEmpty,!device.isEmpty,!team.isEmpty else {return}
|
guard !busy,authenticated,let ipa,let gameFolder,!originalID.isEmpty,!device.isEmpty,!team.isEmpty else {return}
|
||||||
if autoRefresh && defaults.string(forKey:"sideloadEmail") != email {error="Para renovar al reiniciar, inicia sesión recordándola en el Llavero, o desactiva la renovación.";return}
|
if autoRefresh && defaults.string(forKey:"sideloadEmail") != email {error="Para renovar al reiniciar, inicia sesión recordándola en el Llavero, o desactiva la renovación.";return}
|
||||||
@@ -137,7 +190,7 @@ enum RefreshPolicy {
|
|||||||
private func startInstall(_ job:RefreshJob,updating:Bool,content:URL?) {
|
private func startInstall(_ job:RefreshJob,updating:Bool,content:URL?) {
|
||||||
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}
|
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}
|
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
|
installIntent=job;loggedPhase="";loggedBucket = -1;logURL=nil;record("Firmando e instalando…");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 && !manualRefresh]
|
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 updating {value["expected_id"]=job.effectiveID}
|
||||||
if !job.sha256.isEmpty {value["sha256"]=job.sha256}
|
if !job.sha256.isEmpty {value["sha256"]=job.sha256}
|
||||||
@@ -155,7 +208,7 @@ enum RefreshPolicy {
|
|||||||
switch event["event"] as? String {
|
switch event["event"] as? String {
|
||||||
case "hello":if (event["protocol"] as? Int) != 1 || event["architecture"] as? String != "aarch64" {error="El instalador debe compilarse para Apple silicon.";cancel()}
|
case "hello":if (event["protocol"] as? Int) != 1 || event["architecture"] as? String != "aarch64" {error="El instalador debe compilarse para Apple silicon.";cancel()}
|
||||||
case "devices":
|
case "devices":
|
||||||
devices=((event["items"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["udid"] as? String,let name=item["name"] as? String else {return nil};return SideloadDevice(id:id,name:name)}
|
devices=SideloadDevice.decode((event["items"] as? [[String:Any]]) ?? []);record("Búsqueda de dispositivos terminada.")
|
||||||
busy=false
|
busy=false
|
||||||
if let refresh=activeRefresh {continueRefresh(refresh)}
|
if let refresh=activeRefresh {continueRefresh(refresh)}
|
||||||
else {
|
else {
|
||||||
@@ -164,34 +217,46 @@ enum RefreshPolicy {
|
|||||||
if !device.isEmpty {inspectApps()}
|
if !device.isEmpty {inspectApps()}
|
||||||
}
|
}
|
||||||
case "installed_apps":
|
case "installed_apps":
|
||||||
apps=((event["items"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["id"] as? String,let name=item["name"] as? String else {return nil};return InstalledGame(id:id,name:name)}.sorted {$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending}
|
apps=((event["items"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["id"] as? String,let name=item["name"] as? String else {return nil};return InstalledGame(id:id,name:name,version:item["version"] as? String ?? "")}.sorted {$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending}
|
||||||
|
record("Lista de apps instalada actualizada.")
|
||||||
let matches=apps.filter {$0.id.hasPrefix(originalID)};if existing.isEmpty,matches.count==1 {existing=matches[0].id}
|
let matches=apps.filter {$0.id.hasPrefix(originalID)};if existing.isEmpty,matches.count==1 {existing=matches[0].id}
|
||||||
busy=false
|
busy=false
|
||||||
case "two_factor":codeRequired=true;phase="Introduce el código de Apple.";smsNumbers=((event["numbers"] as? [[String:Any]]) ?? []).compactMap {number in guard let id=number["id"] as? Int,let digits=number["last_digits"] as? String else {return nil};return TrustedSMS(id:id,digits:digits)}
|
case "account_devices":
|
||||||
|
accountDeviceTeam=event["team"] as? String ?? ""
|
||||||
|
accountDevices=((event["items"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["udid"] as? String else {return nil};return AccountDevice(id:id,name:item["name"] as? String ?? String(id.suffix(8)),status:item["status"] as? String ?? "")}
|
||||||
|
busy=false;record("Dispositivos registrados consultados.")
|
||||||
|
case "two_factor":record("Introduce el código de Apple.");codeRequired=true;phase="Introduce el código de Apple.";smsNumbers=((event["numbers"] as? [[String:Any]]) ?? []).compactMap {number in guard let id=number["id"] as? Int,let digits=number["last_digits"] as? String else {return nil};return TrustedSMS(id:id,digits:digits)}
|
||||||
case "signed_in":
|
case "signed_in":
|
||||||
authenticated=true;busy=false;codeRequired=false;error=nil;phase="Sesión iniciada. Elige tu equipo personal."
|
record("Sesión iniciada. Elige tu equipo personal.");authenticated=true;busy=false;codeRequired=false;error=nil;phase="Sesión iniciada. Elige tu equipo personal."
|
||||||
if let remember=pendingRemember {if remember {defaults.set(email,forKey:"sideloadEmail");defaults.set(anisette,forKey:"sideloadAnisette")}else{defaults.removeObject(forKey:"sideloadEmail")};pendingRemember=nil}
|
if let remember=pendingRemember {if remember {defaults.set(email,forKey:"sideloadEmail");defaults.set(anisette,forKey:"sideloadAnisette")}else{defaults.removeObject(forKey:"sideloadEmail")};pendingRemember=nil}
|
||||||
teams=((event["teams"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["id"] as? String else {return nil};return SideloadTeam(id:id,name:item["name"] as? String ?? id)}
|
teams=((event["teams"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["id"] as? String else {return nil};return SideloadTeam(id:id,name:item["name"] as? String ?? id,kind:item["kind"] as? String ?? "")}
|
||||||
if !teams.contains(where:{$0.id==team}) {team=""}
|
if !teams.contains(where:{$0.id==team}) {team=""}
|
||||||
for index in jobs.indices where jobs[index].account==RefreshPolicy.account(email) {jobs[index].needsLogin=false};saveJobs()
|
for index in jobs.indices where jobs[index].account==RefreshPolicy.account(email) {jobs[index].needsLogin=false};saveJobs()
|
||||||
if let refresh=activeRefresh {refreshAuthenticated=true;continueRefresh(refresh)}
|
if let refresh=activeRefresh {refreshAuthenticated=true;continueRefresh(refresh)}
|
||||||
case "signed_out":authenticated=false;teams=[];team="";busy=false
|
case "signed_out":authenticated=false;accountDevices=[];accountDeviceTeam="";teams=[];team="";busy=false
|
||||||
case "progress":
|
case "progress":
|
||||||
let phases=["Preparing IPA":"Preparando IPA…","Signing app":"Firmando la app…","Installing app":"Instalando la app…","Copying game data":"Copiando datos del juego…","Game data ready":"Datos del juego listos."]
|
let phases=["Registering device":"Registrando el dispositivo con Apple…","Verifying signature":"Verificando firma y vencimiento…","Preparing IPA":"Preparando IPA…","Signing app":"Firmando la app…","Installing app":"Instalando la app…","Copying game data":"Copiando datos del juego…","Game data ready":"Datos del juego listos."]
|
||||||
phase=phases[event["phase"] as? String ?? ""] ?? phase;if let value=event["fraction"] as? Double,value.isFinite,value>=0,value<=1 {fraction=value}else{fraction=nil}
|
phase=phases[event["phase"] as? String ?? ""] ?? phase;if let value=event["fraction"] as? Double,value.isFinite,value>=0,value<=1 {fraction=value}else{fraction=nil}
|
||||||
|
let bucket=Int((fraction ?? 0)*10)
|
||||||
|
if loggedPhase != phase || loggedBucket != bucket {record(phase,progress:fraction);loggedPhase=phase;loggedBucket=bucket}
|
||||||
case "installed":
|
case "installed":
|
||||||
guard var job=installIntent,let actual=event["effective_id"] as? String,actual==job.effectiveID,let expiry=event["expires"] as? Double,expiry>Date().timeIntervalSince1970,let hash=event["sha256"] as? String,hash.count==64 else {
|
guard var job=installIntent,let actual=event["effective_id"] as? String,actual==job.effectiveID,let expiry=event["expires"] as? Double,expiry>Date().timeIntervalSince1970,let hash=event["sha256"] as? String,hash.count==64 else {
|
||||||
error="No pudimos verificar el resultado de la instalación. Revisa el dispositivo antes de continuar.";busy=false;activeRefresh=nil;installIntent=nil;return
|
error="No pudimos verificar el resultado de la instalación. Revisa el dispositivo antes de continuar.";busy=false;activeRefresh=nil;installIntent=nil;return
|
||||||
}
|
}
|
||||||
job.expires=Date(timeIntervalSince1970:expiry);job.sha256=hash;job.retryAfter=nil;job.needsLogin=false;jobs.removeAll {$0.id==job.id};jobs.append(job);error=nil;saveJobs();existing=actual
|
job.expires=Date(timeIntervalSince1970:expiry);job.sha256=hash;job.retryAfter=nil;job.needsLogin=false;jobs.removeAll {$0.id==job.id};jobs.append(job);error=nil;saveJobs();existing=actual
|
||||||
installIntent=nil;activeRefresh=nil;refreshAuthenticated=false;busy=false;fraction=1;phase="Instalado. Tus partidas se conservan en el dispositivo."
|
installIntent=nil;activeRefresh=nil;refreshAuthenticated=false;busy=false;fraction=1;phase="Instalado. Tus partidas se conservan en el dispositivo.";record(phase)
|
||||||
case "error":
|
case "error":
|
||||||
|
record("La operación se detuvo. Revisa el mensaje de error.")
|
||||||
let op=event["operation"] as? String
|
let op=event["operation"] as? String
|
||||||
let codes=["game_running":"El juego sigue abierto. La renovación espera para no interrumpir tu partida.","idle_unknown":"No pudimos comprobar si el juego está abierto. Guarda, ciérralo y renueva manualmente.","identity_mismatch":"Ese identificador no corresponde al equipo elegido. Detenido para conservar la app anterior.","not_installed":"No encontramos esa app instalada. Revisa el identificador antes de actualizar.","wrong_app":"La app elegida no corresponde a este juego. No se instaló encima.","storage":"No hay espacio suficiente para preparar los datos nuevos sin borrar los anteriores.","ipa_changed":"La IPA cambió. Prepara y aprueba la actualización antes de renovarla."]
|
let codes=["game_running":"El juego sigue abierto. La renovación espera para no interrumpir tu partida.","idle_unknown":"No pudimos comprobar si el juego está abierto. Guarda, ciérralo y renueva manualmente.","identity_mismatch":"Ese identificador no corresponde al equipo elegido. Detenido para conservar la app anterior.","not_installed":"No encontramos esa app instalada. Revisa el identificador antes de actualizar.","wrong_app":"La app elegida no corresponde a este juego. No se instaló encima.","storage":"No hay espacio suficiente para preparar los datos nuevos sin borrar los anteriores.","ipa_changed":"La IPA cambió. Prepara y aprueba la actualización antes de renovarla."]
|
||||||
error=codes[event["code"] as? String ?? ""] ?? ((op=="login" || op=="restore") ? "No pudimos iniciar sesión o la sesión guardada caducó. Vuelve a iniciar sesión." : "No pudimos completar la instalación. Revisa el dispositivo, equipo y permisos. No se revocaron certificados automáticamente.")
|
error=codes[event["code"] as? String ?? ""] ?? ((op=="login" || op=="restore") ? "No pudimos iniciar sesión o la sesión guardada caducó. Vuelve a iniciar sesión." : "No pudimos completar la instalación. Revisa el dispositivo, equipo y permisos. No se revocaron certificados automáticamente.")
|
||||||
|
if op=="installed_apps" {apps=[];error="No pudimos leer las apps. Desbloquea el dispositivo, confía en este Mac y vuelve a buscar."}
|
||||||
|
if op=="devices" {error="No pudimos buscar dispositivos. Revisa la conexión y vuelve a intentar."}
|
||||||
|
if op=="account_devices" {accountDevices=[];accountDeviceTeam="";error="Apple no devolvió los dispositivos de este equipo. Revisa tu sesión y vuelve a consultar."}
|
||||||
if let code=event["apple_code"] as? Int {error=(error ?? "")+" · Apple: \(code)"}
|
if let code=event["apple_code"] as? Int {error=(error ?? "")+" · Apple: \(code)"}
|
||||||
if op=="login" || op=="restore" {authenticated=false;pendingRemember=nil}
|
if op=="login" || op=="restore" {authenticated=false;pendingRemember=nil}
|
||||||
if let refresh=activeRefresh,let index=jobs.firstIndex(where:{$0.id==refresh.id}) {jobs[index].retryAfter=Date().addingTimeInterval((event["code"] as? String)=="game_running" ? 600 : 6*3600);jobs[index].needsLogin=(op=="restore");saveJobs()}
|
if let refresh=activeRefresh,let index=jobs.firstIndex(where:{$0.id==refresh.id}) {jobs[index].retryAfter=Date().addingTimeInterval((event["code"] as? String)=="game_running" ? 600 : 6*3600);jobs[index].needsLogin=(op=="restore");saveJobs()}
|
||||||
|
if let error {record(error)}
|
||||||
activeRefresh=nil;installIntent=nil;refreshAuthenticated=false;codeRequired=false;busy=false
|
activeRefresh=nil;installIntent=nil;refreshAuthenticated=false;codeRequired=false;busy=false
|
||||||
default:break
|
default:break
|
||||||
}
|
}
|
||||||
@@ -207,7 +272,7 @@ enum RefreshPolicy {
|
|||||||
}
|
}
|
||||||
func restoreSession() {
|
func restoreSession() {
|
||||||
guard !busy,!email.isEmpty,defaults.string(forKey:"sideloadEmail")==email else {return}
|
guard !busy,!email.isEmpty,defaults.string(forKey:"sideloadEmail")==email else {return}
|
||||||
busy=true;error=nil;startedAt=Date();phase="Renovando sesión guardada…"
|
busy=true;error=nil;startedAt=Date();fraction=nil;phase="Renovando sesión guardada…";record(phase)
|
||||||
send(["op":"restore","email":email,"anisette_url":anisette])
|
send(["op":"restore","email":email,"anisette_url":anisette])
|
||||||
}
|
}
|
||||||
var hasRememberedSession:Bool {defaults.string(forKey:"sideloadEmail")==email && !email.isEmpty}
|
var hasRememberedSession:Bool {defaults.string(forKey:"sideloadEmail")==email && !email.isEmpty}
|
||||||
@@ -266,32 +331,48 @@ struct SideloadView:View {
|
|||||||
var body:some View {
|
var body:some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment:.leading,spacing:16) {
|
VStack(alignment:.leading,spacing:16) {
|
||||||
HStack {Text(text("Instalar en iPhone / iPad")).font(.title2.bold());Spacer();Button(text("Cerrar")){dismiss()}.disabled(model.busy)}
|
HStack {Text(text(model.ipa==nil ? "Tu cuenta Apple" : "Instalar en iPhone / iPad")).font(.title2.bold());Spacer();Button(text("Cerrar")){dismiss()}.disabled(model.busy)}
|
||||||
|
HStack(spacing:18) {
|
||||||
|
Label(text("1 · Dispositivo"),systemImage:model.device.isEmpty ? "circle" : "checkmark.circle.fill")
|
||||||
|
Label(text("2 · Cuenta Apple"),systemImage:model.authenticated ? "checkmark.circle.fill" : "circle")
|
||||||
|
Label(text("3 · Instalar"),systemImage:"arrow.down.app")
|
||||||
|
}.font(.caption).foregroundStyle(.secondary)
|
||||||
if let folder=model.gameFolder {Text(folder.lastPathComponent).font(.headline)}
|
if let folder=model.gameFolder {Text(folder.lastPathComponent).font(.headline)}
|
||||||
Text(text("Instalación nativa en Apple silicon. Cierra el juego y no lo abras hasta terminar. La firma e instalación con tu cuenta aún necesitan validación en un dispositivo."))
|
Text(text("Conecta tu dispositivo e inicia sesión. Firmar e instalar requiere tu confirmación.")).foregroundStyle(.secondary)
|
||||||
Picker(text("Dispositivo"),selection:$model.device) {Text(text("Elige tu iPhone/iPad")).tag("");ForEach(model.devices){Text($0.name).tag($0.id)}}.disabled(model.busy)
|
Picker(text("Dispositivo"),selection:$model.device) {Text(text("Elige tu iPhone/iPad")).tag("");ForEach(model.devices){Text($0.name).tag($0.id)}}.disabled(model.busy)
|
||||||
.onChange(of:model.device) {_,_ in model.existing="";model.inspectApps()}
|
.onChange(of:model.device) {_,_ in model.existing="";model.inspectApps()}
|
||||||
|
if let selected=model.devices.first(where:{$0.id==model.device}),!selected.detail.isEmpty {Text(selected.detail).font(.caption).foregroundStyle(.secondary)}
|
||||||
Button(text("Buscar dispositivos")){model.listDevices()}.disabled(model.busy)
|
Button(text("Buscar dispositivos")){model.listDevices()}.disabled(model.busy)
|
||||||
Text(text("Desbloquea el dispositivo, confía en este Mac y activa Modo de desarrollador si iOS lo pide.")) .font(.caption).foregroundStyle(.secondary)
|
Text(text("Desbloquea el dispositivo, confía en este Mac y activa Modo de desarrollador si iOS lo pide.")) .font(.caption).foregroundStyle(.secondary)
|
||||||
if !model.authenticated {
|
if !model.authenticated {
|
||||||
TextField(text("Cuenta Apple personal"),text:$model.email).disabled(model.busy)
|
LabeledContent(text("Cuenta Apple personal")){TextField("name@example.com",text:$model.email).textFieldStyle(.roundedBorder).disabled(model.busy)}
|
||||||
SecureField(text("Contraseña de Apple"),text:$password).disabled(model.busy)
|
LabeledContent(text("Contraseña de Apple")){SecureField(text("Contraseña de Apple"),text:$password).textFieldStyle(.roundedBorder).disabled(model.busy)}
|
||||||
Toggle(text("Recordar sesión en el Llavero; no guardar la contraseña"),isOn:$remember).disabled(model.busy).onChange(of:remember){_,value in if !value {autoRefresh=false}}
|
Toggle(text("Recordar sesión en el Llavero; no guardar la contraseña"),isOn:$remember).disabled(model.busy).onChange(of:remember){_,value in if !value {autoRefresh=false}}
|
||||||
DisclosureGroup(text("Servicio de autenticación")) {
|
DisclosureGroup(text("Servicio de autenticación")) {
|
||||||
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)
|
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)
|
TextField("Anisette HTTPS",text:$model.anisette).disabled(model.busy)
|
||||||
}
|
}
|
||||||
if model.hasRememberedSession {Button(text("Usar sesión del Llavero")){model.restoreSession()}.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)
|
Button(text("Iniciar sesión")){model.signIn(password:password,remember:remember);password=""}.buttonStyle(.borderedProminent).disabled(model.busy || model.email.isEmpty || password.isEmpty)
|
||||||
} else {
|
} else {
|
||||||
Label(text("Sesión iniciada"),systemImage:"person.crop.circle.badge.checkmark")
|
Label(text("Sesión iniciada"),systemImage:"person.crop.circle.badge.checkmark")
|
||||||
|
Text(model.email).font(.caption).textSelection(.enabled)
|
||||||
Picker(text("Tu equipo personal"),selection:$model.team) {Text(text("Selecciona tu equipo")).tag("");ForEach(model.teams){Text($0.name+" · "+$0.id).tag($0.id)}}.disabled(model.busy)
|
Picker(text("Tu equipo personal"),selection:$model.team) {Text(text("Selecciona tu equipo")).tag("");ForEach(model.teams){Text($0.name+" · "+$0.id).tag($0.id)}}.disabled(model.busy)
|
||||||
|
if let selected=model.teams.first(where:{$0.id==model.team}),!selected.kind.isEmpty {Text(text("Tipo de equipo según Apple")+": "+selected.kind).font(.caption).foregroundStyle(.secondary)}
|
||||||
|
Button(text("Leer dispositivos de mi cuenta Apple")){model.inspectAccountDevices()}.disabled(model.busy || model.team.isEmpty)
|
||||||
|
if model.accountDeviceTeam==model.team {
|
||||||
|
Text(text("Dispositivos registrados en el equipo")+": "+String(model.accountDevices.count)).font(.headline)
|
||||||
|
ForEach(model.accountDevices) {item in Text(item.name+" · …"+String(item.id.suffix(8))+" · "+item.status).font(.caption)}
|
||||||
|
}
|
||||||
|
Text(text("Cuenta gratuita: Apple indica hasta 3 dispositivos y perfiles de 7 días. Esta lista muestra registros reales; no calcula cupos libres ni elimina dispositivos.")).font(.caption).foregroundStyle(.secondary)
|
||||||
|
if model.ipa != nil {
|
||||||
Picker(text("App existente que actualizarás"),selection:$model.existing) {Text(text("Primera instalación")).tag("");ForEach(model.apps){Text($0.name+" · "+$0.id).tag($0.id)}}.disabled(model.busy)
|
Picker(text("App existente que actualizarás"),selection:$model.existing) {Text(text("Primera instalación")).tag("");ForEach(model.apps){Text($0.name+" · "+$0.id).tag($0.id)}}.disabled(model.busy)
|
||||||
Text(text("Se conserva el identificador efectivo. Si no corresponde al juego y equipo, la instalación se detiene; no se desinstala la app anterior.")) .font(.caption).foregroundStyle(.secondary)
|
Text(text("Se conserva el identificador efectivo. Si no corresponde al juego y equipo, la instalación se detiene; no se desinstala la app anterior.")) .font(.caption).foregroundStyle(.secondary)
|
||||||
Toggle(text("Renovar automáticamente antes de que caduque"),isOn:$autoRefresh).disabled(model.busy || !remember)
|
Toggle(text("Renovar automáticamente antes de que caduque"),isOn:$autoRefresh).disabled(model.busy || !remember)
|
||||||
Toggle(text("Abrir nxnative Easy al iniciar sesión"),isOn:$loginItem).onChange(of:loginItem){_,value in model.setLoginItem(value)}.disabled(model.busy)
|
Toggle(text("Abrir nxnative Easy al iniciar sesión"),isOn:$loginItem).onChange(of:loginItem){_,value in model.setLoginItem(value)}.disabled(model.busy)
|
||||||
Text(text("La renovación necesita este Mac encendido, nxnative Easy abierto, conexión a Internet y el dispositivo accesible. Conserva la IPA original.")) .font(.caption).foregroundStyle(.secondary)
|
Text(text("La renovación necesita este Mac encendido, nxnative Easy abierto, conexión a Internet y el dispositivo accesible. Conserva la IPA original.")) .font(.caption).foregroundStyle(.secondary)
|
||||||
Button(text(model.existing.isEmpty ? "Firmar e instalar" : "Firmar y actualizar")){model.install(autoRefresh:autoRefresh)}.buttonStyle(.borderedProminent).disabled(model.busy || model.device.isEmpty || model.team.isEmpty || model.originalID.isEmpty)
|
Button(text(model.existing.isEmpty ? "Firmar e instalar" : "Firmar y actualizar")){model.install(autoRefresh:autoRefresh)}.buttonStyle(.borderedProminent).disabled(model.busy || model.device.isEmpty || model.team.isEmpty || model.originalID.isEmpty)
|
||||||
|
}
|
||||||
Button(text("Cerrar sesión")){model.signOut()}.disabled(model.busy)
|
Button(text("Cerrar sesión")){model.signOut()}.disabled(model.busy)
|
||||||
}
|
}
|
||||||
if model.codeRequired {
|
if model.codeRequired {
|
||||||
@@ -300,11 +381,11 @@ struct SideloadView:View {
|
|||||||
Button(text("Enviar a mis dispositivos")){model.sendToDevices()}
|
Button(text("Enviar a mis dispositivos")){model.sendToDevices()}
|
||||||
ForEach(model.smsNumbers) {number in Button("SMS · …"+number.digits){model.sendSMS(number.id)}}
|
ForEach(model.smsNumbers) {number in Button("SMS · …"+number.digits){model.sendSMS(number.id)}}
|
||||||
}
|
}
|
||||||
if model.busy {TimelineView(.periodic(from:.now,by:1)){context in Text(text("Tiempo transcurrido")+" "+String(Int(context.date.timeIntervalSince(model.startedAt ?? context.date)))+" s").font(.caption)};if let fraction=model.fraction {ProgressView(value:fraction)}else{ProgressView().progressViewStyle(.linear)};Button(text("Cancelar")){model.cancel()}}
|
Text(text("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.")).font(.caption).foregroundStyle(.secondary)
|
||||||
Text(text(model.phase)).font(.callout)
|
InstallActivityView(model:model,text:text)
|
||||||
if let error=model.error {Text(text(error)).foregroundStyle(.orange).textSelection(.enabled)}
|
if !model.busy {Text(text(model.phase)).font(.callout)}
|
||||||
}.padding(24)
|
}.padding(24)
|
||||||
}.frame(width:720,height:690).interactiveDismissDisabled(model.busy)
|
}.frame(width:760,height:720).tint(Color(red:0.79,green:0.95,blue:0.37)).preferredColorScheme(.dark).interactiveDismissDisabled(model.busy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,51 +394,91 @@ struct SideloadLibraryView:View {
|
|||||||
@ObservedObject var model:SideloadModel
|
@ObservedObject var model:SideloadModel
|
||||||
let text:(String)->String
|
let text:(String)->String
|
||||||
let canInstall:Bool
|
let canInstall:Bool
|
||||||
|
var prepareGame:()->Void={}
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var showInstaller=false
|
@State private var showInstaller=false
|
||||||
@State private var forgotten:RefreshJob?
|
@State private var forgotten:RefreshJob?
|
||||||
|
@State private var search=""
|
||||||
|
@State private var appLimit=8
|
||||||
|
private var matchingApps:[InstalledGame] {model.apps.filter {search.isEmpty || ($0.name+" "+$0.id).localizedCaseInsensitiveContains(search)}}
|
||||||
|
private let accent=Color(red:0.79,green:0.95,blue:0.37)
|
||||||
var body:some View {
|
var body:some View {
|
||||||
ScrollView {
|
VStack(spacing:0) {
|
||||||
VStack(alignment:.leading,spacing:18) {
|
HStack(alignment:.top) {
|
||||||
HStack {Text(text("Mis iPhones y iPads")).font(.title2.bold());Spacer();Button(text("Cerrar")){dismiss()}.disabled(model.busy)}
|
VStack(alignment:.leading,spacing:6) {
|
||||||
Text(text("Administra los juegos instalados desde esta app. Renovar la firma no recompila el juego ni copia sus datos otra vez."))
|
Label(text("Mis iPhones y iPads"),systemImage:"iphone.and.ipad").font(.title2.bold())
|
||||||
.font(.callout).foregroundStyle(.secondary)
|
Text(text("Conecta. Elige tu juego. Instala o actualiza.")).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)}
|
Spacer();Button(text("Cerrar")){dismiss()}.disabled(model.busy)
|
||||||
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)
|
}.padding(24)
|
||||||
}.frame(width:760,height:650).interactiveDismissDisabled(model.busy)
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment:.leading,spacing:20) {
|
||||||
|
HStack {
|
||||||
|
Label(text("Dispositivos conectados"),systemImage:"cable.connector").font(.headline)
|
||||||
|
Spacer();Button(text("Buscar dispositivos")){model.listDevices()}.disabled(model.busy || !canInstall)
|
||||||
|
}
|
||||||
|
if model.devices.isEmpty && !model.busy {
|
||||||
|
VStack(alignment:.leading,spacing:10) {
|
||||||
|
Text(text("Conecta tu iPhone o iPad")).font(.headline)
|
||||||
|
Text(text("Desbloquea el dispositivo, confía en este Mac y activa Modo de desarrollador si iOS lo pide.")).foregroundStyle(.secondary)
|
||||||
|
Button(text("Buscar dispositivos")){model.listDevices()}
|
||||||
|
}.padding(18).frame(maxWidth:.infinity,minHeight:136,alignment:.topLeading).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:14))
|
||||||
|
}
|
||||||
|
ForEach(model.devices) {device in
|
||||||
|
Button {model.openDevice(device.id)} label:{
|
||||||
|
HStack(spacing:14) {
|
||||||
|
Image(systemName:device.kind=="iPad" ? "ipad" : "iphone").font(.system(size:30)).foregroundStyle(accent).frame(width:42)
|
||||||
|
VStack(alignment:.leading,spacing:5) {Text(device.name).font(.headline);Text(device.detail.isEmpty ? text("Información del dispositivo no disponible") : device.detail).font(.caption).foregroundStyle(.secondary);if !device.ready {Text(text("Desbloquea y confía en este Mac para leer las apps.")).font(.caption).foregroundStyle(.orange)}}
|
||||||
|
Spacer();Image(systemName:model.device==device.id ? "checkmark.circle.fill" : "circle").foregroundStyle(model.device==device.id ? accent : .secondary)
|
||||||
|
}.padding(16).background(model.device==device.id ? accent.opacity(0.08) : .white.opacity(0.035),in:RoundedRectangle(cornerRadius:14))
|
||||||
|
}.buttonStyle(.plain).disabled(model.busy || !canInstall)
|
||||||
|
}
|
||||||
|
HStack(alignment:.top,spacing:16) {
|
||||||
|
VStack(alignment:.leading,spacing:10) {
|
||||||
|
Label(text("Tu cuenta Apple"),systemImage:model.authenticated ? "person.crop.circle.badge.checkmark" : "person.crop.circle").font(.headline)
|
||||||
|
Text(text(model.authenticated ? "Sesión iniciada" : "Inicia sesión aquí para firmar e instalar.")).foregroundStyle(.secondary)
|
||||||
|
Button(text(model.authenticated ? "Ver cuenta y equipos" : "Iniciar sesión")){showInstaller=true}.disabled(model.busy || !canInstall)
|
||||||
|
}.padding(18).frame(maxWidth:.infinity,minHeight:136,alignment:.topLeading).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:14))
|
||||||
|
VStack(alignment:.leading,spacing:10) {
|
||||||
|
Label(text("Instalar otro juego"),systemImage:"plus.app").font(.headline)
|
||||||
|
Text(text("Elige tus archivos; prepararemos el juego y abriremos el instalador al terminar.")).foregroundStyle(.secondary)
|
||||||
|
Button(text("Elegir carpeta de juego")){model.cancelPendingUpdate();prepareGame();dismiss()}.disabled(model.busy || !canInstall)
|
||||||
|
}.padding(18).frame(maxWidth:.infinity,minHeight:136,alignment:.topLeading).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:14))
|
||||||
|
}
|
||||||
|
if !model.device.isEmpty {
|
||||||
|
HStack {Label(text("Apps instaladas en este dispositivo"),systemImage:"square.grid.2x2").font(.headline);Spacer();Text(String(model.apps.count)).foregroundStyle(.secondary)}
|
||||||
|
TextField(text("Buscar por nombre o identificador"),text:$search).textFieldStyle(.roundedBorder)
|
||||||
|
if model.apps.isEmpty && !model.busy {Text(text("No se leyeron apps. Desbloquea el dispositivo y vuelve a buscar.")).foregroundStyle(.secondary)}
|
||||||
|
ForEach(Array(matchingApps.prefix(appLimit))) {app in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment:.leading,spacing:4) {Text(app.name).font(.headline);Text(app.id+(app.version.isEmpty ? "" : " · "+app.version)).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)}
|
||||||
|
Spacer();Button(text("Actualizar con mis archivos")){model.prepareUpdate(app);prepareGame();dismiss()}.disabled(model.busy || !canInstall)
|
||||||
|
}.padding(.vertical,6)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
if matchingApps.count>appLimit {Button(text("Mostrar más apps")+" · "+String(min(appLimit,matchingApps.count))+" / "+String(matchingApps.count)){appLimit+=8}}
|
||||||
|
Text(text("Actualizar requiere tus archivos del mismo juego y la cuenta/equipo originales. No desinstalamos la app existente.")).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if !model.jobs.isEmpty {
|
||||||
|
Label(text("Firmas y renovación"),systemImage:"arrow.clockwise.circle").font(.headline)
|
||||||
|
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)
|
||||||
|
Text(text(job.expires<=Date() ? "Firma vencida" : "La firma vence")+" "+job.expires.formatted(date:.abbreviated,time:.shortened)).font(.caption)
|
||||||
|
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)};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.04),in:RoundedRectangle(cornerRadius:14)).disabled(model.busy || !canInstall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InstallActivityView(model:model,text:text)
|
||||||
|
}.padding(24)
|
||||||
|
}
|
||||||
|
}.frame(width:850,height:730).background(Color(red:0.065,green:0.075,blue:0.07)).preferredColorScheme(.dark).interactiveDismissDisabled(model.busy)
|
||||||
|
.onChange(of:search){_,_ in appLimit=8}
|
||||||
|
.onChange(of:model.device){_,_ in appLimit=8}
|
||||||
.task {if canInstall && !model.busy {model.listDevices()}}
|
.task {if canInstall && !model.busy {model.listDevices()}}
|
||||||
.sheet(isPresented:$showInstaller){SideloadView(model:model,text:text)}
|
.sheet(isPresented:$showInstaller){SideloadView(model:model,text:text)}
|
||||||
.alert(text("¿Quitar este registro?"),isPresented:Binding(get:{forgotten != nil},set:{if !$0 {forgotten=nil}})) {
|
.alert(text("¿Quitar este registro?"),isPresented:Binding(get:{forgotten != nil},set:{if !$0 {forgotten=nil}})) {
|
||||||
@@ -366,3 +487,25 @@ struct SideloadLibraryView:View {
|
|||||||
} 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."))}
|
} 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."))}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct InstallActivityView:View {
|
||||||
|
@ObservedObject var model:SideloadModel
|
||||||
|
let text:(String)->String
|
||||||
|
@State private var expanded=true
|
||||||
|
var body:some View {
|
||||||
|
VStack(alignment:.leading,spacing:10) {
|
||||||
|
if model.busy {
|
||||||
|
HStack {Text(text(model.phase)).font(.headline);Spacer();Button(text("Cancelar")){model.cancel()}}
|
||||||
|
if let fraction=model.fraction {ProgressView(value:fraction);Text(String(Int(fraction*100))+" %").font(.caption.monospacedDigit())}else{ProgressView().progressViewStyle(.linear)}
|
||||||
|
TimelineView(.periodic(from:.now,by:1)){context in Text(text("Tiempo transcurrido")+" "+String(Int(context.date.timeIntervalSince(model.startedAt ?? context.date)))+" s").font(.caption).foregroundStyle(.secondary)}
|
||||||
|
}
|
||||||
|
if let error=model.error {Label(text(error),systemImage:"exclamationmark.triangle").foregroundStyle(.orange).textSelection(.enabled)}
|
||||||
|
if !model.logs.isEmpty {
|
||||||
|
DisclosureGroup(text("Actividad de instalación"),isExpanded:$expanded) {
|
||||||
|
HStack {Button(text("Copiar registro")){model.copyLogs(text:text)};if let url=model.logURL {Button(text("Mostrar registro guardado")){NSWorkspace.shared.activateFileViewerSelecting([url])}};Spacer()}
|
||||||
|
ScrollView {LazyVStack(alignment:.leading,spacing:5) {ForEach(model.logs) {entry in Text(entry.date.formatted(date:.omitted,time:.standard)+" · "+text(entry.message)+(entry.progress.map{" · "+String(Int($0*100))+" %"} ?? "")).font(.system(size:11,design:.monospaced)).textSelection(.enabled)}}.frame(maxWidth:.infinity,alignment:.leading)}.frame(height:160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import Darwin
|
|||||||
@Published var target = "" {
|
@Published var target = "" {
|
||||||
didSet { output=nil;assets=nil;failed=false;restoreOutput() }
|
didSet { output=nil;assets=nil;failed=false;restoreOutput() }
|
||||||
}
|
}
|
||||||
|
@Published var splitIOS=(UserDefaults.standard.object(forKey:"iosSplitGameData") as? Bool ?? true) {didSet {UserDefaults.standard.set(splitIOS,forKey:"iosSplitGameData");output=nil;assets=nil;restoreOutput()}}
|
||||||
var isMac: Bool {target == "mac"}
|
var isMac: Bool {target == "mac"}
|
||||||
@Published var folder: URL?
|
@Published var folder: URL?
|
||||||
@Published var keysFolder: URL?
|
@Published var keysFolder: URL?
|
||||||
@@ -33,6 +34,8 @@ import Darwin
|
|||||||
@Published var showSaves = false
|
@Published var showSaves = false
|
||||||
@Published var showSideload = false
|
@Published var showSideload = false
|
||||||
@Published var showDeviceLibrary = false
|
@Published var showDeviceLibrary = false
|
||||||
|
@Published var installerReady = false
|
||||||
|
var chooseGameAfterDeviceLibrary=false
|
||||||
@Published var catalog: [CatalogGame] = []
|
@Published var catalog: [CatalogGame] = []
|
||||||
@Published var catalogFailed = false
|
@Published var catalogFailed = false
|
||||||
private var process: Process?
|
private var process: Process?
|
||||||
@@ -59,25 +62,25 @@ import Darwin
|
|||||||
refreshCatalog()
|
refreshCatalog()
|
||||||
showCatalog=true
|
showCatalog=true
|
||||||
}
|
}
|
||||||
func chooseFolder() {
|
@discardableResult func chooseFolder()->Bool {
|
||||||
let panel=NSOpenPanel();panel.canChooseDirectories=true;panel.canChooseFiles=false
|
let panel=NSOpenPanel();panel.canChooseDirectories=true;panel.canChooseFiles=false
|
||||||
panel.allowsMultipleSelection=false;panel.prompt=text("Elegir juego")
|
panel.allowsMultipleSelection=false;panel.prompt=text("Elegir juego")
|
||||||
panel.message=text("Selecciona la carpeta con el juego base y sus actualizaciones NSP/XCI.")
|
panel.message=text("Selecciona la carpeta con el juego base y sus actualizaciones NSP/XCI.")
|
||||||
guard panel.runModal() == .OK, let url=panel.url else { return }
|
guard panel.runModal() == .OK, let url=panel.url else { return false }
|
||||||
do {
|
do {
|
||||||
let files=try FileManager.default.contentsOfDirectory(at:url,includingPropertiesForKeys:nil)
|
let files=try FileManager.default.contentsOfDirectory(at:url,includingPropertiesForKeys:nil)
|
||||||
guard files.contains(where:{["nsp","xci","nsz","xcz"].contains($0.pathExtension.lowercased())}) else {
|
guard files.contains(where:{["nsp","xci","nsz","xcz"].contains($0.pathExtension.lowercased())}) else {
|
||||||
output=nil;assets=nil;phase="No encontramos el juego en esa carpeta."
|
output=nil;assets=nil;phase="No encontramos el juego en esa carpeta."
|
||||||
detail="Elige la carpeta que contiene los archivos NSP o XCI directamente."
|
detail="Elige la carpeta que contiene los archivos NSP o XCI directamente."
|
||||||
failed=true;return
|
failed=true;return false
|
||||||
}
|
}
|
||||||
guard !url.standardizedFileURL.path.hasPrefix(repo.standardizedFileURL.path+"/"), url.standardizedFileURL != repo.standardizedFileURL else {
|
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
|
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;UserDefaults.standard.set(url.path,forKey:"lastGameFolder")
|
||||||
phase="Listo para preparar el juego.";detail="Un clic más. Los archivos anteriores se conservan."
|
phase="Listo para preparar el juego.";detail="Un clic más. Los archivos anteriores se conservan."
|
||||||
failed=false;output=nil;assets=nil;transcript="";restoreOutput()
|
failed=false;output=nil;assets=nil;transcript="";restoreOutput();return true
|
||||||
} catch { phase="No pudimos leer esa carpeta.";detail=error.localizedDescription;failed=true }
|
} catch { phase="No pudimos leer esa carpeta.";detail=error.localizedDescription;failed=true;return false }
|
||||||
}
|
}
|
||||||
func chooseKeysFolder() {
|
func chooseKeysFolder() {
|
||||||
let panel=NSOpenPanel();panel.canChooseDirectories=true;panel.canChooseFiles=false
|
let panel=NSOpenPanel();panel.canChooseDirectories=true;panel.canChooseFiles=false
|
||||||
@@ -107,11 +110,10 @@ import Darwin
|
|||||||
guard target == "ios", let folder,
|
guard target == "ios", let folder,
|
||||||
let data=try? Data(contentsOf:folder.appendingPathComponent("nxnative-ios.json")),
|
let data=try? Data(contentsOf:folder.appendingPathComponent("nxnative-ios.json")),
|
||||||
let record=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any],
|
let record=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any],
|
||||||
let path=record["light_dir"] as? String else {return}
|
let prepared=IOSPackageOutput.load(record:record,split:splitIOS) else {return}
|
||||||
let directory=URL(fileURLWithPath:path)
|
output=prepared.ipa;assets=prepared.romfs
|
||||||
if let files=try? FileManager.default.contentsOfDirectory(at:directory,includingPropertiesForKeys:nil),
|
|
||||||
let ipa=files.first(where:{$0.pathExtension=="ipa"}) { output=ipa;assets=directory.appendingPathComponent("romfs") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func build() {
|
func build() {
|
||||||
guard let folder, !running, !target.isEmpty else {return}
|
guard let folder, !running, !target.isEmpty else {return}
|
||||||
let script=repo.appendingPathComponent("scripts/prepare_ios.sh")
|
let script=repo.appendingPathComponent("scripts/prepare_ios.sh")
|
||||||
@@ -124,6 +126,7 @@ import Darwin
|
|||||||
task.executableURL=URL(fileURLWithPath:"/usr/bin/python3")
|
task.executableURL=URL(fileURLWithPath:"/usr/bin/python3")
|
||||||
var arguments=[repo.appendingPathComponent("desktop/NxNativeEasy/build_session.py").path,script.path,"--target",target,folder.path]
|
var arguments=[repo.appendingPathComponent("desktop/NxNativeEasy/build_session.py").path,script.path,"--target",target,folder.path]
|
||||||
arguments += ["--keys-folder",keysFolder.path]
|
arguments += ["--keys-folder",keysFolder.path]
|
||||||
|
if target=="ios" && !splitIOS {arguments.append("--full-only")}
|
||||||
task.arguments=arguments;task.currentDirectoryURL=repo
|
task.arguments=arguments;task.currentDirectoryURL=repo
|
||||||
task.standardOutput=pipe;task.standardError=pipe
|
task.standardOutput=pipe;task.standardError=pipe
|
||||||
var environment=ProcessInfo.processInfo.environment
|
var environment=ProcessInfo.processInfo.environment
|
||||||
@@ -133,7 +136,7 @@ import Darwin
|
|||||||
logURL=log;environment["NXE_BUILD_LOG"]=log.path;environment["NXE_GAME_DIR"]=folder.path
|
logURL=log;environment["NXE_BUILD_LOG"]=log.path;environment["NXE_GAME_DIR"]=folder.path
|
||||||
startedAt=Date();lastOutputAt=nil;activity=BuildActivity()
|
startedAt=Date();lastOutputAt=nil;activity=BuildActivity()
|
||||||
task.environment=environment
|
task.environment=environment
|
||||||
running=true;cancelled=false;failed=false;showDetails=false;output=nil;assets=nil;transcript=""
|
installerReady=false;running=true;cancelled=false;failed=false;showDetails=false;output=nil;assets=nil;transcript=""
|
||||||
phase="Preparando herramientas…";detail="Puedes dejar esta ventana abierta. No necesitas usar Terminal."
|
phase="Preparando herramientas…";detail="Puedes dejar esta ventana abierta. No necesitas usar Terminal."
|
||||||
pipe.fileHandleForReading.readabilityHandler={ [weak self] handle in
|
pipe.fileHandleForReading.readabilityHandler={ [weak self] handle in
|
||||||
let data=handle.availableData
|
let data=handle.availableData
|
||||||
@@ -151,6 +154,7 @@ import Darwin
|
|||||||
} else if status==0 {
|
} else if status==0 {
|
||||||
self.restoreOutput()
|
self.restoreOutput()
|
||||||
self.failed=self.output==nil
|
self.failed=self.output==nil
|
||||||
|
self.installerReady = !self.isMac && self.output != nil
|
||||||
self.phase=self.output == nil ? "Terminó, pero no encontramos la app." : (self.isMac ? "Tu juego está listo para Mac." : "Tu juego está listo para instalar en iPhone / iPad.")
|
self.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."
|
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 {
|
} else {
|
||||||
@@ -428,6 +432,11 @@ struct ContentView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Button(model.keysFolder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseKeysFolder()}.disabled(model.running || updater.busy || sideload.busy).accessibilityLabel(model.text("Carpeta de llaves"))
|
Button(model.keysFolder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseKeysFolder()}.disabled(model.running || updater.busy || sideload.busy).accessibilityLabel(model.text("Carpeta de llaves"))
|
||||||
}
|
}
|
||||||
|
if model.target=="ios" {
|
||||||
|
Toggle(model.text("IPA ligera: separar los datos del juego"),isOn:$model.splitIOS).disabled(model.running || updater.busy || sideload.busy)
|
||||||
|
Text(model.text(model.splitIOS ? "Recomendado: la app pesa menos y el instalador copia romfs por separado." : "IPA completa: incluye el juego dentro. Ocupa más y cada renovación vuelve a transferir toda la app.")).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Label(model.text("Carpeta y llaves → Preparar → Cuenta Apple → Instalar y copiar datos"),systemImage:"arrow.right.circle").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
if model.running {
|
if model.running {
|
||||||
Divider().overlay(Color.white.opacity(0.08))
|
Divider().overlay(Color.white.opacity(0.08))
|
||||||
HStack {
|
HStack {
|
||||||
@@ -527,7 +536,10 @@ 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)
|
}.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}}
|
.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.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)}
|
.onChange(of:model.installerReady) {_,ready in
|
||||||
|
if ready,let ipa=model.output,let folder=model.folder {sideload.configure(ipa:ipa,folder:folder,romfs:model.assets);model.showSideload=true;model.installerReady=false}
|
||||||
|
}
|
||||||
|
.sheet(isPresented:$model.showDeviceLibrary,onDismiss:{if model.chooseGameAfterDeviceLibrary {model.chooseGameAfterDeviceLibrary=false;model.target="ios";if !model.chooseFolder() {sideload.cancelPendingUpdate()}}}) {SideloadLibraryView(model:sideload,text:model.text,canInstall:!model.running && !updater.busy,prepareGame:{model.chooseGameAfterDeviceLibrary=true})}
|
||||||
.sheet(isPresented:$model.showSideload) {SideloadView(model:sideload,text:model.text)}
|
.sheet(isPresented:$model.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.showAnalysis) {if let game=model.folder {GameAnalysisView(repo:model.repo,game:game,text:model.text)} else {Text(model.text("Primero selecciona la carpeta de un juego preparado.")).padding(24)}}
|
||||||
.sheet(isPresented:$model.showCatalog) {GameCatalogView(model:model)}
|
.sheet(isPresented:$model.showCatalog) {GameCatalogView(model:model)}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import Foundation
|
|||||||
emit({'event':'hello','protocol':1,'architecture':'aarch64'})
|
emit({'event':'hello','protocol':1,'architecture':'aarch64'})
|
||||||
for line in sys.stdin:
|
for line in sys.stdin:
|
||||||
value=json.loads(line)
|
value=json.loads(line)
|
||||||
if value['op']=='devices':emit({'event':'devices','items':[{'udid':'SYNTHETIC-DEVICE','name':'Test iPad'}]})
|
if value['op']=='devices':emit({'event':'devices','items':[{'udid':'SYNTHETIC-DEVICE','name':'Test iPad','model':'iPad-test','os':'26.0','connections':['Wi-Fi']},{'udid':'SYNTHETIC-DEVICE','name':'Test iPad','model':'iPad-test','os':'26.0','connections':['USB','Wi-Fi']}]})
|
||||||
elif value['op']=='installed_apps':emit({'event':'installed_apps','items':[{'id':'org.game.custom','name':'Test game'}]})
|
elif value['op']=='installed_apps':emit({'event':'installed_apps','items':[{'id':'org.game.custom','name':'Test game'}]})
|
||||||
elif value['op']=='login':
|
elif value['op']=='login':
|
||||||
assert value['password']=='synthetic-password-only'
|
assert value['password']=='synthetic-password-only'
|
||||||
@@ -30,6 +30,9 @@ import Foundation
|
|||||||
assert value['expected_id']=='org.game.custom'
|
assert value['expected_id']=='org.game.custom'
|
||||||
assert value['sha256']=='a'*64 and 'romfs' not in value
|
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})
|
emit({'event':'installed','effective_id':value['expected_id'],'expires':time.time()+86400,'sha256':'a'*64})
|
||||||
|
elif value['op']=='account_devices':
|
||||||
|
assert value['team']=='ABCDE12345'
|
||||||
|
emit({'event':'account_devices','team':value['team'],'items':[{'udid':'REGISTERED-DEVICE','name':'Synthetic iPhone','status':'a'}]})
|
||||||
elif value['op']=='logout':emit({'event':'signed_out'})
|
elif value['op']=='logout':emit({'event':'signed_out'})
|
||||||
elif value['op']=='quit':break
|
elif value['op']=='quit':break
|
||||||
""".write(to:helper,atomically:true,encoding:.utf8)
|
""".write(to:helper,atomically:true,encoding:.utf8)
|
||||||
@@ -37,12 +40,18 @@ import Foundation
|
|||||||
let missing=SideloadModel(stateURL:root.appendingPathComponent("missing-jobs.json"),defaults:defaults,helperURL:root.appendingPathComponent("missing-helper"));missing.listDevices();assert(!missing.busy && missing.error != nil)
|
let missing=SideloadModel(stateURL:root.appendingPathComponent("missing-jobs.json"),defaults:defaults,helperURL:root.appendingPathComponent("missing-helper"));missing.listDevices();assert(!missing.busy && missing.error != nil)
|
||||||
let model=SideloadModel(stateURL:root.appendingPathComponent("jobs.json"),defaults:defaults,helperURL:helper)
|
let model=SideloadModel(stateURL:root.appendingPathComponent("jobs.json"),defaults:defaults,helperURL:helper)
|
||||||
model.listDevices();try await wait { !model.busy && model.apps.count==1 }
|
model.listDevices();try await wait { !model.busy && model.apps.count==1 }
|
||||||
assert(model.device=="SYNTHETIC-DEVICE")
|
assert(model.device=="SYNTHETIC-DEVICE" && model.devices.count==1 && model.devices[0].model=="iPad-test" && model.devices[0].connections.contains("USB"))
|
||||||
model.email=" TEST@example.org ";model.signIn(password:"synthetic-password-only",remember:false)
|
model.email=" TEST@example.org ";model.signIn(password:"synthetic-password-only",remember:false)
|
||||||
try await wait {model.codeRequired};assert(model.busy && !model.authenticated)
|
try await wait {model.codeRequired};assert(model.busy && !model.authenticated)
|
||||||
model.submitCode("123456");try await wait {model.authenticated && !model.busy}
|
model.submitCode("123456");try await wait {model.authenticated && !model.busy}
|
||||||
assert(model.email=="test@example.org" && model.teams.count==1 && model.team.isEmpty)
|
assert(model.email=="test@example.org" && model.teams.count==1 && model.team.isEmpty)
|
||||||
assert(defaults.string(forKey:"sideloadEmail")==nil)
|
assert(defaults.string(forKey:"sideloadEmail")==nil)
|
||||||
|
model.inspectAccountDevices();assert(!model.busy && model.accountDevices.isEmpty)
|
||||||
|
model.team="ABCDE12345";model.inspectAccountDevices();try await wait {!model.busy && model.accountDevices.count==1}
|
||||||
|
assert(model.accountDeviceTeam==model.team)
|
||||||
|
assert(!model.logs.map(\.message).joined().contains("synthetic-password-only"))
|
||||||
|
assert(!model.logs.map(\.message).joined().contains("123456"))
|
||||||
|
assert(!model.logs.map(\.message).joined().contains("test@example.org"))
|
||||||
model.signOut();try await wait {!model.authenticated};model.cancel()
|
model.signOut();try await wait {!model.authenticated};model.cancel()
|
||||||
let now=Date();var job=RefreshJob(id:"test",ipa:"/synthetic.ipa",gameFolder:root.path,originalID:"org.game",effectiveID:"org.game.custom",team:"ABCDE12345",udid:"test",account:RefreshPolicy.account("Test@Example.org"),sha256:"test",expires:now.addingTimeInterval(24*3600),enabled:true)
|
let now=Date();var job=RefreshJob(id:"test",ipa:"/synthetic.ipa",gameFolder:root.path,originalID:"org.game",effectiveID:"org.game.custom",team:"ABCDE12345",udid:"test",account:RefreshPolicy.account("Test@Example.org"),sha256:"test",expires:now.addingTimeInterval(24*3600),enabled:true)
|
||||||
assert(RefreshPolicy.due(job,now:now,account:RefreshPolicy.account("test@example.org")))
|
assert(RefreshPolicy.due(job,now:now,account:RefreshPolicy.account("test@example.org")))
|
||||||
@@ -70,6 +79,21 @@ import Foundation
|
|||||||
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)
|
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))
|
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)
|
let empty=SideloadModel(stateURL:root.appendingPathComponent("jobs.json"),defaults:defaults,helperURL:helper);assert(empty.jobs.count==1 && empty.jobs[0].id==second.id)
|
||||||
|
let lightweight=root.appendingPathComponent("light");try FileManager.default.createDirectory(at:lightweight,withIntermediateDirectories:true)
|
||||||
|
try Data("synthetic-ipa".utf8).write(to:lightweight.appendingPathComponent("test.ipa"))
|
||||||
|
let packaging:[String:Any]=["full_ipa":ipa.path,"light_dir":lightweight.path,"packaging":"full"]
|
||||||
|
assert(IOSPackageOutput.load(record:packaging,split:false)?.ipa==ipa)
|
||||||
|
assert(IOSPackageOutput.load(record:packaging,split:false)?.romfs==nil)
|
||||||
|
assert(IOSPackageOutput.load(record:packaging,split:true)?.romfs==lightweight.appendingPathComponent("romfs"))
|
||||||
|
assert(IOSPackageOutput.load(record:["full_ipa":ipa.path],split:false)==nil)
|
||||||
|
try JSONSerialization.data(withJSONObject:["schema":1,"bundle_id":"org.game"]).write(to:folder.appendingPathComponent("nxnative-ios.json"))
|
||||||
|
let selected=InstalledGame(id:"org.game.custom",name:"Test game")
|
||||||
|
model.prepareUpdate(selected);model.configure(ipa:ipa,folder:folder,romfs:nil)
|
||||||
|
try await wait {!model.busy && model.apps.count==1}
|
||||||
|
assert(model.existing==selected.id && model.device=="SYNTHETIC-DEVICE")
|
||||||
|
assert(model.logURL != nil)
|
||||||
|
let permissions=try FileManager.default.attributesOfItem(atPath:model.logURL!.path)[.posixPermissions] as! NSNumber
|
||||||
|
assert(permissions.intValue==0o600)
|
||||||
model.cancel()
|
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")
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.9.0",
|
||||||
"build": 13,
|
"build": 14,
|
||||||
"minimumMacOSMajor": 26
|
"minimumMacOSMajor": 26
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,12 @@ static char *g_argv[4]; static int g_argc;
|
|||||||
static NSString *romfs_locate(void) {
|
static NSString *romfs_locate(void) {
|
||||||
NSFileManager *fm = NSFileManager.defaultManager;
|
NSFileManager *fm = NSFileManager.defaultManager;
|
||||||
NSString *doc = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
|
NSString *doc = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
|
||||||
for (NSString *d in @[[doc stringByAppendingPathComponent:@"romfs"], [NSBundle.mainBundle.resourcePath stringByAppendingPathComponent:@"romfs"]])
|
NSString *external = [doc stringByAppendingPathComponent:@"romfs"];
|
||||||
|
NSString *bundled = [NSBundle.mainBundle.resourcePath stringByAppendingPathComponent:@"romfs"];
|
||||||
|
// A complete native installation must not boot stale externally copied data.
|
||||||
|
// Legacy/full apps still accept an intentional Documents override by default.
|
||||||
|
BOOL preferBundled = [NSBundle.mainBundle.infoDictionary[@"DMPreferBundledRomFS"] boolValue];
|
||||||
|
for (NSString *d in (preferBundled ? @[bundled, external] : @[external, bundled]))
|
||||||
if ([fm fileExistsAtPath:[d stringByAppendingPathComponent:@DM_GAME_ROMFS_MARKER]]) return d; // only a directory containing the game profile's romfs_marker path counts (Mario: System/Resource, the resource size table)
|
if ([fm fileExistsAtPath:[d stringByAppendingPathComponent:@DM_GAME_ROMFS_MARKER]]) return d; // only a directory containing the game profile's romfs_marker path counts (Mario: System/Resource, the resource size table)
|
||||||
return nil;
|
return nil;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ appear everywhere without being forgotten. No recurring schedule is implied.
|
|||||||
Keep personal commit attribution, original upstream authors and licensing.
|
Keep personal commit attribution, original upstream authors and licensing.
|
||||||
Preserve the isideload exact-identifier patch, signed/profile identity checks,
|
Preserve the isideload exact-identifier patch, signed/profile identity checks,
|
||||||
Keychain session storage, redacted stdio and RomFS-only transfer capability.
|
Keychain session storage, redacted stdio and RomFS-only transfer capability.
|
||||||
|
Preserve UDID deduplication (USB preferred), device metadata/search/update
|
||||||
|
selection, separate connected/registered inventories and logs without raw
|
||||||
|
credential/provider payloads. Full/light packaging must retain the bundled
|
||||||
|
RomFS priority flag and legacy-record rebuild gate; never delete external
|
||||||
|
content or saves to switch modes. Show actual registration results, not an
|
||||||
|
invented remaining free-account quota.
|
||||||
Never add automatic certificate revocation, account/team changes, save-folder
|
Never add automatic certificate revocation, account/team changes, save-folder
|
||||||
writes or password logging. Helper tests use synthetic credentials and no device;
|
writes or password logging. Helper tests use synthetic credentials and no device;
|
||||||
compilation never counts as successful Apple login or physical installation.
|
compilation never counts as successful Apple login or physical installation.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ One line per symptom in the [subsystem references](../reference/): what you see
|
|||||||
- preparation appears stuck; the player cannot inspect compilation output until a failure. → [Desktop preparation shows a spinner with no build activity](../reference/packing.md#desktop-preparation-shows-a-spinner-with-no-build-activity)
|
- preparation appears stuck; the player cannot inspect compilation output until a failure. → [Desktop preparation shows a spinner with no build activity](../reference/packing.md#desktop-preparation-shows-a-spinner-with-no-build-activity)
|
||||||
- the official Rust component shell installer rejects part of `--prefix` as an unknown option; a partial LLVM setup can also make `rust-objcopy` fail to load `libLLVM.dylib`. → [Native sideload toolchain install rejects a workspace path with spaces](../reference/packing.md#native-sideload-toolchain-install-rejects-a-workspace-path-with-spaces)
|
- the official Rust component shell installer rejects part of `--prefix` as an unknown option; a partial LLVM setup can also make `rust-objcopy` fail to load `libLLVM.dylib`. → [Native sideload toolchain install rejects a workspace path with spaces](../reference/packing.md#native-sideload-toolchain-install-rejects-a-workspace-path-with-spaces)
|
||||||
- a replacement signer can append a second team suffix or produce another app identifier, separating the user from the existing app container. → [A sideload signer changes an existing game's bundle identifier](../reference/packing.md#a-sideload-signer-changes-an-existing-games-bundle-identifier)
|
- a replacement signer can append a second team suffix or produce another app identifier, separating the user from the existing app container. → [A sideload signer changes an existing game's bundle identifier](../reference/packing.md#a-sideload-signer-changes-an-existing-games-bundle-identifier)
|
||||||
|
- a complete game update includes new bundled RomFS but an existing Documents/romfs can take priority when the app boots. → [A complete iOS IPA can still select older external game data](../reference/packing.md#a-complete-ios-ipa-can-still-select-older-external-game-data)
|
||||||
|
|
||||||
## Rewriter
|
## Rewriter
|
||||||
|
|
||||||
|
|||||||
@@ -435,3 +435,29 @@ assertions, account rejection and non-destructive record removal. The production
|
|||||||
ARM64 bundle compiled and its signature verified; localization, documentation and
|
ARM64 bundle compiled and its signature verified; localization, documentation and
|
||||||
78-profile catalog checks passed. No runtime, shader or Rust helper change was
|
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.
|
needed. No real account login, signing or physical-device install was performed.
|
||||||
|
|
||||||
|
## Guided device workspace and complete iOS packaging (1.9)
|
||||||
|
|
||||||
|
The device workspace now exposes metadata, transport/readiness, installed app
|
||||||
|
search, update selection, Apple-account access and separate team registration
|
||||||
|
queries. USB/Wi-Fi duplicates merge by UDID with USB preferred. After preparing
|
||||||
|
an iOS game the installer opens, but signing/installing still needs explicit
|
||||||
|
approval in the UI. Lightweight mode remains default; full-only mode includes
|
||||||
|
RomFS and skips separate transfer. The new `DMPreferBundledRomFS` runtime flag
|
||||||
|
prevents an older Documents/romfs from shadowing a complete native installation;
|
||||||
|
unflagged apps retain legacy overrides. No saves or external directories are
|
||||||
|
removed. Installation activity is bounded, timestamped, copyable and privately
|
||||||
|
retained, without raw protocol/account credentials.
|
||||||
|
|
||||||
|
Validation: 11 Rust cases passed; the expanded Swift fixture covers duplicate
|
||||||
|
connections, metadata, installed-target continuity, explicit team-account device
|
||||||
|
queries, full/light output selection, legacy full-record refusal and private log
|
||||||
|
permissions/redaction. All 64 script cases completed (two optional dependency/
|
||||||
|
compiled-helper checks skipped in that suite); documentation/localization and
|
||||||
|
78-profile catalog gates passed. The unsigned iOS compile check compiled 178
|
||||||
|
files with zero failures. Read-only real-device probing returned one unique
|
||||||
|
connected iPhone with model/OS and 184 installed user apps. No Apple-account
|
||||||
|
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.
|
||||||
@@ -70,3 +70,10 @@
|
|||||||
- **Cause:** isideload 0.5.0 unconditionally appended the team ID to the supplied bundle ID, even when the caller already knows the effective installed identifier.
|
- **Cause:** isideload 0.5.0 unconditionally appended the team ID to the supplied bundle ID, even when the caller already knows the effective installed identifier.
|
||||||
- **Fix:** the small vendored MIT patch preserves the caller's exact final ID. Our helper selects an actual existing device app, checks that it matches the game, verifies the signed/provisioned identity and never uninstalls the previous app to force an update. Original preparation IPAs stay unsigned. Tests cover custom identifiers and mismatch refusal; real-account/device migration still requires validation.
|
- **Fix:** the small vendored MIT patch preserves the caller's exact final ID. Our helper selects an actual existing device app, checks that it matches the game, verifies the signed/provisioned identity and never uninstalls the previous app to force an update. Original preparation IPAs stay unsigned. Tests cover custom identifiers and mismatch refusal; real-account/device migration still requires validation.
|
||||||
- **Found on:** source review and synthetic tests for the [integrated installer](../sideloading.md#user-flow), with no existing user installation modified.
|
- **Found on:** source review and synthetic tests for the [integrated installer](../sideloading.md#user-flow), with no existing user installation modified.
|
||||||
|
|
||||||
|
### A complete iOS IPA can still select older external game data
|
||||||
|
|
||||||
|
- **Symptom:** a complete game update includes new bundled RomFS but an existing Documents/romfs can take priority when the app boots.
|
||||||
|
- **Cause:** the legacy iOS boot search intentionally checks Documents first to support external game-content overrides; preserving the container across an update also preserves that older directory.
|
||||||
|
- **Fix:** the native signer marks bundles containing RomFS with `DMPreferBundledRomFS`. The iOS boot search honors the flag by checking bundled content first, with Documents as fallback. Unflagged apps retain the old override behavior. The desktop full-mode preparation gate requires a new preparation record; it does not silently reuse an older runtime. No save or external content is deleted. Physical mode migration remains unvalidated.
|
||||||
|
- **Found on:** [native installer source review](../sideloading.md#guided-device-workspace-19), not a verified gameplay regression.
|
||||||
@@ -162,3 +162,74 @@ The implementation covers our prepared-game installation lifecycle. It is not a
|
|||||||
full binary/source migration of Sideloadly: no proprietary code is available to
|
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
|
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.
|
not implemented. Intel Sideloadly is not required by the native code path.
|
||||||
|
|
||||||
|
## Guided device workspace (1.9)
|
||||||
|
|
||||||
|
The device workspace separates three different inventories:
|
||||||
|
|
||||||
|
- **Connected devices:** read-only usbmuxd/Lockdown discovery. Duplicate USB/Wi-Fi
|
||||||
|
entries are merged by UDID, preferring USB for operations. Device cards show
|
||||||
|
Apple's model identifier, OS version, available transports and trust/readiness.
|
||||||
|
A model identifier is not a guessed marketing model name.
|
||||||
|
- **Installed apps:** the selected device's actual installation-proxy user-app
|
||||||
|
list, with names, effective bundle IDs, versions and search. These do not need
|
||||||
|
an Apple-account login and include apps outside nxnative. **Update using my
|
||||||
|
files** retains that target/device, returns to game-folder preparation and
|
||||||
|
carries the target into installation. Matching game identity is checked again
|
||||||
|
before overwriting; selecting an unrelated app does not authorize replacing it.
|
||||||
|
- **Apple-account devices:** after login and explicit team selection, **Read my
|
||||||
|
Apple account devices** queries that team's actual registrations. This is not
|
||||||
|
the same as a list of devices currently connected. A query failure is shown,
|
||||||
|
not interpreted as zero registrations or available free slots.
|
||||||
|
|
||||||
|
Apple documents a Personal Team limit of up to three test devices and seven-day
|
||||||
|
profiles in its [account overview](https://developer.apple.com/help/account/basics/about-your-developer-account).
|
||||||
|
Sideloadly's [FAQ](https://sideloadly.io/faq.html) also describes the three-active-app
|
||||||
|
limit for free accounts. The UI shows returned registrations and team type; it
|
||||||
|
cannot infer a reliable remaining quota from installed user-app counts, count
|
||||||
|
paid teams as free, remove registrations or bypass Apple's limits. No speculative
|
||||||
|
"slots remaining" counter is displayed.
|
||||||
|
|
||||||
|
### Folder to device
|
||||||
|
|
||||||
|
Choose iPhone/iPad, select your own game folder and keys, select packaging, and
|
||||||
|
press Prepare. Compilation already exposes live and retained compiler logs.
|
||||||
|
After success the native installer opens automatically: select the device, sign
|
||||||
|
in with your Apple account/2FA, explicitly choose your team and existing target,
|
||||||
|
then approve signing/installing. Installation remains an explicit button; finishing
|
||||||
|
compilation never signs or installs silently. Device-workspace account access is
|
||||||
|
also available before preparing a game. A new source folder alone does not
|
||||||
|
install anything.
|
||||||
|
|
||||||
|
**Lightweight IPA** is the default. RomFS remains separate and the installer
|
||||||
|
copies it with bounded progress and staging/recovery. **Complete IPA** uses
|
||||||
|
`--full-only`, omits separate content transfer and includes the game inside the
|
||||||
|
app; every signature renewal must transfer that larger app. Archive expansion is
|
||||||
|
bounded to 64 GiB; use lightweight mode for larger content or constrained storage. Older preparation
|
||||||
|
records require a new full preparation before using this mode. During native
|
||||||
|
signing a bundle with RomFS gets `DMPreferBundledRomFS`; the updated game runtime
|
||||||
|
then checks bundled content first, falling back to Documents when absent. This
|
||||||
|
prevents a complete game update from booting an older external RomFS. It does not
|
||||||
|
delete the previous external directory or touch saves. Legacy apps without the
|
||||||
|
flag retain their intentional Documents override. Physical content-mode migration
|
||||||
|
still needs device validation.
|
||||||
|
|
||||||
|
**Installation activity** shows timestamped, bounded events for discovery, login
|
||||||
|
state, device registration, signing, signature verification, installation and
|
||||||
|
content transfer. Percentages come from actual helper callbacks when available;
|
||||||
|
otherwise progress is indeterminate with elapsed time. The viewer can copy the
|
||||||
|
log and reveal a retained `nxnative-tools/logs/install-<id>.log` in the game folder.
|
||||||
|
Only fixed local status/error messages and numeric progress/Apple error codes
|
||||||
|
are logged, never raw protocol payloads, passwords, 2FA codes, Apple responses,
|
||||||
|
account names, device IDs or session tokens. The compiler log remains separate.
|
||||||
|
|
||||||
|
### Honest parity boundary
|
||||||
|
|
||||||
|
The REA investigation was read-only Mach-O inspection, not full decompilation or
|
||||||
|
a recovered Sideloadly source tree. This project implements our game workflow
|
||||||
|
using a separately licensed open-source signer. It has no claim of full Sideloadly
|
||||||
|
parity: arbitrary IPA/plugin injection, all advanced signing options and its
|
||||||
|
unattended background-daemon behavior are not implemented. Auto-renewal requires
|
||||||
|
our launcher running. Real connected-device metadata and installed-app discovery
|
||||||
|
were checked locally; account registrations, real provisioning/signing, physical
|
||||||
|
RomFS recovery and unattended renewal remain unvalidated. Keep the fallback.
|
||||||
@@ -3,8 +3,8 @@ mod idle;
|
|||||||
mod romfs;
|
mod romfs;
|
||||||
use idevice::{
|
use idevice::{
|
||||||
IdeviceService,
|
IdeviceService,
|
||||||
provider::UsbmuxdProvider,
|
provider::{IdeviceProvider, UsbmuxdProvider},
|
||||||
usbmuxd::{UsbmuxdAddr, UsbmuxdConnection},
|
usbmuxd::{Connection, UsbmuxdAddr, UsbmuxdConnection, UsbmuxdDevice},
|
||||||
};
|
};
|
||||||
use isideload::{
|
use isideload::{
|
||||||
anisette::remote_v3::RemoteV3AnisetteProvider,
|
anisette::remote_v3::RemoteV3AnisetteProvider,
|
||||||
@@ -109,25 +109,65 @@ fn effective_id(
|
|||||||
}
|
}
|
||||||
async fn provider(udid: &str) -> Result<UsbmuxdProvider, Report> {
|
async fn provider(udid: &str) -> Result<UsbmuxdProvider, Report> {
|
||||||
let mut mux = UsbmuxdConnection::default().await?;
|
let mut mux = UsbmuxdConnection::default().await?;
|
||||||
let devices = mux.get_devices().await?;
|
let devices = unique_devices(mux.get_devices().await?);
|
||||||
let device = devices
|
let device = devices
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|d| d.udid == udid)
|
.find(|d| d.udid == udid)
|
||||||
.ok_or_else(|| report!("Device disconnected"))?;
|
.ok_or_else(|| report!("Device disconnected"))?;
|
||||||
Ok(device.to_provider(UsbmuxdAddr::from_env_var()?, "nxnative-easy"))
|
Ok(device.to_provider(UsbmuxdAddr::from_env_var()?, "nxnative-easy"))
|
||||||
}
|
}
|
||||||
|
// usbmuxd can report the same paired device over USB and Wi-Fi. Keep one
|
||||||
|
// stable identity and prefer USB for metadata, app queries and installations.
|
||||||
|
fn unique_devices(mut devices: Vec<UsbmuxdDevice>) -> Vec<UsbmuxdDevice> {
|
||||||
|
devices.sort_by_key(|d| {
|
||||||
|
(
|
||||||
|
d.udid.clone(),
|
||||||
|
!matches!(d.connection_type, Connection::Usb),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
devices.dedup_by(|a, b| a.udid == b.udid);
|
||||||
|
devices
|
||||||
|
}
|
||||||
|
async fn device_details(p: &UsbmuxdProvider) -> Result<plist::Dictionary, Report> {
|
||||||
|
let mut lockdown = idevice::lockdown::LockdownClient::connect(p).await?;
|
||||||
|
lockdown.start_session(&p.get_pairing_file().await?).await?;
|
||||||
|
lockdown
|
||||||
|
.get_value(None, None)
|
||||||
|
.await?
|
||||||
|
.into_dictionary()
|
||||||
|
.ok_or_else(|| report!("Missing device metadata"))
|
||||||
|
}
|
||||||
async fn devices() -> Result<(), Report> {
|
async fn devices() -> Result<(), Report> {
|
||||||
let mut mux = UsbmuxdConnection::default().await?;
|
let mut mux = UsbmuxdConnection::default().await?;
|
||||||
|
let all = mux.get_devices().await?;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for device in mux.get_devices().await? {
|
for device in unique_devices(all.clone()) {
|
||||||
let p = device.to_provider(UsbmuxdAddr::from_env_var()?, "nxnative-easy");
|
let p = device.to_provider(UsbmuxdAddr::from_env_var()?, "nxnative-easy");
|
||||||
let name = tokio::time::timeout(Duration::from_secs(3), IdeviceInfo::from_device(&p))
|
let details = tokio::time::timeout(Duration::from_secs(5), device_details(&p))
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(Result::ok)
|
.and_then(Result::ok);
|
||||||
.map(|i| i.name)
|
let value = |key: &str| {
|
||||||
.unwrap_or_else(|| "iPhone / iPad — unlock and trust this Mac".into());
|
details
|
||||||
result.push(json!({"udid":device.udid,"name":name}));
|
.as_ref()
|
||||||
|
.and_then(|d| d.get(key))
|
||||||
|
.and_then(plist::Value::as_string)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
let connections: Vec<_> = all
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.udid == device.udid)
|
||||||
|
.map(|d| match d.connection_type {
|
||||||
|
Connection::Usb => "USB",
|
||||||
|
Connection::Network(_) => "Wi-Fi",
|
||||||
|
Connection::Unknown(_) => "Unknown",
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let name = value("DeviceName");
|
||||||
|
result.push(json!({"udid":device.udid,"name":if name.is_empty(){"iPhone / iPad".to_string()}else{name},"model":value("ProductType"),"os":value("ProductVersion"),"kind":value("DeviceClass"),"ready":details.is_some(),"connections":connections}));
|
||||||
}
|
}
|
||||||
emit(json!({"event":"devices","items":result}));
|
emit(json!({"event":"devices","items":result}));
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -145,7 +185,7 @@ async fn installed(udid: &str) -> Result<Vec<Value>, Report> {
|
|||||||
.or_else(|| app.get("CFBundleName"))
|
.or_else(|| app.get("CFBundleName"))
|
||||||
.and_then(plist::Value::as_string)
|
.and_then(plist::Value::as_string)
|
||||||
.unwrap_or(&id);
|
.unwrap_or(&id);
|
||||||
Some(json!({"id":id,"name":name}))
|
Some(json!({"id":id,"name":name,"version":app.get("CFBundleShortVersionString").and_then(plist::Value::as_string).unwrap_or("")}))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -220,7 +260,7 @@ async fn login(request: &mut Value, restore: bool) -> Result<Session, Report> {
|
|||||||
.build();
|
.build();
|
||||||
let choices: Vec<_> = teams
|
let choices: Vec<_> = teams
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| json!({"id":t.team_id,"name":t.name}))
|
.map(|t| json!({"id":t.team_id,"name":t.name,"kind":t.r#type,"status":t.status}))
|
||||||
.collect();
|
.collect();
|
||||||
emit(json!({"event":"signed_in","teams":choices}));
|
emit(json!({"event":"signed_in","teams":choices}));
|
||||||
Ok(Session {
|
Ok(Session {
|
||||||
@@ -275,7 +315,8 @@ fn extract(ipa: &Path, stage: &Path) -> Result<PathBuf, Report> {
|
|||||||
expanded = expanded
|
expanded = expanded
|
||||||
.checked_add(entry.size())
|
.checked_add(entry.size())
|
||||||
.ok_or_else(|| report!("IPA too large"))?;
|
.ok_or_else(|| report!("IPA too large"))?;
|
||||||
if expanded > 16 * 1024 * 1024 * 1024 {
|
// Full game IPAs can exceed 16 GiB; retain a bounded 64 GiB ceiling.
|
||||||
|
if expanded > 64 * 1024 * 1024 * 1024 {
|
||||||
bail!("IPA too large")
|
bail!("IPA too large")
|
||||||
}
|
}
|
||||||
let output = stage.join(relative);
|
let output = stage.join(relative);
|
||||||
@@ -413,6 +454,11 @@ async fn install(session: &mut Session, request: &Value) -> Result<(), Report> {
|
|||||||
}
|
}
|
||||||
// Rebind the main bundle and its extensions using the SDK's own prefix rules.
|
// Rebind the main bundle and its extensions using the SDK's own prefix rules.
|
||||||
rebind_app(&app, original, &base)?;
|
rebind_app(&app, original, &base)?;
|
||||||
|
if app.join("romfs").is_dir() {
|
||||||
|
let mut info: plist::Dictionary = plist::from_file(app.join("Info.plist"))?;
|
||||||
|
info.insert("DMPreferBundledRomFS".into(), plist::Value::Boolean(true));
|
||||||
|
plist::to_file_binary(app.join("Info.plist"), &info)?;
|
||||||
|
}
|
||||||
if request["update"].as_bool() == Some(true) {
|
if request["update"].as_bool() == Some(true) {
|
||||||
let name = metadata
|
let name = metadata
|
||||||
.get("CFBundleDisplayName")
|
.get("CFBundleDisplayName")
|
||||||
@@ -429,6 +475,7 @@ async fn install(session: &mut Session, request: &Value) -> Result<(), Report> {
|
|||||||
bail!("Selected installed app does not match this game")
|
bail!("Selected installed app does not match this game")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
emit(json!({"event":"progress","phase":"Registering device"}));
|
||||||
session
|
session
|
||||||
.loader
|
.loader
|
||||||
.get_dev_session()
|
.get_dev_session()
|
||||||
@@ -449,6 +496,7 @@ async fn install(session: &mut Session, request: &Value) -> Result<(), Report> {
|
|||||||
{
|
{
|
||||||
bail!("Signed identity mismatch; not installed")
|
bail!("Signed identity mismatch; not installed")
|
||||||
}
|
}
|
||||||
|
emit(json!({"event":"progress","phase":"Verifying signature"}));
|
||||||
let expires = profile_details(&signed, &effective, team_id)?;
|
let expires = profile_details(&signed, &effective, team_id)?;
|
||||||
let verify = std::process::Command::new("/usr/bin/codesign")
|
let verify = std::process::Command::new("/usr/bin/codesign")
|
||||||
.args(["--verify", "--deep", "--strict"])
|
.args(["--verify", "--deep", "--strict"])
|
||||||
@@ -502,6 +550,16 @@ async fn main() {
|
|||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"account_devices" => match &mut session {
|
||||||
|
Some(value) => async {
|
||||||
|
let id=text(&request,"team")?;
|
||||||
|
let team=value.teams.iter().find(|t|t.team_id==id).cloned().ok_or_else(||report!("Select your team"))?;
|
||||||
|
let items=value.loader.get_dev_session().list_devices(&team,None).await?;
|
||||||
|
emit(json!({"event":"account_devices","team":id,"items":items.iter().map(|d|json!({"udid":d.device_number,"name":d.name,"status":d.status})).collect::<Vec<_>>()}));
|
||||||
|
Ok(())
|
||||||
|
}.await,
|
||||||
|
None => Err(report!("Sign in first")),
|
||||||
|
},
|
||||||
"install" => match &mut session {
|
"install" => match &mut session {
|
||||||
Some(value) => install(value, &request).await,
|
Some(value) => install(value, &request).await,
|
||||||
None => Err(report!("Sign in first")),
|
None => Err(report!("Sign in first")),
|
||||||
@@ -554,6 +612,28 @@ async fn main() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn usb_and_wifi_are_one_device_with_usb_preferred() {
|
||||||
|
let usb = UsbmuxdDevice {
|
||||||
|
udid: "same".into(),
|
||||||
|
device_id: 2,
|
||||||
|
connection_type: Connection::Usb,
|
||||||
|
};
|
||||||
|
let wifi = UsbmuxdDevice {
|
||||||
|
udid: "same".into(),
|
||||||
|
device_id: 1,
|
||||||
|
connection_type: Connection::Network("127.0.0.1".parse().unwrap()),
|
||||||
|
};
|
||||||
|
for items in [vec![wifi.clone(), usb.clone()], vec![
|
||||||
|
usb.clone(),
|
||||||
|
wifi.clone(),
|
||||||
|
]] {
|
||||||
|
let result = unique_devices(items);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].device_id, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn identifiers_preserve_update_identity() {
|
fn identifiers_preserve_update_identity() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ def main(argv=None):
|
|||||||
print(json.dumps({'ev':'stage','name':'Export lightweight IPA','step':'Separating game data','k':0,'n':0}),flush=True)
|
print(json.dumps({'ev':'stage','name':'Export lightweight IPA','step':'Separating game data','k':0,'n':0}),flush=True)
|
||||||
light_dir = None if args.full_only else split_ipa(output)
|
light_dir = None if args.full_only else split_ipa(output)
|
||||||
record = dict(schema=1, **settings, version=info.get('CFBundleShortVersionString'),
|
record = dict(schema=1, **settings, version=info.get('CFBundleShortVersionString'),
|
||||||
source_revision=revision, full_ipa=str(output), light_dir=str(light_dir) if light_dir else None)
|
source_revision=revision, packaging="full" if args.full_only else "split", full_ipa=str(output), light_dir=str(light_dir) if light_dir else None)
|
||||||
temp = state_path.with_suffix('.json.tmp')
|
temp = state_path.with_suffix('.json.tmp')
|
||||||
temp.write_text(json.dumps(record,indent=2)+'\n'); temp.replace(state_path)
|
temp.write_text(json.dumps(record,indent=2)+'\n'); temp.replace(state_path)
|
||||||
print('Build complete. Install over the existing app using the same personal account and effective bundle ID.')
|
print('Build complete. Install over the existing app using the same personal account and effective bundle ID.')
|
||||||
|
|||||||
Reference in new issue
Block a user