Saltar al contenido
Development

Lanzar Deep Cut Atlas: qué se rompió durante mi primer envío a la App Store

Por Victor Da Luz
iosswiftapp-storestorekitdev-logdeep-cut-atlas

Esta semana finalmente envié Deep Cut Atlas a revisión de la App Store. La app en sí llevaba lista un buen tiempo, la última función real se había integrado semanas atrás, pero “enviarla” resultó esconder una cantidad sorprendente de trabajo. No trabajo difícil, exactamente. Solo una larga cadena de pequeños obstáculos filosos de la plataforma que solo aparecen una vez, al final del todo, en producción.

Esto es lo que realmente se rompió, en el orden en que me lo encontré.

La compra dentro de la app que configuré mal

Deep Cut Atlas tiene una sola función paga: un desbloqueo único “Pro” que sincroniza la lista de álbumes descartados entre dispositivos. Todo lo demás es gratis. Configurar esto en App Store Connect implicaba elegir un “Type” para la compra dentro de la app, y elegí el equivocado. Consumible en vez de No consumible. Están justo uno al lado del otro en el menú desplegable y no lo pensé dos veces.

Este no es un error cosmético. Toda la API de StoreKit 2 para responder “¿este usuario tiene esto?” es Transaction.currentEntitlements, y nunca devuelve compras consumibles. Por diseño. Los consumibles están pensados para gastarse y volver a comprarse, como vidas extra en un juego, así que Apple no rastrea su historial de propiedad de la misma forma que lo hace con un desbloqueo permanente.

Lo aterrador es lo invisible que habría sido esto. El código de compra cambia el estado local de inmediato a partir de la transacción que devuelve StoreKit, así que el botón habría dicho correctamente “Pro” justo después de comprarlo. Después el usuario cierra la app. En el siguiente inicio, el código vuelve a calcular el estado Pro desde currentEntitlements, no encuentra nada, y la compra simplemente desaparece. Para siempre. “Restaurar compras” tampoco puede recuperarla, porque no hay nada en el flujo de derechos (entitlements) para restaurar. Un cliente que pagó se habría topado con esto sin ninguna forma de saber por qué.

Nada en mi suite de pruebas detectó esto, porque nada en una suite de pruebas puede detectarlo. Es una propiedad de la infraestructura en vivo de Apple, no de mi código.

Después descubrí que no se puede corregir un ID de producto

En cuanto noté el error del Consumible, mi plan era: borrarlo, recrearlo bien, listo en treinta segundos. App Store Connect tenía otros planes.

The Product ID you entered is already being used by another in-app purchase associated with this team.

Los IDs de producto quedan reservados de forma permanente en el momento en que se escriben, incluso si la IAP se borra cinco segundos después, incluso si nunca se envió ni nadie la compró. No hay reutilización, no hay período de espera, no hay apelación. Tuve que elegir un ID completamente nuevo, lo que implicó un cambio de código real (el ID es una constante de Swift codificada directamente, verificada contra una configuración local de pruebas de StoreKit mediante una prueba unitaria) y un build nuevo, no solo una edición en App Store Connect.

Lección para la próxima: el ID de producto es una de quizás tres decisiones en todo este proceso que son verdaderamente permanentes. Vale la pena ir más despacio justo en esas.

El botón de compra que “no funcionaba,” salvo que sí funcionaba

Después de corregir el ID de producto, quise probar el flujo de compra de verdad antes de lanzar, así que configuré TestFlight e instalé el build en mi propio teléfono. Al tocar “Unlock Pro” falló de inmediato: “Pro isn’t available right now.”

Resultó que esto es esperado, no un error. La primerísima compra dentro de la app de una app completamente nueva no está disponible para pruebas en sandbox hasta que se haya enviado a revisión al menos una vez, adjunta a una versión. Product.products(for:) simplemente no devuelve nada hasta que el backend de Apple “activa” el producto, y esa activación está ligada a un envío real, no al simple hecho de que la IAP exista. Una vez que una app se lanzó aunque sea una sola vez, toda IAP nueva funciona de inmediato. Solo la primerísima tiene esta restricción.

Así que el orden de las operaciones está invertido respecto a lo que esperaba: primero enviar, después probar, no al revés. Apple sí permite sacar un envío de la cola de revisión antes de que se apruebe si se encuentra algo roto, así que no es una puerta sin retorno, solo una confusa.

Una cosa que funcionó como esperaba: los builds de TestFlight siempre corren las compras a través del sandbox de Apple de forma automática, sin importar qué Apple ID los instale. No hace falta una cuenta de prueba de sandbox separada, no hay cargo real, solo una pequeña etiqueta “[Environment: Sandbox]” en la hoja de compra. Esa parte fue agradablemente simple.

El archivo que en silencio me mintió

Este dolió un poco. Después de corregir el ID de producto, le dije al agente que había subido un build nuevo. Tenía un número de build nuevo y todo. Salvo que en realidad no había reconstruido nada.

El Organizer de Xcode guarda en una lista cada archivo que se haya creado, y “Distribute App” se puede ejecutar sobre cualquiera de ellos, incluyendo uno viejo de horas antes. Había hecho clic en una entrada de archivo vieja en vez de correr Product y después Archive de nuevo, así que el build “nuevo” era en realidad el binario viejo reempaquetado bajo un número nuevo. Ninguna de mis correcciones de código reales estaba ahí.

La única forma en que esto se detectó fue descomprimiendo el .ipa exportado y revisándolo directamente: la clave UIDeviceFamily del Info.plist seguía diciendo que la app soportaba iPad, algo que no debía pasar después de una corrección que acababa de hacer. Ese único valor no coincidente fue la señal. Una vez que hice un archivo genuinamente nuevo, todo cuadró: el número de build correcto, la familia de dispositivo correcta, y hacer grep sobre las cadenas del binario compilado confirmó que el ID de producto nuevo sí estaba ahí y el viejo había desaparecido por completo.

Es poco probable que esto se hubiera detectado sin revisar el artefacto realmente enviado en vez de confiar en la confirmación de “build subido.” Vale la pena recordarlo para cualquier pipeline de build futuro: verificar lo que salió, no lo que se pretendía enviar.

Un cambio de nombre con una mecha de dos semanas

Semanas antes, en una sesión sin relación, había cambiado el nombre del producto compilado de la app, del título de trabajo anterior a “Deep Cut Atlas,” para que el binario enviado coincidiera con el nombre real de App Store. Un cambio de configuración simple, el build funcionó, seguí adelante.

Resultó que ese único cambio de nombre había roto en silencio dos cosas que no fallan hasta que se corre la suite de pruebas completa, no un simple build. La configuración TEST_HOST del target de pruebas seguía apuntando al path del bundle viejo por nombre, así que las pruebas ya no podían encontrar su app anfitriona. Y el nombre del módulo de Swift compilado sigue en silencio el nombre del producto por defecto, así que cada @testable import del nombre viejo en mis archivos de prueba dejó de resolverse.

La solución fue una sola línea, no un buscar y reemplazar masivo: fijar explícitamente PRODUCT_MODULE_NAME de vuelta al nombre interno anterior, desacoplando la identidad a nivel de Swift del nombre de producto que ve el usuario. La misma idea que mantener estable el nombre de un target o scheme de Xcode durante un cambio de marca, solo una capa más abajo de lo que se me había ocurrido revisar.

Los últimos cortecitos

Un puñado de cosas más pequeñas, cada una un freno total hasta resolverla. El campo Support URL de App Store Connect rechaza de plano los enlaces mailto:, quiere http:// o https://, punto, sin excepciones para un desarrollador solo sin una página de soporte dedicada. Las dimensiones de las capturas son una lista exacta de valores permitidos, no “más o menos” funciona, la resolución nativa del simulador del iPhone más nuevo no está en la lista aceptada de Apple para el espacio de iPhone grande, y reducir al tamaño aceptado más cercano con sips era seguro porque las proporciones difieren menos de medio por ciento, pero eso lo descubrí por un error de validación, no por haberlo anticipado. Y el proyecto había estado configurado como “universal” (iPhone y iPad) desde el día en que lo creé en Xcode, solo porque esa es la opción por defecto de la plantilla; nunca había adaptado ni probado el diseño para iPad, y el requisito de Apple de capturas para pantallas de iPad de 13 pulgadas fue lo que finalmente forzó la decisión: solo iPhone para esta versión.

Lo que le diría a mi yo del pasado

Nada de esto fue difícil, individualmente. Cada corrección fue pequeña. Lo que hizo que fuera lento fue que casi cada uno de estos obstáculos es invisible hasta el momento exacto en que golpea, y varios solo aparecen en la brecha entre “parece terminado” y “verificado contra el artefacto realmente enviado.” El build que en silencio no se reconstruyó es el que va a quedarme grabado. Todo se veía correcto desde afuera. El correo de confirmación incluso llegó. Lo único que lo delató fue negarme a confiar en la confirmación y revisar el binario mismo.

Deep Cut Atlas está en revisión ahora. Primer envío a la App Store, y honestamente, la mayor parte de construir la app en sí fue la parte fácil.

Lecturas relacionadas