Review device slots and require explicit consent for new Apple registrations
This commit is contained in:
1 parent
5f116da478
commit
d8511da8a9
14 files changed
+298
-31
No files matched your search
+1
-1
@@ -44,7 +44,7 @@ Partimos de [seedds/nxnative](https://github.com/seedds/nxnative): el motor, los
|
||||
|
||||
- **Preparación guiada:** destino → archivos → llaves → revisión → compilación con avance y registro. Arrastrar archivos no mueve tus originales.
|
||||
- **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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.13.1",
|
||||
"build": 21,
|
||||
"version": "1.14.0",
|
||||
"build": 22,
|
||||
"minimumMacOSMajor": 26
|
||||
}
|
||||
@@ -24,7 +24,10 @@ appear everywhere without being forgotten. No recurring schedule is implied.
|
||||
redacted diagnostics and background completion notices with OS permission.
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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-<UDID>` names when matched;
|
||||
unrecognized status letters are not presented as meaningful readiness claims.
|
||||
|
||||
The install review fetches the selected team's registrations and accepts client
|
||||
snapshots for at most five minutes. It distinguishes unknown, already registered
|
||||
and new devices using exact case-insensitive UDID equality. An already registered
|
||||
device uses no additional device slot for another game/update; application-ID and
|
||||
app/profile limits remain separate. A new device shows the estimated before/after
|
||||
count when available and requires an explicit acceptance checkbox. Changing team
|
||||
or device clears that acceptance. Refreshing the query is read-only.
|
||||
|
||||
The helper advertises `registration-consent-v1`. New clients reject older helpers
|
||||
for native installs rather than falling back around the guard. Immediately before
|
||||
registration, the helper queries Apple again. It registers only a new device with
|
||||
explicit manual approval; automatic refresh may reuse an existing registration
|
||||
but cannot create one, even if an approval flag is supplied. A failed read stops
|
||||
the operation. No certificate is automatically revoked, registration removed or
|
||||
existing game uninstalled. The protocol returns only a derived free-team limit
|
||||
and known registration IDs/names, never raw membership/provider/session payloads.
|
||||
|
||||
After successful registration, a narrow team/device event updates the local
|
||||
inventory even if signing later fails: registering happens before installation
|
||||
and can consume the slot independently of install success. The UI explains this
|
||||
before confirmation. Apple remains authoritative when registration is attempted.
|
||||
Agent validation never logs into a real account or registers a device.
|
||||
@@ -41,6 +41,26 @@ struct Session {
|
||||
loader: Loader,
|
||||
}
|
||||
|
||||
// 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) {
|
||||
let mut output = io::stdout().lock();
|
||||
let _ = writeln!(output, "{value}");
|
||||
@@ -244,7 +264,7 @@ async fn login(request: &mut Value, restore: bool) -> Result<Session, Report> {
|
||||
.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<Session> = 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::<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(())
|
||||
}.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::<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() {
|
||||
let usb = UsbmuxdDevice {
|
||||
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,
|
||||
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.
|
||||
@@ -12,6 +12,13 @@ pub struct DeveloperTeam {
|
||||
pub team_id: String,
|
||||
pub r#type: 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))]
|
||||
|
||||
@@ -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':[]})
|
||||
Reference in new issue
Block a user