Review device slots and require explicit consent for new Apple registrations

This commit is contained in:
Erick Sanchez committed 2026-10-27 15:17:00 -06:00
1 parent 5f116da478
commit d8511da8a9
14 files changed
+298 -31

No files matched your search

+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+20
View File
@@ -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",
+77 -14
View File
@@ -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)
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.13.1", "version": "1.14.0",
"build": 21, "build": 22,
"minimumMacOSMajor": 26 "minimumMacOSMajor": 26
} }
+4 -1
View File
@@ -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
+3 -2
View File
@@ -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.
+29
View File
@@ -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.
+47
View File
@@ -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.
+80 -7
View File
@@ -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))]
+1
View File
@@ -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':[]})