From d8511da8a9788c5e9e7ab414ccf56cc00caa7a72 Mon Sep 17 00:00:00 2001 From: Erick Sanchez <7268597+nalancer08@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:17:00 -0600 Subject: [PATCH] Review device slots and require explicit consent for new Apple registrations --- README-es.md | 2 +- README.md | 2 +- desktop/NxNativeEasy/Localization.swift | 20 ++++ desktop/NxNativeEasy/Sideloading.swift | 91 ++++++++++++++++--- .../NxNativeEasy/tests/sideload_check.swift | 21 ++++- desktop/release.json | 4 +- docs/agents/07-upstream-maintenance.md | 5 +- docs/frontend-library.md | 5 +- docs/personal-ios.md | 29 ++++++ docs/sideloading.md | 47 ++++++++++ native/sideload-helper/src/main.rs | 87 ++++++++++++++++-- .../vendor/isideload/NXNATIVE_PATCH.md | 8 ++ .../vendor/isideload/src/dev/teams.rs | 7 ++ scripts/tests/test_sideload_protocol.py | 1 + 14 files changed, 298 insertions(+), 31 deletions(-) diff --git a/README-es.md b/README-es.md index 3078307..c720fd0 100644 --- a/README-es.md +++ b/README-es.md @@ -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. - **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. - **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. diff --git a/README.md b/README.md index e18f7c1..b2806d5 100644 --- a/README.md +++ b/README.md @@ -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. - **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. - **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. diff --git a/desktop/NxNativeEasy/Localization.swift b/desktop/NxNativeEasy/Localization.swift index b87e35c..037fe44 100644 --- a/desktop/NxNativeEasy/Localization.swift +++ b/desktop/NxNativeEasy/Localization.swift @@ -12,6 +12,26 @@ enum EasyLanguage: String, CaseIterable { struct EasyStrings { 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…", "Solicitando un nuevo código…":"Requesting a new code…", "Verifica tu cuenta":"Verify your account", diff --git a/desktop/NxNativeEasy/Sideloading.swift b/desktop/NxNativeEasy/Sideloading.swift index 59f9bc6..3b83436 100644 --- a/desktop/NxNativeEasy/Sideloading.swift +++ b/desktop/NxNativeEasy/Sideloading.swift @@ -22,7 +22,18 @@ struct SideloadDevice:Identifiable { struct AccountDevice:Identifiable {let id:String;let name:String;let status:String} struct InstallLog:Identifiable {let id=UUID();let date=Date();let message:String;var progress:Double?=nil} struct TrustedSMS:Identifiable {let id:Int;let digits:String} -struct 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 GameAppScope { @@ -94,6 +105,8 @@ enum RefreshPolicy { private var scopedDiscovery=false @Published var accountDevices:[AccountDevice]=[] @Published var accountDeviceTeam="" + @Published var accountDeviceLimit:Int? + @Published var accountDevicesCheckedAt:Date? @Published var logs:[InstallLog]=[] @Published var logURL:URL? private var pendingUpdate:(device:String,id:String)? @@ -215,9 +228,16 @@ enum RefreshPolicy { guard !busy else {return} 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() { 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]) } func copyLogs(text:(String)->String) { @@ -294,19 +314,21 @@ enum RefreshPolicy { let app=apps.first(where:{$0.id==existing}) else {return false} 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} 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} 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) - 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} 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 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 updating {value["expected_id"]=job.effectiveID} if !job.sha256.isEmpty {value["sha256"]=job.sha256} @@ -341,19 +363,25 @@ enum RefreshPolicy { record("Lista de tus juegos actualizada.") let matches=apps.filter {$0.id.hasPrefix(originalID)};if existing.isEmpty,matches.count==1 {existing=matches[0].id} 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": 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 ?? "")} busy=false;record("Dispositivos registrados consultados.") case "two_factor":record("Introduce el código de Apple.");codeRequired=true;phase="Introduce el código de Apple.";smsNumbers=((event["numbers"] as? [[String:Any]]) ?? []).compactMap {number in guard let id=number["id"] as? Int,let digits=number["last_digits"] as? String else {return nil};return TrustedSMS(id:id,digits:digits)} case "signed_in": 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} - 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=""} 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)} - 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": 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} @@ -374,7 +402,7 @@ enum RefreshPolicy { case "error": record("La operación se detuvo. Revisa el mensaje de error.") 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.") 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."] @@ -456,6 +484,7 @@ struct SideloadView:View { var accountOnly=false var embedded=false @Environment(\.dismiss) private var dismiss + @State private var approveRegistration=false @State private var installStep=0 @State private var password="" @State private var remember=true @@ -500,13 +529,17 @@ struct SideloadView:View { Label(text("Sesión iniciada"),systemImage:"person.crop.circle.badge.checkmark") Text(model.email).font(.caption).textSelection(.enabled) Picker(text("Tu equipo personal"),selection:$model.team) {Text(text("Selecciona tu equipo")).tag("");ForEach(model.teams){Text($0.name+" · "+$0.id).tag($0.id)}}.disabled(model.busy) - 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) if model.accountDeviceTeam==model.team { Text(text("Dispositivos registrados en el equipo")+": "+String(model.accountDevices.count)).font(.headline) - ForEach(model.accountDevices) {item in Text(item.name+" · …"+String(item.id.suffix(8))+" · "+item.status).font(.caption)} + 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) } 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.email).font(.callout).foregroundStyle(.secondary) if let folder=model.gameFolder {Text(folder.lastPathComponent).font(.headline)} + RegistrationReviewView(model:model,text:text,approved:$approveRegistration) 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) 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("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) - 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 { - 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) } } @@ -536,12 +570,41 @@ struct SideloadView:View { if !model.busy {Text(text(model.phase)).font(.callout)} }.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) } } +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 { @ObservedObject var model:SideloadModel let text:(String)->String diff --git a/desktop/NxNativeEasy/tests/sideload_check.swift b/desktop/NxNativeEasy/tests/sideload_check.swift index 85f48ce..5235704 100644 --- a/desktop/NxNativeEasy/tests/sideload_check.swift +++ b/desktop/NxNativeEasy/tests/sideload_check.swift @@ -13,7 +13,7 @@ import Foundation #!/usr/bin/python3 import sys,json,time,zipfile,os 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: 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']}]}) @@ -29,6 +29,10 @@ import Foundation elif value['op']=='restore':emit({'event':'signed_in','teams':[{'id':'ABCDE12345','name':'Personal test team'}]}) elif value['op']=='install': assert value['automatic']==False and value['update']==True + assert value.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 'romfs' not in value 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']}) elif value['op']=='account_devices': assert value['team']=='ABCDE12345' - emit({'event':'account_devices','team':value['team'],'items':[{'udid':'REGISTERED-DEVICE','name':'Synthetic iPhone','status':'a'}]}) + 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']=='quit':break """.write(to:helper,atomically:true,encoding:.utf8) @@ -69,7 +73,15 @@ import Foundation assert(defaults.string(forKey:"sideloadEmail")==nil) model.inspectAccountDevices();assert(!model.busy && model.accountDevices.isEmpty) model.team="ABCDE12345";model.inspectAccountDevices();try await wait {!model.busy && model.accountDevices.count==1} - assert(model.accountDeviceTeam==model.team) + assert(model.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("123456")) 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.canUpdateAppOnly) 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") 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) diff --git a/desktop/release.json b/desktop/release.json index cee2a3e..7d283ea 100644 --- a/desktop/release.json +++ b/desktop/release.json @@ -1,5 +1,5 @@ { - "version": "1.13.1", - "build": 21, + "version": "1.14.0", + "build": 22, "minimumMacOSMajor": 26 } diff --git a/docs/agents/07-upstream-maintenance.md b/docs/agents/07-upstream-maintenance.md index b5086cf..2877a2f 100644 --- a/docs/agents/07-upstream-maintenance.md +++ b/docs/agents/07-upstream-maintenance.md @@ -24,7 +24,10 @@ appear everywhere without being forgotten. No recurring schedule is implied. redacted diagnostics and background completion notices with OS permission. Keep per-platform source provenance/rebuild advice and scoped device-presence 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; 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 diff --git a/docs/frontend-library.md b/docs/frontend-library.md index a7f904d..e70aaa7 100644 --- a/docs/frontend-library.md +++ b/docs/frontend-library.md @@ -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 existing app identity and confirm installation. Reading the team's registered devices is separate from physical connection. The response does not expose a -reliable remaining-slot count; the UI explicitly says so rather than subtracting -from an assumed free-account quota. Signing/install execution remains subject +guaranteed remaining-slot count. Since 1.14, explicitly identified free memberships +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. The library separates a prepared local IPA from an observed device installation. diff --git a/docs/personal-ios.md b/docs/personal-ios.md index 774ac4c..c93d7d8 100644 --- a/docs/personal-ios.md +++ b/docs/personal-ios.md @@ -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 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. + +## 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. diff --git a/docs/sideloading.md b/docs/sideloading.md index 5765e63..aede715 100644 --- a/docs/sideloading.md +++ b/docs/sideloading.md @@ -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 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. + +## 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-` 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. diff --git a/native/sideload-helper/src/main.rs b/native/sideload-helper/src/main.rs index cbf29e1..c65b885 100644 --- a/native/sideload-helper/src/main.rs +++ b/native/sideload-helper/src/main.rs @@ -41,6 +41,26 @@ struct Session { loader: Loader, } +// A team labelled Individual may be either paid or free. Missing membership is unknown. +fn free_device_limit(team: &DeveloperTeam) -> Option { + 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) { let mut output = io::stdout().lock(); let _ = writeln!(output, "{value}"); @@ -244,7 +264,7 @@ async fn login(request: &mut Value, restore: bool) -> Result { .build(); let choices: Vec<_> = teams .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(); emit(json!({"event":"signed_in","teams":choices})); 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") } } - emit(json!({"event":"progress","phase":"Registering device"})); - session + let registered = session .loader .get_dev_session() - .ensure_device_registered(&team, &info.name, &info.udid, None) - .await?; + .list_devices(&team, None) + .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 { 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 _ = isideload::init(); 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 = None; while let Ok(mut request) = read_request() { @@ -605,7 +642,7 @@ async fn main() { let id=text(&request,"team")?; let team=value.teams.iter().find(|t|t.team_id==id).cloned().ok_or_else(||report!("Select your team"))?; let items=value.loader.get_dev_session().list_devices(&team,None).await?; - emit(json!({"event":"account_devices","team":id,"items":items.iter().map(|d|json!({"udid":d.device_number,"name":d.name,"status":d.status})).collect::>()})); + 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::>()})); Ok(()) }.await, None => Err(report!("Sign in first")), @@ -665,6 +702,8 @@ async fn main() { "not_installed" } else if detail.contains("does not match this game") { "wrong_app" + } else if detail.contains("registration requires explicit approval") { + "registration_consent" } else if detail.contains("storage") { "storage" } else if detail.contains("IPA changed") { @@ -682,6 +721,40 @@ async fn main() { mod tests { use super::*; #[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::(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() { let usb = UsbmuxdDevice { udid: "same".into(), diff --git a/native/sideload-helper/vendor/isideload/NXNATIVE_PATCH.md b/native/sideload-helper/vendor/isideload/NXNATIVE_PATCH.md index 3126ad3..c2fcd5e 100644 --- a/native/sideload-helper/vendor/isideload/NXNATIVE_PATCH.md +++ b/native/sideload-helper/vendor/isideload/NXNATIVE_PATCH.md @@ -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, 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. + +## 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. diff --git a/native/sideload-helper/vendor/isideload/src/dev/teams.rs b/native/sideload-helper/vendor/isideload/src/dev/teams.rs index 4c14774..cc1d5e0 100644 --- a/native/sideload-helper/vendor/isideload/src/dev/teams.rs +++ b/native/sideload-helper/vendor/isideload/src/dev/teams.rs @@ -12,6 +12,13 @@ pub struct DeveloperTeam { pub team_id: String, pub r#type: Option, pub status: Option, + #[serde(default)] + pub memberships: Option>, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct DeveloperMembership { + pub name: Option, } #[cfg_attr(feature = "wasm", async_trait::async_trait(?Send))] diff --git a/scripts/tests/test_sideload_protocol.py b/scripts/tests/test_sideload_protocol.py index 57f5a48..86ae0a1 100644 --- a/scripts/tests/test_sideload_protocol.py +++ b/scripts/tests/test_sideload_protocol.py @@ -23,4 +23,5 @@ class SideloadProtocol(unittest.TestCase): 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()] self.assertIn('scoped-games-v1',events[0]['capabilities']) + self.assertIn('registration-consent-v1',events[0]['capabilities']) self.assertEqual(events[1],{'event':'installed_apps','items':[]})