Você terminou o app. Agora quer colocá-lo num celular, e as duas portas da frente têm fila.
O Google Play pede a contas pessoais de desenvolvedor um teste fechado: pelo menos 12 testadores, participando sem interrupção por 14 dias, antes mesmo de você poder solicitar acesso à produção. A análise dessa solicitação, depois, “normalmente leva sete dias ou menos”.
A Apple faz uma pergunta diferente, e a diretriz 4.2 a coloca sem rodeios: seu app “deve incluir recursos, conteúdo e interface que o elevem além de um site reempacotado”.
Existe uma terceira porta, e ela não tem fila nenhuma. Um web app pode ficar na tela inicial com ícone próprio, abrir em tela cheia sem barra de endereço e continuar funcionando sem conexão. Este guia constrói um, e depois prova as duas metades dessa afirmação contra um navegador de verdade.
Prefere assistir?
As lojas de aplicativos são o número 13 da nossa contagem regressiva do que realmente dá errado para quem constrói com IA.
O que as lojas estão pedindo, de fato?
Duas coisas diferentes, e é por isso que o mesmo app pode travar nas duas por motivos sem relação entre si.
A do Google é aritmética. Desenvolvedores com contas pessoais criadas depois de 13 de novembro de 2023 precisam rodar um teste fechado com “no mínimo 12 testadores que tenham participado continuamente por pelo menos 14 dias”. Quem sai do teste zera o relógio daquela pessoa, então os doze têm que ficar. Contas de organização pulam o teste por completo.
A da Apple é editorial. A diretriz 4.2.2 acrescenta que apps “não devem ser principalmente material de marketing, anúncios, recortes da web, agregadores de conteúdo ou uma coleção de links”. Um site embrulhado numa casca nativa é exatamente o caso que a frase descreve, então o revisor que a lê já sabe o que procurar.
O caminho da web não pede nada disso. Também não pede dinheiro, conta nem processo de recurso, porque não há a quem recorrer.
O que torna uma página web instalável?
Um arquivo de manifest. É isso mesmo, o mecanismo inteiro: um pequeno documento JSON que diz ao navegador como o app se chama, qual ícone usar e que ele deve abrir na própria janela.
{
"name": "Shift Notes",
"short_name": "Shift Notes",
"start_url": "./index.html",
"scope": "./",
"display": "standalone",
"background_color": "#F4F4EF",
"theme_color": "#0A1220",
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Referencie-o na página e essa parte está pronta:
<link rel="manifest" href="manifest.webmanifest" />
display: standalone é a linha que remove a barra de endereço. Sem ela você tem um favorito; com ela você tem algo que uma pessoa vai descrever como um app, porque a coisa na tela não tem navegador nenhum dentro.
O que o navegador exige de verdade?
Menos do que os checklists dizem. Perguntamos ao próprio Chrome, uma mudança por vez, usando o veredito de instalabilidade dele em vez do resumo de um blog.
| O que mudamos | Veredito do Chrome |
|---|---|
ícones em 192 e 512 | instalável |
só um ícone 192 | instalável |
só um ícone 512 | instalável |
| nenhum ícone | manifest-missing-suitable-icon, no-acceptable-icon |
display: browser | manifest-display-not-supported |
sem short_name | instalável |
Então um ícone adequado basta, e o tão repetido “você precisa de 192 e 512” é um conselho sobre como o ícone aparece em telas diferentes, não uma condição para instalar.
Mais uma coisa que vale saber antes de começar a corrigir. Com três falhas presentes ao mesmo tempo — sem short_name, sem ícone grande e display: browser — o Chrome apontou exatamente uma: manifest-display-not-supported. Ele reporta o bloqueio em que esbarrou, não uma auditoria. Corrija e o próximo aparece.
Como medimos: cada linha é uma edição no manifest, depois um perfil novo do Chrome controlado pelo protocolo do DevTools, lendo Page.getInstallabilityErrors na página carregada. O script está no repositório.
Como fazer o app funcionar sem conexão?
Com um service worker, que é um script que o navegador mantém mesmo com a sua página fechada. Na instalação ele copia o shell do app para um cache; depois disso, responde às requisições primeiro a partir desse cache.
const VERSION = 'shift-notes-v1';
const SHELL = ['./', './index.html', './manifest.webmanifest', './icons/icon-192.png', './icons/icon-512.png'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
e.respondWith(
caches.match(e.request, { ignoreSearch: true })
.then((hit) => hit || fetch(e.request).catch(() => caches.match('./index.html')))
);
});
Registre-o a partir da página, depois que ela carregar:
if ('serviceWorker' in navigator) {
addEventListener('load', () => navigator.serviceWorker.register('sw.js'));
}
O catch da última linha importa mais do que parece. Quando a rede some e a requisição era uma navegação, ele devolve a página em cache em vez de deixar o navegador mostrar o próprio erro — que é exatamente a diferença que a próxima seção mede.
O app sobrevive mesmo à perda da rede?
Sim, e conferimos do jeito difícil. Cortar a rede do navegador não basta por si só, porque um servidor que continua rodando ainda pode estar respondendo de algum lugar. Então o teste corta a rede e para o servidor, e depois confirma de fora do navegador que a origem está realmente morta antes de recarregar.
✓ Chrome reports no installability errors none
✓ a service worker is active state=activated
✓ the page is controlled by it controller=true
✓ the shell is in the cache 5 entries
✓ the origin is genuinely gone ECONNREFUSED
✓ it still renders with the server stopped h1="Shift Notes"
✓ it is still interactive offline form and list present
first load 4430 bytes over the wire, 18 ms to load event
offline reload 0 bytes over the wire
Catorze verificações, todas passando, estáveis em três execuções consecutivas. O número que sustenta a afirmação é o último: 0 bytes pela rede num reload completo, enquanto um fetch simples à mesma origem, feito de fora do navegador, volta com ECONNREFUSED.
Como medimos: Node 22 controla um Chrome de verdade pelo protocolo do DevTools, com um perfil novo a cada execução. Network.emulateNetworkConditions coloca a página offline, o servidor estático é encerrado em vez de pausado, e o reload é um reload normal, não um carregamento só do cache.
Instalável e funciona-offline são duas propriedades diferentes, e só uma delas é o que uma pessoa quer dizer com “é um app”.
O que acontece se você pular o service worker?
O Chrome instala o app mesmo assim — e essa é a armadilha. Apagamos uma linha, a do registro, e rodamos as mesmas verificações.
O veredito de instalabilidade do Chrome não mudou: nenhum erro. Ele ainda ofereceria a instalação, ainda daria um ícone, ainda abriria em tela cheia sem barra de endereço.
Aí o reload offline caiu na página de erro do próprio Chrome. Nosso script lê o primeiro título da página, e ele voltou como h1="Press space to play" — o jogo do dinossauro. O reload também puxou 184.663 bytes por uma conexão que deveria estar morta, porque essa página de erro é, ela mesma, uma página.

Então o service worker não é o passo opcional de acabamento. É o arquivo que torna a instalação honesta.
O que isso custa num iPhone?
Três coisas concretas, e você deve conhecê-las antes de prometer a alguém uma instalação sem atrito.
Não existe prompt de instalação. O Safari não mostra banner nem dispara evento que você possa capturar; o leitor precisa abrir o menu Compartilhar e escolher Adicionar à Tela de Início. Isso significa que o seu app tem que pedir isso na própria interface, num momento em que o leitor já o quer.
Notificações push funcionam, mas só depois. Elas chegaram no iOS 16.4 e só para um web app que já está na Tela de Início — um site numa aba do Safari não recebe push, qualquer que seja a permissão que ele tenha. A sincronização em segundo plano (background sync) continua indisponível, então tudo o que precisa acontecer com o app fechado precisa acontecer no seu servidor.
Nada disso impede o app de funcionar. Muda o que você pode prometer: um ícone, uma janela em tela cheia e uso offline são seus; comportamento silencioso em segundo plano, não.
Quando usar uma loja mesmo assim?
Quando uma de quatro coisas for verdade, e vale ser honesto sobre elas antes de se comprometer.
Você precisa do sistema de pagamento da loja, porque vender bens digitais dentro de um app iOS não é algo que dá para contornar. Você precisa de acesso profundo ao hardware — periféricos Bluetooth, localização em segundo plano, dados de saúde. Você conta com a busca da loja para encontrar usuários. Ou o processo de compras do seu cliente simplesmente exige uma listagem.
Se nenhuma delas for verdade, a loja é sobretudo um portão pelo qual você está pedindo para passar. E se uma delas se tornar verdade mais tarde, o backend que você constrói agora não muda — as mesmas contas e a mesma API atendem um cliente nativo quando você o escrever.
Onde entra o backend?
No momento em que duas pessoas precisam dos mesmos dados. O app do repositório guarda as notas em localStorage, o que é honesto para uma demo e errado para um produto: é por dispositivo, por navegador, invisível para você e some quando alguém limpa o cache.
É o mesmo muro em que o caminho da loja bate, só que sem a fila de revisão na frente. Contas, um banco de dados, armazenamento de arquivos e uma API são o que transforma a coisa na tela inicial em algo que dois celulares podem compartilhar.
Um backend gerenciado cobre isso sem mudar os três arquivos que você acabou de escrever: cadastro e login, um banco de dados com permissões por registro, e endpoints REST e GraphQL que a sua página pode chamar com fetch.
Adicionar cadastro e login é o próximo passo, e é o mesmo trabalho quer isso acabe numa loja, quer nunca acabe.
Como colocar o app no ar?
Sirva a pasta por HTTPS. É o único requisito rígido, e não é negociável: um service worker não registra por HTTP puro em lugar nenhum além de localhost, então sem um certificado a metade offline simplesmente não existe.
Qualquer hospedagem estática com HTTPS serve, e um container também — hospedar um site estático num container mede esse caminho de ponta a ponta, incluindo o arquivo que o serve.
Depois verifique o que você publicou, não o que você escreveu. Instale pelo navegador, desligue a rede e recarregue. Se você vê o seu app, terminou. Se vê um dinossauro, seu service worker não está registrado.
O que fazer primeiro?
Abra a pasta do seu app e adicione o manifest. É a menor mudança com o maior efeito visível — um arquivo e um <link>, e o navegador passa a oferecer a instalação de algo que antes era um favorito.
Depois adicione o service worker, porque a instalação sem ele é uma promessa que você não cumpriu. Depois rode as verificações contra um navegador de verdade em vez de um checklist, já que o checklist estava errado sobre os ícones e calado sobre a coisa que de fato quebra.
As lojas continuarão lá se você precisar delas. A diferença é que este caminho coloca seu app numa tela inicial hoje, e ninguém precisa dizer sim.