Pular para o conteúdo
upgbp

Push não chega no iOS: as regras da Apple para PWA instalada

No Safari móvel a API de notificação nem existe até o site ser adicionado à tela de início. As cinco condições, e como detectar cada uma.

8 min de leitura

O erro tratado aqui

ReferenceError: Can't find variable: Notification

Ambiente testado

  • iOS 18
  • Safari 18
  • Next.js 15.3
  • web-push 3.6
Neste artigo (11)

Contexto: o que estava rodando

Web Push implementado e funcionando: Chrome no desktop, Chrome no Android, Firefox. A funcionalidade estava pronta.

Aí veio o relato: “no iPhone o botão não faz nada”.

O erro

Conectando o iPhone ao Safari do Mac para inspecionar, a resposta apareceu na primeira linha:

Safari · Web Inspector · iPhoneexit 1
> Notification.permission
ReferenceError: Can't find variable: Notification

> 'PushManager' in window
false

> 'serviceWorker' in navigator
true

O Service Worker existe. Notification e PushManager não. E o código, que verificava só 'serviceWorker' in navigator antes de prosseguir, seguia adiante e quebrava no meio.

Diagnóstico

No Safari móvel, a API só existe dentro do aplicativo instalado

Esta é a regra que explica tudo. A Apple liberou Web Push no iOS a partir do 16.4, mas apenas para sites adicionados à tela de início. Numa aba comum do Safari, os objetos Notification e PushManager simplesmente não são definidos.

Não é permissão negada. Não é bloqueio silencioso. A API não está lá.

Isso muda o desenho da funcionalidade: no Android, você pede permissão. No iOS, você precisa primeiro convencer o usuário a instalar o aplicativo — e a Apple não oferece nenhuma API para pedir isso.

As cinco condições

Todas precisam ser verdadeiras. Uma falhando, o push não existe.

# Condição Como falha na prática
1 iOS 16.4 ou superior Aparelho antigo; nada a fazer
2 Site adicionado à tela de início O caso mais comum, e invisível para quem testa no Android
3 Manifest com display: standalone ou fullscreen browser faz o app abrir no Safari e a API não aparece
4 Permissão pedida dentro de um gesto do usuário Qualquer await antes do requestPermission invalida o gesto
5 Notificação exibida pelo Service Worker O construtor new Notification() não funciona

O gesto perdido

A condição 4 é a mais traiçoeira, porque o código funciona no Chrome e falha no Safari:

// Funciona no Chrome. No Safari o await consome o gesto, e requestPermission
// devolve 'denied' sem mostrar nada.
onClick={async () => {
  const registro = await navigator.serviceWorker.register('/sw.js');
  await navigator.serviceWorker.ready;
  const permissao = await Notification.requestPermission();  // tarde demais
}}

O Safari é rígido: a chamada precisa ser a primeira coisa do manipulador de clique. Qualquer operação assíncrona antes dela quebra a associação com o gesto.

Sem beforeinstallprompt

No Android, o navegador oferece o evento que permite mostrar um botão “instalar”. No iOS ele não existe. A instalação é manual — Compartilhar, Adicionar à Tela de Início — e a única coisa que você pode fazer é ensinar o caminho.

A solução

  1. Detectar o ambiente antes de oferecer qualquer coisa.

    src/lib/ambiente-push.ts
    export type Situacao =
      | 'suportado'          // pode pedir permissão agora
      | 'precisa-instalar'   // iOS em aba: instruir a instalação
      | 'indisponivel';      // navegador sem suporte
    
    export function situacaoPush(): Situacao {
      if (typeof window === 'undefined') return 'indisponivel';
    
      const temApi = 'serviceWorker' in navigator && 'PushManager' in window;
      if (temApi) return 'suportado';
    
      // A API some no Safari móvel fora do app instalado. Se for iOS e ainda
      // não estiver instalado, o caminho não é pedir permissão — é instalar.
      const ehIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
      const instalado =
        window.matchMedia('(display-mode: standalone)').matches ||
        // Safari usa uma propriedade própria, anterior ao padrão.
        (navigator as unknown as { standalone?: boolean }).standalone === true;
    
      if (ehIos && !instalado) return 'precisa-instalar';
      return 'indisponivel';
    }
  2. Ensinar a instalação em vez de mostrar um botão morto.

    src/components/InstruirInstalacao.tsx
    export function InstruirInstalacao() {
      return (
        <div className="rounded-lg border border-neutral-200 p-4 text-sm">
          <p className="font-medium">Para receber avisos no iPhone</p>
          <ol className="mt-2 list-decimal space-y-1 pl-5 text-neutral-600">
            <li>Toque no botão Compartilhar, na barra do Safari.</li>
            <li>Escolha <strong>Adicionar à Tela de Início</strong>.</li>
            <li>Abra o aplicativo pelo ícone e volte aqui.</li>
          </ol>
          <p className="mt-2 text-neutral-500">
            A Apple só permite notificações em sites instalados dessa forma.
          </p>
        </div>
      );
    }

    Explicar o motivo importa: sem a última frase, o pedido parece capricho.

  3. Manifest com o modo de exibição certo.

    public/manifest.json
    {
      "name": "Entregas",
      "short_name": "Entregas",
      "start_url": "/",
      "display": "standalone",
      "scope": "/",
      "background_color": "#ffffff",
      "theme_color": "#0d0f13",
      "icons": [
        { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
        { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
        { "src": "/icons/512-mask.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
      ]
    }
    src/app/layout.tsx
    export const metadata = {
      manifest: '/manifest.json',
      // O iOS usa este ícone na tela de início, e não o do manifest.
      appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Entregas' },
    };

    display: "browser" faz o ícone abrir uma aba comum do Safari. Visualmente quase igual, e a API de push continua ausente.

  4. Pedir permissão como primeira instrução do clique.

    src/components/AtivarAvisosIos.tsx
    'use client';
    
    export function AtivarAvisos() {
      return (
        <button
          type="button"
          onClick={(evento) => {
            // Primeira linha, sem await antes. No Safari o gesto do usuário
            // não sobrevive a uma operação assíncrona.
            const promessa = Notification.requestPermission();
    
            evento.currentTarget.disabled = true;
            promessa.then(async (permissao) => {
              if (permissao !== 'granted') return;
              const registro = await navigator.serviceWorker.register('/sw.js');
              await navigator.serviceWorker.ready;
              await assinar(registro);
            });
          }}
        >
          Ativar avisos de entrega
        </button>
      );
    }

    O registro do worker acontece depois, dentro do then. A ordem parece estranha e é exatamente o que o Safari exige.

  5. Exibir sempre pelo Service Worker.

    public/sw.js
    self.addEventListener('push', (event) => {
      const dados = event.data?.json() ?? {};
    
      event.waitUntil(
        (async () => {
          await self.registration.showNotification(dados.titulo ?? 'Entregas', {
            body: dados.corpo,
            icon: '/icons/192.png',
            data: { url: dados.url ?? '/' },
          });
    
          // Contador no ícone do app. Funciona em PWA instalada e é ignorado
          // silenciosamente onde não houver suporte.
          if ('setAppBadge' in self.navigator && typeof dados.badge === 'number') {
            await self.navigator.setAppBadge(dados.badge);
          }
        })(),
      );
    });

    new Notification(...) na página não funciona no iOS. Só registration.showNotification.

Como confirmar que resolveu

Inspecione o app instalado, não a aba. No Safari do Mac, Desenvolvedor → [nome do iPhone] lista os contextos. O aplicativo instalado aparece separado das abas do Safari — inspecionar a aba errada mostra PushManager ausente e leva a conclusão errada.

Com o iPhone conectado e o app aberto pelo ícone:

Safari · Web Inspector · app instalado
> 'PushManager' in window
true

> Notification.permission
"granted"

> (await navigator.serviceWorker.ready).pushManager.getSubscription()
PushSubscription {endpoint: "https://web.push.apple.com/QP8..."}

O endpoint em web.push.apple.com confirma que a assinatura foi criada pela infraestrutura da Apple.

Envie com o app fechado. Fechar de verdade — deslizar para cima na multitarefa — e disparar do servidor. É o único teste que vale.

Teste a reinstalação. Remova o app da tela de início e adicione de novo: a assinatura anterior morre. O servidor deve receber 410 no próximo envio e apagar a linha, e o usuário precisa ativar novamente.

Confirme o display. Abra pelo ícone: se a barra de endereço do Safari aparecer, o display não está como standalone e nada mais funciona.

E quando o problema é no Android

O sintoma “não chega” no Android tem causas diferentes:

Economia de bateria. Fabricantes agressivos com gerenciamento de energia suspendem o Service Worker. O push chega quando o aparelho acorda, às vezes com minutos de atraso. Pouco a fazer do lado do código.

TTL curto demais. Com o aparelho offline, o servidor de push guarda a mensagem pelo TTL enviado. TTL: 0 significa “entregue agora ou descarte”.

Carga acima do limite. O payload cifrado tem teto na casa de poucos kilobytes. Mande identificadores e busque o conteúdo no push, em vez de mandar o objeto inteiro.

tag reaproveitada. Notificações com a mesma tag se substituem. Se o usuário deveria ver três avisos e vê um, é isso.

Armadilhas que sobram depois disso

Não existe API para saber se o usuário instalou. display-mode: standalone só responde quando a página já está rodando dentro do app. Numa aba, não há como saber se ele instalou em outro momento.

Modo de navegação privada não tem Service Worker. O botão some sem explicação.

iPad se comporta como desktop no user agent. A detecção por /iPad/ falha em iPadOS recente, que se identifica como Mac. Prefira testar a ausência da API a testar o nome do aparelho.

Assinatura da Apple também expira. Trate pushsubscriptionchange no worker e reassine, ou o usuário para de receber sem ter mudado nada.

A permissão continua sendo tentativa única. Todo o cuidado sobre o momento de pedir vale aqui também — com o agravante de que, no iOS, o usuário já gastou esforço instalando o app antes de chegar nesse ponto.

Continue por aqui