Lançando o Deep Cut Atlas: o que quebrou na minha primeira submissão à App Store
Eu finalmente submeti o Deep Cut Atlas para revisão da App Store essa semana. O app em si já estava pronto há um tempo, o último recurso de verdade tinha entrado semanas atrás, mas “submeter” acabou escondendo uma quantidade surpreendente de trabalho. Não trabalho difícil, exatamente. Só uma longa cadeia de pequenas pegadinhas de plataforma, afiadas, que só aparecem uma vez, bem no final, em produção.
Aqui está o que realmente quebrou, na ordem em que eu esbarrei em cada coisa.
A compra dentro do app que eu criei errada
O Deep Cut Atlas tem um recurso pago: um desbloqueio único “Pro” que sincroniza sua lista de álbuns dispensados entre dispositivos. Todo o resto é grátis. Configurar isso no App Store Connect significava escolher um “Type” para a compra dentro do app, e eu escolhi o errado. Consumível em vez de Não Consumível. Eles ficam bem um do lado do outro no menu suspenso e eu não pensei duas vezes.
Isso não é um erro cosmético. Toda a API do StoreKit 2 para “esse usuário é dono disso” é Transaction.currentEntitlements, e ela nunca retorna compras consumíveis. De propósito. Consumíveis são feitos para ser usados e comprados de novo, tipo vidas extras num jogo, então a Apple não rastreia o histórico de posse deles da mesma forma que faz para um desbloqueio permanente.
A parte assustadora é o quão invisível isso teria sido. Meu código de compra vira o estado local imediatamente a partir da transação que o StoreKit devolve, então o botão diria corretamente “Pro” logo depois da compra. Aí o usuário fecha o app. Na próxima abertura, o código re-deriva o status Pro a partir de currentEntitlements, não encontra nada, e a compra simplesmente sumiu. Para sempre. “Restore Purchases” também não consegue trazer de volta, porque não há nada no fluxo de entitlements para restaurar. Um cliente pagante teria batido nisso e não teria como saber por quê.
Nada na minha suíte de testes pegou isso, porque nada numa suíte de testes consegue. É uma propriedade da infraestrutura ao vivo da Apple, não do meu código.
Aí eu descobri que você não consegue consertar um product ID
Assim que percebi o erro do Consumível, meu plano era: apagar, recriar corretamente, resolvido em trinta segundos. O App Store Connect tinha outros planos.
The Product ID you entered is already being used by another in-app purchase associated with this team.
Product IDs ficam permanentemente reservados no momento em que você os digita, mesmo que a compra dentro do app seja apagada cinco segundos depois, mesmo que nunca tenha sido submetida ou comprada por ninguém. Não há reuso, não há período de espera, não há recurso. Eu tive que escolher um ID totalmente novo, o que significou uma mudança de código de verdade (o ID é uma constante Swift fixa no código, conferida contra uma configuração local de teste do StoreKit por um teste unitário) e um build novo, não só uma edição no App Store Connect.
Lição para a próxima vez: o product ID é uma das talvez três decisões nesse processo inteiro que são realmente permanentes. Vale a pena desacelerar especificamente nessas.
O botão de compra que “não funcionou”, só que funcionou
Depois de consertar o product ID, eu quis testar o fluxo de compra de verdade antes de lançar, então configurei o TestFlight e instalei o build no meu próprio telefone. Tocar em “Unlock Pro” falhou na hora: “Pro isn’t available right now.”
Acontece que isso é esperado, não um bug. A primeira compra dentro do app de um app novo em folha não fica disponível para teste no sandbox até ter sido submetida para revisão pelo menos uma vez, associada a uma versão. Product.products(for:) simplesmente não retorna nada até o backend da Apple “ativar” o produto, e essa ativação está ligada a uma submissão de verdade, não à IAP simplesmente existir. Assim que um app já lançou uma vez, toda IAP nova futura funciona imediatamente. Só a primeira tem essa restrição.
Então a ordem das operações é o inverso do que eu esperava: submeter primeiro, testar depois, não o contrário. A Apple deixa você tirar uma submissão de volta da fila de revisão antes de ser aprovada se você encontrar algo quebrado, então isso não é uma porta sem volta, só uma confusa.
Uma coisa que funcionou do jeito que eu esperava: builds do TestFlight sempre rodam compras pelo sandbox da Apple automaticamente, não importa qual Apple ID as instale. Nenhuma conta de testador de sandbox separada é necessária, nenhuma cobrança real, só um rótulo pequeno “[Environment: Sandbox]” na tela de compra. Essa parte foi agradavelmente simples.
O archive que mentiu para mim em silêncio
Essa doeu um pouco. Depois de consertar o product ID, eu disse ao agente que tinha subido um build novo. Tinha um número de build novo e tudo. Só que não tinha rebuildado nada de fato.
O Organizer do Xcode guarda todo archive que você já fez numa lista, e “Distribute App” pode ser rodado em qualquer um deles, incluindo um antigo de horas atrás. Eu tinha clicado numa entrada de archive antiga em vez de rodar Product e depois Archive do zero, então o build “novo” era na verdade o binário antigo reempacotado sob um número novo. Nenhum dos meus ajustes de código reais estava nele.
A única forma de isso ter sido pego foi descompactando o .ipa exportado e conferindo diretamente: a chave UIDeviceFamily do Info.plist ainda dizia que o app suportava iPad, o que não deveria acontecer depois de um ajuste que eu tinha acabado de fazer. Aquele único valor incoerente foi o sinal. Depois de fazer um archive genuinamente novo, tudo bateu: o número de build certo, a família de dispositivo certa, e uma busca pelas strings do binário compilado confirmou que o product ID novo estava lá de verdade e o antigo tinha sumido por completo.
Eu não acho que teria pego isso sem conferir o artefato realmente entregue em vez de confiar na confirmação de “build enviado”. Vale lembrar para qualquer pipeline de build futuro: verifique a coisa que saiu da porta, não a coisa que você pretendia enviar.
Um rename com um pavio de duas semanas
Semanas antes, numa sessão sem relação com isso, eu tinha renomeado o produto compilado do app do título de trabalho antigo para “Deep Cut Atlas”, para que o binário entregue batesse com o nome real na App Store. Mudança de configuração simples, o build teve sucesso, segui em frente.
Acontece que aquele único rename tinha quebrado silenciosamente duas coisas que não falham até você rodar a suíte de testes completa, não só um build simples. A configuração TEST_HOST do alvo de teste ainda apontava para o caminho do bundle antigo pelo nome, então os testes não conseguiam mais encontrar seu app anfitrião. E o nome do módulo Swift compilado segue silenciosamente o nome do produto por padrão, então todo @testable import do nome antigo nos meus arquivos de teste parou de resolver.
O ajuste foi uma linha, não uma busca-e-substitui em massa: fixar explicitamente PRODUCT_MODULE_NAME de volta para o nome interno antigo, desacoplando a identidade em nível de Swift do nome do produto voltado para o usuário. Mesma ideia de manter o nome de um target/scheme do Xcode estável durante um rebranding, só uma camada mais abaixo do que eu tinha pensado em checar.
Os últimos pequenos incômodos
Um punhado de coisas menores, cada uma delas uma parada obrigatória até ser resolvida. O campo Support URL do App Store Connect rejeita de forma direta links mailto:, ele quer http:// ou https://, ponto final, sem exceção para um desenvolvedor solo sem uma página de suporte dedicada. As dimensões de screenshot são uma lista de permissões exata, não “próximo o suficiente”, a resolução nativa do simulador do iPhone mais novo não está na lista aceita pela Apple para o slot de iPhone grande, e redimensionar para o tamanho aceito mais próximo com sips era seguro já que as proporções ficam dentro de meio por cento, mas eu só descobri isso por um erro de validação, não por ter lido com antecedência. E o projeto tinha sido lançado como “universal” (iPhone e iPad) desde o dia em que eu o criei no Xcode, puramente porque esse é o padrão do template; eu nunca tinha de fato adaptado ou testado o layout para iPad, e a exigência de screenshot da Apple para telas de iPad de 13 polegadas foi o que finalmente forçou a decisão: só iPhone para esta versão.
O que eu diria para mim mesmo do passado
Nada disso foi difícil, individualmente. Cada ajuste foi pequeno. O que tornou tudo lento foi que quase todas essas pegadinhas são invisíveis até o exato momento em que mordem, e várias só aparecem na lacuna entre “parece pronto” e “verificado contra o artefato realmente entregue”. O build que silenciosamente não foi rebuildado é o que vai ficar comigo. Tudo parecia correto de fora. Até o e-mail de confirmação chegou certinho. A única coisa que entregou foi recusar confiar na confirmação e conferir o binário em si.
O Deep Cut Atlas está em revisão agora. Primeira submissão para a App Store, e sinceramente, a maior parte de construir o app em si foi a parte fácil.
Leitura relacionada
Desbloqueio vitalício com StoreKit 2 num app SwiftUI em Swift 6: três coisas que me pegaram
A API de compra é a parte fácil. As junções entre StoreKit 2, isolamento do Swift 6 e observação do SwiftUI foram onde o tempo foi.
Uma seção de tracklist, e por que levou 30 minutos
Um método de protocolo, um enum de estado reaproveitado, uma convenção de roteamento que se manteve, e um orçamento de lint que forçou uma divisão que valia a pena fazer de qualquer forma.
A playlist que já tinha o nome certo
Um artefato de renomeação, uma API sem campo de autor pra atualizar, e um teste em dispositivo real provando que o bug já tinha se corrigido sozinho, fechado como aceitar como está.
Você também pode achar útil
Proton Mail
E-mail criptografado de ponta a ponta, com arquitetura de acesso zero.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisRackNerd VPS
Hospedagem VPS econômica para serviços leves que funcionam continuamente.
Como afiliado da RackNerd, ganho com compras qualificadas.
Saiba maisProton Pass
Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba mais