Un botón de suscripción, una API de MusicKit que nunca había usado, y una sheet que se cierra en silencio
Deep Cut Atlas recibió un rechazo de App Review por errores crudos de MusicKit apareciendo en pantalla cuando alguien está autorizado pero no tiene en realidad una suscripción a Apple Music. Ya arreglé la parte cercana al crash de eso: una vista de bloqueo real en vez de un stack trace. Pero el bloqueo en sí era tosco. Tanto el estado “hace falta una suscripción” como el estado “ya hay una suscripción, solo falta aceptar los términos una vez” mostraban exactamente el mismo título, “Apple Music Required”, lo cual es sencillamente incorrecto para el segundo caso. Y la única acción del estado sin suscripción era un enlace de texto plano que decía “Try Again.” ¿Intentar qué de nuevo? No había ninguna forma de arreglar el problema de verdad desde dentro de la app.
Este fue el seguimiento: retitular el estado confuso, y darle al estado sin suscripción un botón de suscripción real.
Leer el framework en vez de adivinar
MusicKit trae un modificador de SwiftUI llamado musicSubscriptionOffer(isPresented:options:onLoadCompletion:) que presenta la propia sheet nativa de Apple para “suscribirse a Apple Music”, sin necesitar interfaz personalizada. Nunca lo había usado, así que en vez de adivinar la firma a partir de documentación medio recordada, la leí directo del framework:
find /Applications/Xcode.app/.../iPhoneSimulator.sdk -iname "*MusicKit*"
lo cual sacó a la luz todo un segundo framework que no sabía que existía: _MusicKit_SwiftUI.framework. Su archivo .swiftinterface tenía la firma real, más un struct Options con campos affiliateToken/campaignToken que decidí no tocar ya que nada en esta base de código configuró jamás ninguno. Grepear la propia interfaz compilada de un framework le gana a confiar en mi memoria de una API que había visto mencionada dos veces.
El error que se habría publicado si no hubiera mirado dos veces
Acá está la parte que realmente importó. musicSubscriptionOffer presenta una sheet dentro de la app. No manda la app a segundo plano ni dispara ninguna transición de scene-phase. Mientras tanto, la verificación de estado de suscripción de esta app solo se vuelve a correr al iniciar la app o al volver desde segundo plano. Juntando esos dos hechos: alguien toca “Get Apple Music,” se suscribe en la sheet nativa, la cierra… y ve exactamente la misma pantalla “Apple Music Required”, porque nada le dijo a la app que verificara de nuevo. El arreglo funciona perfecto para alguien que manda la app a segundo plano y la vuelve a abrir, y es un callejón sin salida para quien no lo hace.
Eso es una ruta de conversión rota, no un vacío cosmético. Lo detecté antes de escribir una sola línea del arreglo, recorriendo mentalmente qué pasa cuando la sheet se cierra: nada con forma de scenePhase iba a dispararse jamás, así que algo más tenía que hacerlo.
.onChange(of: showingSubscriptionOffer) { wasPresented, isPresented in
if wasPresented && !isPresented {
Task { await onRetry() }
}
}
Vuelve a verificar en el instante en que la sheet se cierra, esté suscrito o no. Barato, y significa que la promesa del botón (“consigue Apple Music, vuelve suscrito”) es de verdad cierta.
Una verificación en vivo que casi no pude correr
musicSubscriptionOffer habla con MusicKit real, lo cual significa que está muerto en el simulador, igual que todo lo demás de MusicKit en esta app. Así que la única forma de confirmar de verdad que el botón hacía lo que decía era una pasada en un dispositivo real, tocando el botón de verdad y observando la sheet real de Apple. En el iPhone 13 de repuesto para pruebas, la sheet apareció y dijo “You are already an Apple Music subscriber”, que es exactamente lo que debería ver una cuenta con suscripción real, y confirmó que la llamada estaba llegando al servicio de suscripción en vivo de Apple en vez de a algún mock local. Cerrarla disparó el refresh limpiamente, sin crash, sin bloqueo.
No puedo probar de punta a punta lo que ve una cuenta genuinamente sin suscripción sin tener una, lo cual es un vacío conocido que viene arrastrado desde el arreglo original del rechazo. Pero “¿el botón presenta lo real y se comporta bien al cerrarse?” ahora es un sí confirmado, no una suposición.
Un hallazgo menor: fijar el idioma del simulador sin tocar Settings
Verificar los nuevos strings en español significaba cambiar el simulador a es-419, y ya había documentado antes que las anulaciones de idioma por app no funcionan en esta combinación de Xcode/iOS, solo funcionaba el flujo completo de la interfaz de la app Settings. Resulta que hay una ruta más rápida por CLI que no había probado: escribir el dominio de preferencias global (no uno por app) y reiniciar el simulador:
xcrun simctl spawn <udid> defaults write -g AppleLanguages -array "es-419" "en"
xcrun simctl spawn <udid> defaults write -g AppleLocale -string "es_419"
xcrun simctl shutdown <udid> && xcrun simctl boot <udid>
Funcionó al primer intento. Cosa chica, pero convierte un paso de “navegar tres pantallas de Settings y esperar un respring” en dos comandos de shell.
Reflexión
La parte interesante de este issue no fue el botón, fue notar que una acción de interfaz correctamente codificada igual puede ser un callejón sin salida funcional si nada más adelante reacciona a ella. “La sheet se presenta” y “la app sabe que la sheet se cerró” son dos afirmaciones separadas, y verificar solo la primera habría publicado un botón de suscripción que en silencio no funciona para la mitad de sus usuarios.
Lecturas relacionadas
La ventana espejada mintió sobre que el teléfono estaba desbloqueado
Un arreglo de agrupación de una línea que ya estaba escrito, y tres muros entre eso y la prueba: aislamiento de actores, el alcance exclusivo a Mac de log stream, y una sesión espejada que parece desbloqueada cuando el dispositivo no lo está.
Álbumes de preestreno, y la solución que no pude verificar del todo
Los marcadores de posición 'Track N' de Apple se mostraban como si fueran datos reales. La única señal de detección que no pude confirmar se convirtió en la única señal de la que dejé de depender.
Confirmar que una escritura en la biblioteca de MusicKit realmente se aplicó, sin escanear toda la biblioteca
Agregar a la biblioteca reportaba éxito en el momento en que la llamada no arrojaba un error. Volver a consultar por id no puede funcionar, escanear toda la biblioteca cuesta 8 segundos, y la respuesta estaba en el archivo .swiftinterface.