Review device slots and require explicit consent for new Apple registrations
This commit is contained in:
1 parent
5f116da478
commit
d8511da8a9
14 files changed
+298
-31
No files matched your search
+1
-1
@@ -44,7 +44,7 @@ Partimos de [seedds/nxnative](https://github.com/seedds/nxnative): el motor, los
|
|||||||
|
|
||||||
- **Preparación guiada:** destino → archivos → llaves → revisión → compilación con avance y registro. Arrastrar archivos no mueve tus originales.
|
- **Preparación guiada:** destino → archivos → llaves → revisión → compilación con avance y registro. Arrastrar archivos no mueve tus originales.
|
||||||
- **Biblioteca con estados claros:** app/IPA preparada, instalaciones verificadas y última comprobación de dispositivos desconectados.
|
- **Biblioteca con estados claros:** app/IPA preparada, instalaciones verificadas y última comprobación de dispositivos desconectados.
|
||||||
- **Avisos para recompilar, diagnósticos sin datos sensibles y notificaciones al terminar.** La cuenta Apple es global; los dispositivos registrados no se presentan como cupos restantes.
|
- **Avisos para recompilar, diagnósticos sin datos sensibles y notificaciones al terminar.** La cuenta Apple es global; los cupos de cuentas gratuitas son estimaciones identificadas, con aviso antes de registrar un dispositivo nuevo.
|
||||||
- **Controles editables en iPhone:** posición, tamaño y opacidad, guardados por juego y layout.
|
- **Controles editables en iPhone:** posición, tamaño y opacidad, guardados por juego y layout.
|
||||||
- **IPA ligera sólo para iPhone/iPad:** los datos `romfs` van aparte; se conservan salidas y cachés válidas.
|
- **IPA ligera sólo para iPhone/iPad:** los datos `romfs` van aparte; se conservan salidas y cachés válidas.
|
||||||
- **Llaves del usuario:** debes seleccionar la carpeta de tus propias `prod.keys`. No incluimos, descargamos ni distribuimos llaves o juegos.
|
- **Llaves del usuario:** debes seleccionar la carpeta de tus propias `prod.keys`. No incluimos, descargamos ni distribuimos llaves o juegos.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Built on [seedds/nxnative](https://github.com/seedds/nxnative). The original pro
|
|||||||
|
|
||||||
- **Guided preparation:** platform → files → keys → review → live build progress, with drag/drop that leaves originals in place.
|
- **Guided preparation:** platform → files → keys → review → live build progress, with drag/drop that leaves originals in place.
|
||||||
- **A library that explains readiness:** local Mac/IPA outputs, verified device installations and clearly dated last-seen devices.
|
- **A library that explains readiness:** local Mac/IPA outputs, verified device installations and clearly dated last-seen devices.
|
||||||
- **Rebuild advice, redacted diagnostics and background completion notifications.** Apple Account is shared across the app; device registration counts are not presented as remaining quotas.
|
- **Rebuild advice, redacted diagnostics and background completion notifications.** Apple Account is shared across the app; free-team device slots are clearly labelled estimates, with a registration warning before installing on a new device.
|
||||||
- **Editable iPhone touch controls:** position, size and opacity, saved per game and 2D/3D layout.
|
- **Editable iPhone touch controls:** position, size and opacity, saved per game and 2D/3D layout.
|
||||||
- **Lightweight IPA export for iPhone/iPad only:** RomFS data live separately; existing outputs and valid caches are preserved.
|
- **Lightweight IPA export for iPhone/iPad only:** RomFS data live separately; existing outputs and valid caches are preserved.
|
||||||
- **Your own keys:** explicitly select the external folder containing your `prod.keys`. No games or keys are included, downloaded or distributed.
|
- **Your own keys:** explicitly select the external folder containing your `prod.keys`. No games or keys are included, downloaded or distributed.
|
||||||
|
|||||||
@@ -12,6 +12,26 @@ enum EasyLanguage: String, CaseIterable {
|
|||||||
|
|
||||||
struct EasyStrings {
|
struct EasyStrings {
|
||||||
static let english: [String:String] = [
|
static let english: [String:String] = [
|
||||||
|
"Cuenta gratuita":"Free account",
|
||||||
|
"Cupos disponibles estimados":"Estimated available slots",
|
||||||
|
"Cuenta gratuita confirmada por su membresía. Estimación basada en los registros devueltos; Apple confirma la disponibilidad al registrar.":"Free team confirmed by membership. Estimate based on returned registrations; Apple confirms availability when registering.",
|
||||||
|
"No pudimos confirmar un límite para esta membresía. No asumimos que Individual significa cuenta gratuita.":"We could not confirm a limit for this membership. Individual does not necessarily mean a free account.",
|
||||||
|
"El cupo corresponde al dispositivo, no a cada juego. Actualizar en un dispositivo ya registrado no consume otro cupo de dispositivo.":"The slot is for the device, not each game. Updating on an already registered device does not use another device slot.",
|
||||||
|
"Este dispositivo ya está registrado":"This device is already registered",
|
||||||
|
"Instalar o actualizar aquí no consume otro cupo de dispositivo.":"Installing or updating here does not use another device slot.",
|
||||||
|
"Este dispositivo usará un cupo nuevo":"This device will use a new slot",
|
||||||
|
"Al confirmar la instalación, registraremos este dispositivo con tu equipo Apple y consumirá un cupo.":"When you confirm installation, we will register this device with your Apple team and use one slot.",
|
||||||
|
"Disponibles estimados antes de instalar":"Estimated available before installation",
|
||||||
|
"Después":"After",
|
||||||
|
"Acepto registrar este dispositivo y usar un cupo":"I agree to register this device and use one slot",
|
||||||
|
"Comprueba el registro antes de instalar":"Check registration before installing",
|
||||||
|
"Consultar mi cuenta Apple":"Check my Apple account",
|
||||||
|
"Actualizar consulta":"Refresh check",
|
||||||
|
"La consulta no registra ni elimina dispositivos. Apple confirma la disponibilidad al instalar.":"Checking does not register or remove devices. Apple confirms availability during installation.",
|
||||||
|
"Actualiza el instalador para comprobar el registro del dispositivo.":"Update the installer to check device registration.",
|
||||||
|
"Este dispositivo necesita registro. Revisa el consumo de un cupo y confirma antes de instalar.":"This device needs registration. Review the device slot and confirm before installing.",
|
||||||
|
"El registro puede consumir el cupo aunque la instalación falle después.":"Registration can use the slot even if installation fails afterwards.",
|
||||||
|
|
||||||
"Verificando código…":"Verifying code…",
|
"Verificando código…":"Verifying code…",
|
||||||
"Solicitando un nuevo código…":"Requesting a new code…",
|
"Solicitando un nuevo código…":"Requesting a new code…",
|
||||||
"Verifica tu cuenta":"Verify your account",
|
"Verifica tu cuenta":"Verify your account",
|
||||||
|
|||||||
@@ -22,7 +22,18 @@ struct SideloadDevice:Identifiable {
|
|||||||
struct AccountDevice:Identifiable {let id:String;let name:String;let status:String}
|
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 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;var kind:String=""}
|
struct SideloadTeam:Identifiable {let id:String;let name:String;var kind:String="";var freeDeviceLimit:Int?}
|
||||||
|
enum DeviceRegistrationState {case unknown,registered,new}
|
||||||
|
enum AccountRegistrationPolicy {
|
||||||
|
static func remaining(limit:Int?,devices:[AccountDevice])->Int? {
|
||||||
|
guard limit==3 else {return nil}
|
||||||
|
return max(0,3-Set(devices.map{$0.id.lowercased()}).count)
|
||||||
|
}
|
||||||
|
static func state(team:String,loadedTeam:String,checkedAt:Date?,device:String,devices:[AccountDevice],now:Date=Date())->DeviceRegistrationState {
|
||||||
|
guard !team.isEmpty,team==loadedTeam,!device.isEmpty,let checkedAt,now.timeIntervalSince(checkedAt)>=0,now.timeIntervalSince(checkedAt)<=300 else {return .unknown}
|
||||||
|
return devices.contains(where:{$0.id.caseInsensitiveCompare(device) == .orderedSame}) ? .registered : .new
|
||||||
|
}
|
||||||
|
}
|
||||||
struct InstalledGame:Identifiable {let id:String;let name:String;var version:String=""}
|
struct InstalledGame:Identifiable {let id:String;let name:String;var version:String=""}
|
||||||
|
|
||||||
struct GameAppScope {
|
struct GameAppScope {
|
||||||
@@ -94,6 +105,8 @@ enum RefreshPolicy {
|
|||||||
private var scopedDiscovery=false
|
private var scopedDiscovery=false
|
||||||
@Published var accountDevices:[AccountDevice]=[]
|
@Published var accountDevices:[AccountDevice]=[]
|
||||||
@Published var accountDeviceTeam=""
|
@Published var accountDeviceTeam=""
|
||||||
|
@Published var accountDeviceLimit:Int?
|
||||||
|
@Published var accountDevicesCheckedAt:Date?
|
||||||
@Published var logs:[InstallLog]=[]
|
@Published var logs:[InstallLog]=[]
|
||||||
@Published var logURL:URL?
|
@Published var logURL:URL?
|
||||||
private var pendingUpdate:(device:String,id:String)?
|
private var pendingUpdate:(device:String,id:String)?
|
||||||
@@ -215,9 +228,16 @@ enum RefreshPolicy {
|
|||||||
guard !busy else {return}
|
guard !busy else {return}
|
||||||
device=id;existing="";apps=[];inspectApps()
|
device=id;existing="";apps=[];inspectApps()
|
||||||
}
|
}
|
||||||
|
var estimatedDeviceSlots:Int? {accountDeviceTeam==team ? AccountRegistrationPolicy.remaining(limit:accountDeviceLimit,devices:accountDevices) : nil}
|
||||||
|
var registrationState:DeviceRegistrationState {AccountRegistrationPolicy.state(team:team,loadedTeam:accountDeviceTeam,checkedAt:accountDevicesCheckedAt,device:device,devices:accountDevices)}
|
||||||
|
func accountDeviceName(_ item:AccountDevice)->String {
|
||||||
|
if let known=devices.first(where:{$0.id.caseInsensitiveCompare(item.id) == .orderedSame}) {return known.name}
|
||||||
|
if item.name.hasPrefix("device-") || item.name==item.id {return "iPhone / iPad · …"+String(item.id.suffix(6))}
|
||||||
|
return item.name
|
||||||
|
}
|
||||||
func inspectAccountDevices() {
|
func inspectAccountDevices() {
|
||||||
guard !busy,authenticated,!team.isEmpty else {return}
|
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)
|
busy=true;error=nil;startedAt=Date();fraction=nil;phase="Consultando dispositivos registrados en Apple…";accountDevices=[];accountDeviceTeam="";accountDeviceLimit=nil;accountDevicesCheckedAt=nil;record(phase)
|
||||||
send(["op":"account_devices","team":team])
|
send(["op":"account_devices","team":team])
|
||||||
}
|
}
|
||||||
func copyLogs(text:(String)->String) {
|
func copyLogs(text:(String)->String) {
|
||||||
@@ -294,19 +314,21 @@ enum RefreshPolicy {
|
|||||||
let app=apps.first(where:{$0.id==existing}) else {return false}
|
let app=apps.first(where:{$0.id==existing}) else {return false}
|
||||||
return app.version==version && romfs != nil
|
return app.version==version && romfs != nil
|
||||||
}
|
}
|
||||||
func install(autoRefresh:Bool,includeContent:Bool=true) {
|
func install(autoRefresh:Bool,includeContent:Bool=true,approveRegistration:Bool=false) {
|
||||||
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 !includeContent && !canUpdateAppOnly {error="La versión del juego cambió o no está verificada. Instala la app con sus datos nuevos.";return}
|
if !includeContent && !canUpdateAppOnly {error="La versión del juego cambió o no está verificada. Instala la app con sus datos nuevos.";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}
|
||||||
guard let effective=RefreshPolicy.identity(original:originalID,team:team,existing:existing) else {error="Ese identificador no corresponde al equipo elegido. Detenido para conservar la app anterior.";return}
|
guard let effective=RefreshPolicy.identity(original:originalID,team:team,existing:existing) else {error="Ese identificador no corresponde al equipo elegido. Detenido para conservar la app anterior.";return}
|
||||||
let job=RefreshJob(id:device+":"+effective,ipa:ipa.path,gameFolder:gameFolder.path,originalID:originalID,effectiveID:effective,team:team,udid:device,account:RefreshPolicy.account(email),sha256:"",expires:Date(),enabled:autoRefresh)
|
let job=RefreshJob(id:device+":"+effective,ipa:ipa.path,gameFolder:gameFolder.path,originalID:originalID,effectiveID:effective,team:team,udid:device,account:RefreshPolicy.account(email),sha256:"",expires:Date(),enabled:autoRefresh)
|
||||||
startInstall(job,updating:!existing.isEmpty,content:includeContent ? romfs : nil,appOnly:!includeContent)
|
startInstall(job,updating:!existing.isEmpty,content:includeContent ? romfs : nil,appOnly:!includeContent,approveRegistration:approveRegistration)
|
||||||
}
|
}
|
||||||
private func startInstall(_ job:RefreshJob,updating:Bool,content:URL?,appOnly:Bool=false) {
|
private func startInstall(_ job:RefreshJob,updating:Bool,content:URL?,appOnly:Bool=false,approveRegistration:Bool=false) {
|
||||||
|
guard helperCapabilities.contains("registration-consent-v1") else {error="Actualiza el instalador para comprobar el registro del dispositivo.";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}
|
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;loggedPhase="";loggedBucket = -1;logURL=nil;record("Firmando e instalando…");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]
|
||||||
|
value["approve_registration"]=approveRegistration && activeRefresh==nil
|
||||||
if appOnly {value["app_only"]=true}
|
if appOnly {value["app_only"]=true}
|
||||||
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}
|
||||||
@@ -341,19 +363,25 @@ enum RefreshPolicy {
|
|||||||
record("Lista de tus juegos actualizada.")
|
record("Lista de tus juegos 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 "device_registered":
|
||||||
|
guard event["team"] as? String==team,event["udid"] as? String==device else {return}
|
||||||
|
if accountDeviceTeam==team,!accountDevices.contains(where:{$0.id.caseInsensitiveCompare(device) == .orderedSame}) {
|
||||||
|
accountDevices.append(AccountDevice(id:device,name:devices.first(where:{$0.id==device})?.name ?? "iPhone / iPad",status:"registered"));accountDevicesCheckedAt=Date()
|
||||||
|
}
|
||||||
case "account_devices":
|
case "account_devices":
|
||||||
accountDeviceTeam=event["team"] as? String ?? ""
|
accountDeviceTeam=event["team"] as? String ?? ""
|
||||||
|
accountDeviceLimit=(event["free_device_limit"] as? Int)==3 ? 3 : nil;accountDevicesCheckedAt=Date()
|
||||||
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 ?? "")}
|
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.")
|
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 "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":
|
||||||
record("Sesión iniciada. Elige tu equipo personal.");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,kind:item["kind"] as? String ?? "")}
|
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 ?? "",freeDeviceLimit:(item["free_device_limit"] as? Int)==3 ? 3 : nil)}
|
||||||
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;accountDevices=[];accountDeviceTeam="";teams=[];team="";busy=false
|
case "signed_out":authenticated=false;accountDevices=[];accountDeviceTeam="";accountDeviceLimit=nil;accountDevicesCheckedAt=nil;teams=[];team="";busy=false
|
||||||
case "progress":
|
case "progress":
|
||||||
let phases=["Exporting saves":"Exportando partidas…","Verifying saves":"Verificando el respaldo…","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."]
|
let phases=["Exporting saves":"Exportando partidas…","Verifying saves":"Verificando el respaldo…","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}
|
||||||
@@ -374,7 +402,7 @@ enum RefreshPolicy {
|
|||||||
case "error":
|
case "error":
|
||||||
record("La operación se detuvo. Revisa el mensaje de 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=["version_changed":"La versión del juego cambió o no está verificada. Instala la app con sus datos nuevos.","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=["registration_consent":"Este dispositivo necesita registro. Revisa el consumo de un cupo y confirma antes de instalar.","version_changed":"La versión del juego cambió o no está verificada. Instala la app con sus datos nuevos.","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=="export_saves" || op=="copy_romfs" {
|
if op=="export_saves" || op=="copy_romfs" {
|
||||||
let failures=["game_running":"Guarda y cierra el juego desde el selector de apps antes de continuar.","idle_unknown":"No pudimos verificar que el juego esté cerrado. Guárdalo y ciérralo antes de continuar.","no_saves":"Todavía no encontramos archivos de partida guardados en este juego.","save_changed":"La partida cambió durante la exportación. Cierra el juego y vuelve a intentar.","save_metadata":"Este guardado usa metadatos que la exportación no puede conservar. Usa un respaldo completo del dispositivo.","save_destination":"Elige un ZIP nuevo; no sobrescribimos respaldos.","version_changed":"Actualiza primero la app con la misma versión que los datos preparados.","save_limits":"El guardado supera el límite de exportación de 512 MB u 8192 entradas."]
|
let failures=["game_running":"Guarda y cierra el juego desde el selector de apps antes de continuar.","idle_unknown":"No pudimos verificar que el juego esté cerrado. Guárdalo y ciérralo antes de continuar.","no_saves":"Todavía no encontramos archivos de partida guardados en este juego.","save_changed":"La partida cambió durante la exportación. Cierra el juego y vuelve a intentar.","save_metadata":"Este guardado usa metadatos que la exportación no puede conservar. Usa un respaldo completo del dispositivo.","save_destination":"Elige un ZIP nuevo; no sobrescribimos respaldos.","version_changed":"Actualiza primero la app con la misma versión que los datos preparados.","save_limits":"El guardado supera el límite de exportación de 512 MB u 8192 entradas."]
|
||||||
@@ -456,6 +484,7 @@ struct SideloadView:View {
|
|||||||
var accountOnly=false
|
var accountOnly=false
|
||||||
var embedded=false
|
var embedded=false
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var approveRegistration=false
|
||||||
@State private var installStep=0
|
@State private var installStep=0
|
||||||
@State private var password=""
|
@State private var password=""
|
||||||
@State private var remember=true
|
@State private var remember=true
|
||||||
@@ -500,13 +529,17 @@ struct SideloadView:View {
|
|||||||
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)
|
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)}
|
if let selected=model.teams.first(where:{$0.id==model.team}),!selected.kind.isEmpty {Text(text("Tipo de equipo según Apple")+": "+(selected.freeDeviceLimit==3 ? text("Cuenta gratuita") : selected.kind)).font(.caption).foregroundStyle(.secondary)}
|
||||||
Button(text("Leer dispositivos de mi cuenta Apple")){model.inspectAccountDevices()}.disabled(model.busy || model.team.isEmpty)
|
Button(text("Leer dispositivos de mi cuenta Apple")){model.inspectAccountDevices()}.disabled(model.busy || model.team.isEmpty)
|
||||||
if model.accountDeviceTeam==model.team {
|
if model.accountDeviceTeam==model.team {
|
||||||
Text(text("Dispositivos registrados en el equipo")+": "+String(model.accountDevices.count)).font(.headline)
|
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)}
|
if let remaining=model.estimatedDeviceSlots {
|
||||||
|
Label(text("Cupos disponibles estimados")+": "+String(remaining)+" / 3",systemImage:"iphone").font(.title3.bold())
|
||||||
|
Text(text("Cuenta gratuita confirmada por su membresía. Estimación basada en los registros devueltos; Apple confirma la disponibilidad al registrar.")).font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {Text(text("No pudimos confirmar un límite para esta membresía. No asumimos que Individual significa cuenta gratuita.")).font(.caption).foregroundStyle(.secondary)}
|
||||||
|
ForEach(model.accountDevices) {item in Label(model.accountDeviceName(item),systemImage:"checkmark.circle").font(.callout)}
|
||||||
}
|
}
|
||||||
Text(text("Apple no informa los cupos restantes en esta consulta. Mostramos los dispositivos registrados, sin inventar disponibilidad ni eliminar registros.")).font(.caption).foregroundStyle(.secondary)
|
Text(text("El cupo corresponde al dispositivo, no a cada juego. Actualizar en un dispositivo ya registrado no consume otro cupo de dispositivo.")).font(.caption).foregroundStyle(.secondary)
|
||||||
Button(text("Cerrar sesión")){model.signOut()}.disabled(model.busy)
|
Button(text("Cerrar sesión")){model.signOut()}.disabled(model.busy)
|
||||||
}
|
}
|
||||||
if model.codeRequired {AppleVerificationView(model:model,text:text)}
|
if model.codeRequired {AppleVerificationView(model:model,text:text)}
|
||||||
@@ -517,16 +550,17 @@ struct SideloadView:View {
|
|||||||
Text(model.devices.first(where:{$0.id==model.device})?.name ?? "iPhone / iPad").font(.headline)
|
Text(model.devices.first(where:{$0.id==model.device})?.name ?? "iPhone / iPad").font(.headline)
|
||||||
Text(model.email).font(.callout).foregroundStyle(.secondary)
|
Text(model.email).font(.callout).foregroundStyle(.secondary)
|
||||||
if let folder=model.gameFolder {Text(folder.lastPathComponent).font(.headline)}
|
if let folder=model.gameFolder {Text(folder.lastPathComponent).font(.headline)}
|
||||||
|
RegistrationReviewView(model:model,text:text,approved:$approveRegistration)
|
||||||
if model.ipa != nil {
|
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")){installStep=3;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")){installStep=3;model.install(autoRefresh:autoRefresh,approveRegistration:approveRegistration)}.buttonStyle(.borderedProminent).disabled(model.busy || model.device.isEmpty || model.team.isEmpty || model.originalID.isEmpty || model.registrationState == .unknown || (model.registrationState == .new && !approveRegistration))
|
||||||
}
|
}
|
||||||
if model.canUpdateAppOnly {
|
if model.canUpdateAppOnly {
|
||||||
Button(text("Actualizar sólo la app; conservar romfs")){installStep=3;model.install(autoRefresh:autoRefresh,includeContent:false)}.disabled(model.busy)
|
Button(text("Actualizar sólo la app; conservar romfs")){installStep=3;model.install(autoRefresh:autoRefresh,includeContent:false,approveRegistration:approveRegistration)}.disabled(model.busy || model.registrationState == .unknown || (model.registrationState == .new && !approveRegistration))
|
||||||
Text(text("Para cambios de controles o de la app con la misma versión del juego. Si cambió el juego o DLC, usa la instalación con datos nuevos.")).font(.caption).foregroundStyle(.secondary)
|
Text(text("Para cambios de controles o de la app con la misma versión del juego. Si cambió el juego o DLC, usa la instalación con datos nuevos.")).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,12 +570,41 @@ struct SideloadView:View {
|
|||||||
if !model.busy {Text(text(model.phase)).font(.callout)}
|
if !model.busy {Text(text(model.phase)).font(.callout)}
|
||||||
}.padding(24)
|
}.padding(24)
|
||||||
}
|
}
|
||||||
.onChange(of:model.team){_,value in if !value.isEmpty && model.authenticated && !model.busy {model.inspectAccountDevices()}}
|
.onChange(of:model.device){_,_ in approveRegistration=false}
|
||||||
|
.onChange(of:installStep){_,step in if step==2 {approveRegistration=false;if !model.busy {model.inspectAccountDevices()}}}
|
||||||
|
.onChange(of:model.team){_,value in approveRegistration=false;if !value.isEmpty && model.authenticated && !model.busy {model.inspectAccountDevices()}}
|
||||||
.frame(maxWidth:.infinity,maxHeight:.infinity).frame(width:embedded ? nil : 760,height:embedded ? nil : 720).tint(Color(red:0.79,green:0.95,blue:0.37)).preferredColorScheme(.dark).interactiveDismissDisabled(model.busy)
|
.frame(maxWidth:.infinity,maxHeight:.infinity).frame(width:embedded ? nil : 760,height:embedded ? nil : 720).tint(Color(red:0.79,green:0.95,blue:0.37)).preferredColorScheme(.dark).interactiveDismissDisabled(model.busy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct RegistrationReviewView:View {
|
||||||
|
@ObservedObject var model:SideloadModel
|
||||||
|
let text:(String)->String
|
||||||
|
@Binding var approved:Bool
|
||||||
|
var body:some View {
|
||||||
|
VStack(alignment:.leading,spacing:12) {
|
||||||
|
switch model.registrationState {
|
||||||
|
case .registered:
|
||||||
|
Label(text("Este dispositivo ya está registrado"),systemImage:"checkmark.seal.fill").font(.headline).foregroundStyle(.green)
|
||||||
|
Text(text("Instalar o actualizar aquí no consume otro cupo de dispositivo.")).font(.callout)
|
||||||
|
case .new:
|
||||||
|
Label(text("Este dispositivo usará un cupo nuevo"),systemImage:"plus.circle.fill").font(.headline).foregroundStyle(.orange)
|
||||||
|
Text(text("Al confirmar la instalación, registraremos este dispositivo con tu equipo Apple y consumirá un cupo.")).font(.callout)
|
||||||
|
if let remaining=model.estimatedDeviceSlots {Text(text("Disponibles estimados antes de instalar")+": "+String(remaining)+" · "+text("Después")+": "+String(max(0,remaining-1))).font(.callout.bold())}
|
||||||
|
Text(text("El registro puede consumir el cupo aunque la instalación falle después.")).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Toggle(text("Acepto registrar este dispositivo y usar un cupo"),isOn:$approved).disabled(model.busy)
|
||||||
|
case .unknown:
|
||||||
|
Label(text("Comprueba el registro antes de instalar"),systemImage:"questionmark.circle").font(.headline)
|
||||||
|
Button(text("Consultar mi cuenta Apple")){model.inspectAccountDevices()}.disabled(model.busy)
|
||||||
|
}
|
||||||
|
if model.registrationState != .unknown {Button(text("Actualizar consulta")){approved=false;model.inspectAccountDevices()}.disabled(model.busy)}
|
||||||
|
Text(text("La consulta no registra ni elimina dispositivos. Apple confirma la disponibilidad al instalar.")).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}.padding(18).frame(maxWidth:.infinity,alignment:.leading).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
struct AppleVerificationView:View {
|
struct AppleVerificationView:View {
|
||||||
@ObservedObject var model:SideloadModel
|
@ObservedObject var model:SideloadModel
|
||||||
let text:(String)->String
|
let text:(String)->String
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Foundation
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
import sys,json,time,zipfile,os
|
import sys,json,time,zipfile,os
|
||||||
def emit(value):print(json.dumps(value),flush=True)
|
def emit(value):print(json.dumps(value),flush=True)
|
||||||
emit({'event':'hello','protocol':1,'architecture':'aarch64','capabilities':['scoped-games-v1','save-export-v1','romfs-copy-v1']})
|
emit({'event':'hello','protocol':1,'architecture':'aarch64','capabilities':['scoped-games-v1','save-export-v1','romfs-copy-v1','registration-consent-v1']})
|
||||||
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','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']}]})
|
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']}]})
|
||||||
@@ -29,6 +29,10 @@ import Foundation
|
|||||||
elif value['op']=='restore':emit({'event':'signed_in','teams':[{'id':'ABCDE12345','name':'Personal test team'}]})
|
elif value['op']=='restore':emit({'event':'signed_in','teams':[{'id':'ABCDE12345','name':'Personal test team'}]})
|
||||||
elif value['op']=='install':
|
elif value['op']=='install':
|
||||||
assert value['automatic']==False and value['update']==True
|
assert value['automatic']==False and value['update']==True
|
||||||
|
assert value.get('approve_registration') in (False,True)
|
||||||
|
if value['approve_registration']:
|
||||||
|
assert value['udid']=='SYNTHETIC-DEVICE' and value['team']=='ABCDE12345'
|
||||||
|
emit({'event':'device_registered','team':value['team'],'udid':value['udid']})
|
||||||
assert value['expected_id']=='org.game.custom'
|
assert value['expected_id']=='org.game.custom'
|
||||||
assert 'romfs' not in value
|
assert 'romfs' not in value
|
||||||
if not value.get('app_only'):assert value['sha256']=='a'*64
|
if not value.get('app_only'):assert value['sha256']=='a'*64
|
||||||
@@ -43,7 +47,7 @@ import Foundation
|
|||||||
emit({'event':'content_copied','effective_id':value['effective_id']})
|
emit({'event':'content_copied','effective_id':value['effective_id']})
|
||||||
elif value['op']=='account_devices':
|
elif value['op']=='account_devices':
|
||||||
assert value['team']=='ABCDE12345'
|
assert value['team']=='ABCDE12345'
|
||||||
emit({'event':'account_devices','team':value['team'],'items':[{'udid':'REGISTERED-DEVICE','name':'Synthetic iPhone','status':'a'}]})
|
emit({'event':'account_devices','team':value['team'],'free_device_limit':3,'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)
|
||||||
@@ -69,7 +73,15 @@ import Foundation
|
|||||||
assert(defaults.string(forKey:"sideloadEmail")==nil)
|
assert(defaults.string(forKey:"sideloadEmail")==nil)
|
||||||
model.inspectAccountDevices();assert(!model.busy && model.accountDevices.isEmpty)
|
model.inspectAccountDevices();assert(!model.busy && model.accountDevices.isEmpty)
|
||||||
model.team="ABCDE12345";model.inspectAccountDevices();try await wait {!model.busy && model.accountDevices.count==1}
|
model.team="ABCDE12345";model.inspectAccountDevices();try await wait {!model.busy && model.accountDevices.count==1}
|
||||||
assert(model.accountDeviceTeam==model.team)
|
assert(model.accountDeviceTeam==model.team && model.estimatedDeviceSlots==2)
|
||||||
|
assert(model.registrationState == .new)
|
||||||
|
let registered=[AccountDevice(id:"PHONE",name:"Test",status:"c"),AccountDevice(id:"phone",name:"duplicate",status:"c")]
|
||||||
|
assert(AccountRegistrationPolicy.remaining(limit:3,devices:registered)==2)
|
||||||
|
assert(AccountRegistrationPolicy.remaining(limit:nil,devices:registered)==nil)
|
||||||
|
assert(AccountRegistrationPolicy.state(team:"A",loadedTeam:"A",checkedAt:Date(),device:"phone",devices:registered) == .registered)
|
||||||
|
assert(AccountRegistrationPolicy.state(team:"B",loadedTeam:"A",checkedAt:Date(),device:"phone",devices:registered) == .unknown)
|
||||||
|
assert(AccountRegistrationPolicy.state(team:"A",loadedTeam:"A",checkedAt:Date().addingTimeInterval(-301),device:"phone",devices:registered) == .unknown)
|
||||||
|
assert(AccountRegistrationPolicy.remaining(limit:3,devices:(0..<4).map {AccountDevice(id:String($0),name:"Test",status:"c")})==0)
|
||||||
assert(!model.logs.map(\.message).joined().contains("synthetic-password-only"))
|
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("123456"))
|
||||||
assert(!model.logs.map(\.message).joined().contains("test@example.org"))
|
assert(!model.logs.map(\.message).joined().contains("test@example.org"))
|
||||||
@@ -126,6 +138,9 @@ import Foundation
|
|||||||
assert(model.dataFolder(for:selected)?.path==model.romfs?.path)
|
assert(model.dataFolder(for:selected)?.path==model.romfs?.path)
|
||||||
assert(model.canUpdateAppOnly)
|
assert(model.canUpdateAppOnly)
|
||||||
model.install(autoRefresh:false,includeContent:false);try await wait {!model.busy}
|
model.install(autoRefresh:false,includeContent:false);try await wait {!model.busy}
|
||||||
|
model.inspectAccountDevices();try await wait {!model.busy};assert(model.estimatedDeviceSlots==2)
|
||||||
|
model.install(autoRefresh:false,includeContent:false,approveRegistration:true);try await wait {!model.busy};assert(model.error==nil)
|
||||||
|
assert(model.estimatedDeviceSlots==1 && model.registrationState == .registered)
|
||||||
let backup=root.appendingPathComponent("progress.zip")
|
let backup=root.appendingPathComponent("progress.zip")
|
||||||
model.exportSaves(selected,destination:backup,confirmedClosed:true);try await wait {!model.busy && model.exportedSaves != nil};assert(model.exportedSaves==backup)
|
model.exportSaves(selected,destination:backup,confirmedClosed:true);try await wait {!model.busy && model.exportedSaves != nil};assert(model.exportedSaves==backup)
|
||||||
model.copyData(selected,confirmedClosed:true);try await wait {!model.busy};assert(model.fraction==1 && model.error==nil)
|
model.copyData(selected,confirmedClosed:true);try await wait {!model.busy};assert(model.fraction==1 && model.error==nil)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.13.1",
|
"version": "1.14.0",
|
||||||
"build": 21,
|
"build": 22,
|
||||||
"minimumMacOSMajor": 26
|
"minimumMacOSMajor": 26
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,10 @@ appear everywhere without being forgotten. No recurring schedule is implied.
|
|||||||
redacted diagnostics and background completion notices with OS permission.
|
redacted diagnostics and background completion notices with OS permission.
|
||||||
Keep per-platform source provenance/rebuild advice and scoped device-presence
|
Keep per-platform source provenance/rebuild advice and scoped device-presence
|
||||||
history; never present cached presence or unknown history as current proof.
|
history; never present cached presence or unknown history as current proof.
|
||||||
Do not infer remaining Apple device slots from registration counts.
|
Show only labelled slot estimates for explicitly identified free memberships
|
||||||
|
using the reviewed Apple policy and distinct returned registrations. Individual
|
||||||
|
is not proof of free membership. Keep new-device consent bound to team/device,
|
||||||
|
fresh helper-side checks and automatic-refresh refusal to register a new device.
|
||||||
Keep Apple Account in the application sidebar, independent of Devices;
|
Keep Apple Account in the application sidebar, independent of Devices;
|
||||||
show the signed-in email and retain account-free save export/content copy.
|
show the signed-in email and retain account-free save export/content copy.
|
||||||
Preserve the collection list/detail, personal folder library, both-platform output discovery and
|
Preserve the collection list/detail, personal folder library, both-platform output discovery and
|
||||||
|
|||||||
@@ -132,8 +132,9 @@ For iOS, the resulting IPA is passed to the installer directly: select the paire
|
|||||||
device, use the global Apple account if needed, choose its team, review the
|
device, use the global Apple account if needed, choose its team, review the
|
||||||
existing app identity and confirm installation. Reading the team's registered
|
existing app identity and confirm installation. Reading the team's registered
|
||||||
devices is separate from physical connection. The response does not expose a
|
devices is separate from physical connection. The response does not expose a
|
||||||
reliable remaining-slot count; the UI explicitly says so rather than subtracting
|
guaranteed remaining-slot count. Since 1.14, explicitly identified free memberships
|
||||||
from an assumed free-account quota. Signing/install execution remains subject
|
show a labelled estimate from the reviewed policy and distinct returned records;
|
||||||
|
unknown membership never inherits an assumed free-account quota. Signing/install execution remains subject
|
||||||
to the existing validation limits in sideloading.md.
|
to the existing validation limits in sideloading.md.
|
||||||
|
|
||||||
The library separates a prepared local IPA from an observed device installation.
|
The library separates a prepared local IPA from an observed device installation.
|
||||||
|
|||||||
@@ -618,3 +618,32 @@ screenshots cover library, platform selection/review and build activity. Python
|
|||||||
lint retained 414 baseline findings, with none added. The game regression harness
|
lint retained 414 baseline findings, with none added. The game regression harness
|
||||||
was invoked with the local root but found no registered Mac Dread build. No new
|
was invoked with the local root but found no registered Mac Dread build. No new
|
||||||
gameplay or real-account signing validation is claimed; host/shaders are unchanged.
|
gameplay or real-account signing validation is claimed; host/shaders are unchanged.
|
||||||
|
|
||||||
|
## New-device registration review and free-team estimates (1.14.0)
|
||||||
|
|
||||||
|
Team membership metadata now distinguishes an explicit free membership from the
|
||||||
|
ambiguous Individual type. Only known free teams show a policy-based, labelled
|
||||||
|
slot estimate; missing/paid/ambiguous metadata remains unknown. Source policy and
|
||||||
|
limitations are documented in sideloading.md. Account device names use connected
|
||||||
|
metadata when possible and omit unexplained raw status letters.
|
||||||
|
|
||||||
|
Installation review refreshes registrations, reports whether the selected device
|
||||||
|
already exists and requires acceptance for a new registration. The helper checks
|
||||||
|
again before adding any device and refuses new registrations during automatic
|
||||||
|
renewal. Older helpers cannot bypass the new consent capability. Confirmation is
|
||||||
|
bound to the chosen team/device; registration success updates local counts even
|
||||||
|
if a later signing/install step fails. Existing game files and saves are untouched
|
||||||
|
by review and lookup. No real-account login/registration/install was performed.
|
||||||
|
|
||||||
|
Validation uses compiled synthetic client fixtures for case-insensitive matches,
|
||||||
|
team mismatch, stale snapshots, distinct registration counts, unknown limits,
|
||||||
|
zero clamping and explicit approval transport. Rust fixtures cover unknown/paid/
|
||||||
|
free/ambiguous membership and the manual/automatic registration-consent matrix.
|
||||||
|
The existing original-identity, RomFS, save-export and device-scope checks remain.
|
||||||
|
|
||||||
|
Final checks: 20 Rust cases passed. All 68 script cases completed with one
|
||||||
|
optional JWT dependency skip; compiled consent/capacity client checks, complete
|
||||||
|
SwiftUI compilation and synthetic new/existing registration renders passed.
|
||||||
|
Documentation and the 78-profile catalog passed. The production native helper
|
||||||
|
was rebuilt with the pinned toolchain; real-account registration/signing remains
|
||||||
|
unexecuted. No games or save data were accessed or modified by these tests.
|
||||||
@@ -319,3 +319,50 @@ or requesting another delivery and is never persisted or logged. No password,
|
|||||||
provider/session, request transport or delivery behavior changed. Generic busy
|
provider/session, request transport or delivery behavior changed. Generic busy
|
||||||
activity and the installer Continue button do not compete with a pending code.
|
activity and the installer Continue button do not compete with a pending code.
|
||||||
The update does not restart a running launcher or interrupt its in-memory login.
|
The update does not restart a running launcher or interrupt its in-memory login.
|
||||||
|
|
||||||
|
## Registration capacity and explicit new-device consent (1.14.0)
|
||||||
|
|
||||||
|
The team type `Individual` does not distinguish a paid membership from a free
|
||||||
|
one. The vendored team decoder now retains optional membership names. A single
|
||||||
|
explicit free membership on an Individual team enables the reviewed free-policy
|
||||||
|
limit of three devices. Missing, ambiguous or paid membership data stays unknown;
|
||||||
|
it never inherits three slots simply because a team is named Individual.
|
||||||
|
|
||||||
|
Apple's [personal-team documentation](https://developer.apple.com/help/account/basics/about-your-developer-account),
|
||||||
|
reviewed 2026-10-27, states the free-team device limit and seven-day expiration.
|
||||||
|
The membership interpretation was cross-checked against
|
||||||
|
[AltSign's team decoder](https://github.com/rileytestut/AltSign/blob/master/AltSign/Model/Apple%20API/ALTTeam.m);
|
||||||
|
no AltSign code was imported. Paid memberships have different
|
||||||
|
[device registration rules](https://developer.apple.com/help/account/devices/devices-overview).
|
||||||
|
We do not assume an annual paid quota or infer paid eligibility from absent data.
|
||||||
|
|
||||||
|
The account page labels `max(0, 3 - distinct returned iOS registrations)` as an
|
||||||
|
**estimate** for an explicitly identified free team. It is not a server-provided
|
||||||
|
remaining-capacity value: expired/disabled records, other registrations, changes
|
||||||
|
from another Mac, and Apple's final eligibility checks can affect availability.
|
||||||
|
Unknown membership shows an explanation instead of an invented counter. The
|
||||||
|
connected device name replaces synthetic `device-<UDID>` names when matched;
|
||||||
|
unrecognized status letters are not presented as meaningful readiness claims.
|
||||||
|
|
||||||
|
The install review fetches the selected team's registrations and accepts client
|
||||||
|
snapshots for at most five minutes. It distinguishes unknown, already registered
|
||||||
|
and new devices using exact case-insensitive UDID equality. An already registered
|
||||||
|
device uses no additional device slot for another game/update; application-ID and
|
||||||
|
app/profile limits remain separate. A new device shows the estimated before/after
|
||||||
|
count when available and requires an explicit acceptance checkbox. Changing team
|
||||||
|
or device clears that acceptance. Refreshing the query is read-only.
|
||||||
|
|
||||||
|
The helper advertises `registration-consent-v1`. New clients reject older helpers
|
||||||
|
for native installs rather than falling back around the guard. Immediately before
|
||||||
|
registration, the helper queries Apple again. It registers only a new device with
|
||||||
|
explicit manual approval; automatic refresh may reuse an existing registration
|
||||||
|
but cannot create one, even if an approval flag is supplied. A failed read stops
|
||||||
|
the operation. No certificate is automatically revoked, registration removed or
|
||||||
|
existing game uninstalled. The protocol returns only a derived free-team limit
|
||||||
|
and known registration IDs/names, never raw membership/provider/session payloads.
|
||||||
|
|
||||||
|
After successful registration, a narrow team/device event updates the local
|
||||||
|
inventory even if signing later fails: registering happens before installation
|
||||||
|
and can consume the slot independently of install success. The UI explains this
|
||||||
|
before confirmation. Apple remains authoritative when registration is attempted.
|
||||||
|
Agent validation never logs into a real account or registers a device.
|
||||||
@@ -41,6 +41,26 @@ struct Session {
|
|||||||
loader: Loader,
|
loader: Loader,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A team labelled Individual may be either paid or free. Missing membership is unknown.
|
||||||
|
fn free_device_limit(team: &DeveloperTeam) -> Option<u64> {
|
||||||
|
let memberships = team.memberships.as_ref()?;
|
||||||
|
if team.r#type.as_deref() != Some("Individual") || memberships.len() != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = memberships[0].name.as_deref()?.to_ascii_lowercase();
|
||||||
|
if name
|
||||||
|
.split(|c: char| !c.is_ascii_alphabetic())
|
||||||
|
.any(|part| part == "free")
|
||||||
|
{
|
||||||
|
Some(3)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn registration_allowed(registered: bool, automatic: bool, approved: bool) -> bool {
|
||||||
|
registered || (!automatic && approved)
|
||||||
|
}
|
||||||
|
|
||||||
fn emit(value: Value) {
|
fn emit(value: Value) {
|
||||||
let mut output = io::stdout().lock();
|
let mut output = io::stdout().lock();
|
||||||
let _ = writeln!(output, "{value}");
|
let _ = writeln!(output, "{value}");
|
||||||
@@ -244,7 +264,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,"kind":t.r#type,"status":t.status}))
|
.map(|t| json!({"id":t.team_id,"name":t.name,"kind":t.r#type,"status":t.status,"free_device_limit":free_device_limit(t)}))
|
||||||
.collect();
|
.collect();
|
||||||
emit(json!({"event":"signed_in","teams":choices}));
|
emit(json!({"event":"signed_in","teams":choices}));
|
||||||
Ok(Session {
|
Ok(Session {
|
||||||
@@ -476,12 +496,29 @@ 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"}));
|
let registered = session
|
||||||
session
|
|
||||||
.loader
|
.loader
|
||||||
.get_dev_session()
|
.get_dev_session()
|
||||||
.ensure_device_registered(&team, &info.name, &info.udid, None)
|
.list_devices(&team, None)
|
||||||
.await?;
|
.await?
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.device_number.eq_ignore_ascii_case(&info.udid));
|
||||||
|
if !registration_allowed(
|
||||||
|
registered,
|
||||||
|
request["automatic"].as_bool() == Some(true),
|
||||||
|
request["approve_registration"].as_bool() == Some(true),
|
||||||
|
) {
|
||||||
|
bail!("New device registration requires explicit approval")
|
||||||
|
}
|
||||||
|
if !registered {
|
||||||
|
emit(json!({"event":"progress","phase":"Registering device"}));
|
||||||
|
session
|
||||||
|
.loader
|
||||||
|
.get_dev_session()
|
||||||
|
.ensure_device_registered(&team, &info.name, &info.udid, None)
|
||||||
|
.await?;
|
||||||
|
emit(json!({"event":"device_registered","team":team.team_id,"udid":info.udid}));
|
||||||
|
}
|
||||||
let progress = |fraction: f32| async move {
|
let progress = |fraction: f32| async move {
|
||||||
emit(json!({"event":"progress","phase":"Signing app","fraction":fraction}));
|
emit(json!({"event":"progress","phase":"Signing app","fraction":fraction}));
|
||||||
};
|
};
|
||||||
@@ -570,7 +607,7 @@ async fn main() {
|
|||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
let _ = isideload::init();
|
let _ = isideload::init();
|
||||||
emit(
|
emit(
|
||||||
json!({"event":"hello","protocol":1,"architecture":std::env::consts::ARCH,"capabilities":["scoped-games-v1","save-export-v1","romfs-copy-v1"]}),
|
json!({"event":"hello","protocol":1,"architecture":std::env::consts::ARCH,"capabilities":["scoped-games-v1","save-export-v1","romfs-copy-v1","registration-consent-v1"]}),
|
||||||
);
|
);
|
||||||
let mut session: Option<Session> = None;
|
let mut session: Option<Session> = None;
|
||||||
while let Ok(mut request) = read_request() {
|
while let Ok(mut request) = read_request() {
|
||||||
@@ -605,7 +642,7 @@ async fn main() {
|
|||||||
let id=text(&request,"team")?;
|
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 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?;
|
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<_>>()}));
|
emit(json!({"event":"account_devices","team":id,"free_device_limit":free_device_limit(&team),"items":items.iter().map(|d|json!({"udid":d.device_number,"name":d.name,"status":d.status})).collect::<Vec<_>>()}));
|
||||||
Ok(())
|
Ok(())
|
||||||
}.await,
|
}.await,
|
||||||
None => Err(report!("Sign in first")),
|
None => Err(report!("Sign in first")),
|
||||||
@@ -665,6 +702,8 @@ async fn main() {
|
|||||||
"not_installed"
|
"not_installed"
|
||||||
} else if detail.contains("does not match this game") {
|
} else if detail.contains("does not match this game") {
|
||||||
"wrong_app"
|
"wrong_app"
|
||||||
|
} else if detail.contains("registration requires explicit approval") {
|
||||||
|
"registration_consent"
|
||||||
} else if detail.contains("storage") {
|
} else if detail.contains("storage") {
|
||||||
"storage"
|
"storage"
|
||||||
} else if detail.contains("IPA changed") {
|
} else if detail.contains("IPA changed") {
|
||||||
@@ -682,6 +721,40 @@ async fn main() {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
|
fn registration_requires_explicit_manual_consent() {
|
||||||
|
assert!(registration_allowed(true, true, false));
|
||||||
|
assert!(registration_allowed(true, false, false));
|
||||||
|
assert!(!registration_allowed(false, false, false));
|
||||||
|
assert!(!registration_allowed(false, true, true));
|
||||||
|
assert!(registration_allowed(false, false, true));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn individual_is_not_proof_of_free_membership() {
|
||||||
|
let decode = |v: Value| serde_json::from_value::<DeveloperTeam>(v).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
free_device_limit(&decode(json!({"teamId":"TEST","type":"Individual"}))),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
free_device_limit(&decode(
|
||||||
|
json!({"teamId":"TEST","type":"Individual","memberships":[{"name":"Free Provisioning"}]})
|
||||||
|
)),
|
||||||
|
Some(3)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
free_device_limit(&decode(
|
||||||
|
json!({"teamId":"TEST","type":"Individual","memberships":[{"name":"Apple Developer Program"}]})
|
||||||
|
)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
free_device_limit(&decode(
|
||||||
|
json!({"teamId":"TEST","type":"Individual","memberships":[{"name":"Free Provisioning"},{"name":"Apple Developer Program"}]})
|
||||||
|
)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn usb_and_wifi_are_one_device_with_usb_preferred() {
|
fn usb_and_wifi_are_one_device_with_usb_preferred() {
|
||||||
let usb = UsbmuxdDevice {
|
let usb = UsbmuxdDevice {
|
||||||
udid: "same".into(),
|
udid: "same".into(),
|
||||||
|
|||||||
@@ -16,3 +16,11 @@ was changed. The helper explicitly refuses automatic certificate revocation.
|
|||||||
When updating this dependency, compare this patch with the new upstream source,
|
When updating this dependency, compare this patch with the new upstream source,
|
||||||
retain identity checks and rerun the helper/device tests. The parent Cargo.lock
|
retain identity checks and rerun the helper/device tests. The parent Cargo.lock
|
||||||
pins all remaining dependencies. Source is retained here with the MIT license.
|
pins all remaining dependencies. Source is retained here with the MIT license.
|
||||||
|
|
||||||
|
## Optional team membership metadata
|
||||||
|
|
||||||
|
The team decoder retains optional membership names for free-team classification.
|
||||||
|
Individual alone does not establish a free account. The helper emits only the
|
||||||
|
derived optional free-policy limit; membership/provider payloads remain private.
|
||||||
|
No account credentials, certificate policy or team selection behavior changes.
|
||||||
|
New-device consent and the fresh registration check live in the helper itself.
|
||||||
@@ -12,6 +12,13 @@ pub struct DeveloperTeam {
|
|||||||
pub team_id: String,
|
pub team_id: String,
|
||||||
pub r#type: Option<String>,
|
pub r#type: Option<String>,
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub memberships: Option<Vec<DeveloperMembership>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct DeveloperMembership {
|
||||||
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "wasm", async_trait::async_trait(?Send))]
|
#[cfg_attr(feature = "wasm", async_trait::async_trait(?Send))]
|
||||||
|
|||||||
@@ -23,4 +23,5 @@ class SideloadProtocol(unittest.TestCase):
|
|||||||
result=subprocess.run([str(helper)],input=request,text=True,capture_output=True,check=True,timeout=10)
|
result=subprocess.run([str(helper)],input=request,text=True,capture_output=True,check=True,timeout=10)
|
||||||
events=[json.loads(line) for line in result.stdout.splitlines()]
|
events=[json.loads(line) for line in result.stdout.splitlines()]
|
||||||
self.assertIn('scoped-games-v1',events[0]['capabilities'])
|
self.assertIn('scoped-games-v1',events[0]['capabilities'])
|
||||||
|
self.assertIn('registration-consent-v1',events[0]['capabilities'])
|
||||||
self.assertEqual(events[1],{'event':'installed_apps','items':[]})
|
self.assertEqual(events[1],{'event':'installed_apps','items':[]})
|
||||||
Reference in new issue
Block a user