Salta al contenuto

Banco di prova · pwa.giuseppebosi.com

PWA con notifiche push:
architettura e istruzioni

Questo documento è il verbale di un esperimento riuscito, scritto per essere riusato. La prima metà spiega com'è fatta questa applicazione. La seconda è quello che direi a un'intelligenza artificiale incaricata di costruirne un'altra: i vincoli che non si negoziano, il codice che conta, le trappole che fanno perdere giorni e l'ordine in cui conviene lavorare.

1Il vincolo che decide tutto

Va letto prima di qualunque riga di codice, perché riscrive il progetto.

Su iOS il Web Push funziona solo dentro una web app aggiunta alla schermata Home, da iOS 16.4 in poi. Mai in Safari. Su Android funziona anche nel browser.

Il modo in cui fallisce

Il Web Push non dà errori. Nessuna eccezione in console, nessun codice di stato sbagliato, nessun avviso: semplicemente la notifica non arriva. Un test verde, un curl che risponde 201 e un service worker registrato non dimostrano nulla sul fatto che il telefono squilli. Questo è il motivo per cui questo progetto ha un registro degli eventi — vedi la sezione 9.

Da questo discendono tre conseguenze che non si aggirano:

  • serve HTTPS con certificato valido — un autofirmato non basta, su iOS impedisce anche l'installazione;
  • su iOS il pulsante «attiva notifiche» va spento finché l'app non è installata, altrimenti l'utente tocca e non succede niente — e impara che l'app è rotta;
  • Notification.requestPermission() deve partire da un gesto dell'utente: un await che lo preceda rompe il legame col tocco e su iOS la richiesta viene ignorata in silenzio.

Cosa non si può fare, e non si potrà a breve

DesiderioRealtà
Installare la PWA con una chiamata di codice su iPhone Impossibile. navigator.install() è Chromium e su iOS ogni browser è WebKit. Chi promette «installazione automatica su iPhone» sta descrivendo Android.
Sapere da Safari se la PWA è già installata Impossibile. getInstalledRelatedApps() è solo Chromium, e la web app installata ha un contenitore dati separato da Safari: non funziona neanche il trucco di lasciare un segno in localStorage.
Far aprire un link nella PWA installata su iPhone Impossibile. Ogni link va al browser predefinito. Le uniche due cose che aprono una web app su iOS sono la sua icona e il tocco su una notifica push.
Sapere che l'utente ha installato, su iOS Nessun evento. appinstalled non esiste. L'unico segnale è vedere la pagina ripartire in display-mode: standalone.
Installazione in un tocco su Android Sì. beforeinstallprompt funziona e va intercettato.

2I cinque requisiti

Se ne manca uno solo, non arriva niente — e senza errori.

#RequisitoSe manca
1HTTPS con certificato valido (o localhost) Il service worker non si registra affatto.
2Manifest con display: standalone o fullscreen iOS non la tratta come web app: niente installazione, niente push.
3Service worker servito dalla radice Da /js/sw.js il suo raggio d'azione sarebbe solo /js/.
4Permesso chiesto da un tocco La richiesta viene ignorata senza dire nulla.
5Su iOS: app aggiunta alla Home In Safari il pulsante non farà mai nulla.
La regola che costa l'iscrizione

Ogni evento push deve concludersi con una notifica visibile. Se il service worker non chiama showNotification(), Safari revoca l'iscrizione e Chrome mostra al posto tuo «questo sito è stato aggiornato in background». Non è una raccomandazione di stile: è il patto in cambio del quale il browser permette a un server remoto di svegliare il telefono.

Conseguenza pratica: nel gestore push nessun ramo può uscire senza mostrare qualcosa — nemmeno quello del payload illeggibile, nemmeno il push senza corpo, che è legittimo e capita.

3L'architettura

Nessun servizio terzo, nessun SDK, tre dipendenze in tutto.

Dispositivo pagina + service worker Caddy TLS automatico :443 Node + Express 127.0.0.1:3020 solo loopback HTTPS proxy web-push firma VAPID + cifra FCM · APNs Google · Apple POST La notifica anche ad app chiusa Registro degli eventi il dispositivo riferisce: «l'ho mostrata, dopo N secondi» /api/annota invii
La linea tratteggiata è il punto

Il server sa soltanto di aver consegnato a Google o ad Apple. Solo il dispositivo sa se la notifica è comparsa su uno schermo. Sono due fatti diversi, e confonderli significa dichiarare riuscita una prova in cui il telefono non ha mai squillato.

I pezzi, e perché sono lì

FileRuoloPerché così
server.jsstatici + API Ascolta solo su 127.0.0.1: da fuori si arriva unicamente passando dal reverse proxy.
sw.jsservice worker Nella radice, non in public/: lo scope non può risalire sopra la cartella da cui è stato scaricato.
lib/iscrizioni.jsarchivio dispositivi File JSON con scrittura atomica. Un database qui aggiungerebbe un servizio da tenere vivo senza risolvere nulla.
lib/registro.jsverbale eventi JSONL: si aggiunge in coda senza rileggere, e una scrittura interrotta perde al massimo una riga.
lib/utenti.js · lib/sessione.jsutenti e accesso Password scrypt, cookie firmato senza archivio delle sessioni, revoca per versione.
lib/notizie.js · lib/pianificatore.jsfeed e riepiloghi Ogni feed isolato: uno che fallisce non ferma gli altri, e l'esito resta registrato.
componente/pwa-invito.jsinvito all'installazione Fuori da public/ di proposito: è il pezzo pensato per essere copiato altrove, e la separazione impedisce che si intrecci col resto.

Le dipendenze

Tre: express, web-push e fast-xml-parser (per i feed RSS: un parser fatto di espressioni regolari sarebbe stato una scorciatoia fragile). Nessun Firebase, nessun SDK, nessun account terzo. Le icone sono generate da uno script che scrive i PNG a mano con zlib — sul server non c'erano né ImageMagick né PIL, e aggiungere una libreria di immagini per disegnare una campanella sarebbe stato sproporzionato.

4Il sito notizie multiutente

La fase 3: notizie vere, utenti veri, e ognuno sceglie cosa ricevere e quanto spesso.

7 feed RSS ANSA · Repubblica · Sole 24 Ore Archivio notizie deduplica per link · 7 giorni Pianificatore un giro al minuto Griglia di ogni utente categoria × frequenza Una notifica per giro le categorie in scadenza, insieme I dispositivi dell'utente tutti i suoi, e solo i suoi ogni 5 min le nuove quando solo se c'è del nuovo

«Nuova» vuol dire vista per la prima volta da questo server, non la data del feed: le date dei feed sono inaffidabili, il momento in cui l'abbiamo vista noi è un fatto.

La griglia

Ogni utente sceglie, per ogni categoria — politica, mercati finanziari, sport, guerre e macroeconomia — una frequenza fra: no, 5 minuti, ogni ora, ogni giorno, ogni settimana. Tre regole la rendono sopportabile:

  • La frequenza è un massimo. Se nell'intervallo non è uscito niente, non parte niente: una notifica vuota insegna a ignorarle tutte.
  • Una notifica per giro. Le categorie che scadono insieme partono in un solo messaggio («4 notizie per te», una riga per categoria). Con tutto a 5 minuti, altrimenti, sarebbero quattro notifiche ogni cinque minuti.
  • Cambiare frequenza riparte da adesso. Passare da «settimana» a «5 minuti» non scarica una settimana di arretrati.

Utenti e accesso

MomentoCosa succede
CreazioneSolo l'amministratore, da /admin o dal terminale. Il server genera una password iniziale che si vede una volta.
Primo accessoCon la password iniziale si può fare una cosa sola: cambiarla. Finché non succede, il pianificatore ignora l'account.
SessioneCookie firmato HMAC, HttpOnly; Secure; SameSite=Lax, 30 giorni. Porta una versione: cambiando password la versione sale e i cookie emessi prima smettono di valere ovunque.
Iscrizione pushIl dispositivo è dell'utente della sessione, mai di quello dichiarato dalla pagina.
UscitaIl server toglie tutti i dispositivi dell'utente: chi ha il telefono dopo di lui non riceve le sue notizie.
Ruolo adminSi assegna solo dal terminale: una sessione admin rubata potrebbe creare utenti, non altri amministratori.

5Il ciclo di una notifica

Dove si rompe, e chi se ne accorge.

#PassoChi lo saSe si ferma qui
1La pagina registra il service workerpagina HTTPS mancante, o file servito dal posto sbagliato.
2L'utente tocca e concede il permessopagina Se risponde no è definitivo: il browser non richiederà più.
3pushManager.subscribe() con la chiave VAPIDpagina Chiave non convertita da base64url a Uint8Array.
4Il server salva l'iscrizioneserver Nulla da segnalare: è la parte facile.
5Il server firma e consegna a FCM/APNsserver 403 = chiavi diverse. 410 = iscrizione morta.
6Il service worker mostra la notificadispositivo Telefono offline, o notifiche silenziate per quell'app.
Il salto di conoscenza fra 5 e 6

Fra il passo 5 e il passo 6 il server smette di sapere qualcosa. Se l'applicazione non fa dire al dispositivo «l'ho mostrata», quel confine resta buio per sempre e ogni diagnosi diventa un'ipotesi. È tutta qui la ragione d'essere della sezione 9.

6Il codice che conta

Il minimo indispensabile, con i commenti che spiegano perché non si può togliere niente.

Il manifest

manifest.webmanifest

{
  "id": "/",
  "name": "Nome completo",
  "short_name": "Nome",
  "start_url": "/?fonte=pwa",
  "scope": "/",
  // standalone o fullscreen: con "browser" iOS non la installa come web app
  "display": "standalone",
  "background_color": "#1e3a8a",
  "theme_color": "#1e3a8a",
  // 192 e 512 sono il minimo per l'installabilita'; maskable evita che
  // Android incornici l'icona in un quadrato bianco
  "icons": [
    { "src": "/icone/192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "/icone/512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "/icone/512m.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  // i link dentro lo scope vanno all'app installata (dove la piattaforma lo permette)
  "handle_links": "preferred",
  "launch_handler": { "client_mode": "navigate-existing" }
}
iOS ignora il manifest per l'icona

Serve anche <link rel="apple-touch-icon" href="/icone/180.png">, e quell'immagine dev'essere opaca: iOS applica la sua maschera e una trasparenza diventa nero. Serve inoltre <meta name="viewport" content="…, viewport-fit=cover"> con le env(safe-area-inset-*) nel CSS, altrimenti in modalità app il contenuto finisce sotto il notch.

Il gestore push — il pezzo più delicato

sw.js

self.addEventListener('push', (event) => {
  let dati = {};
  try {
    // event.data e' null se il push arriva senza payload: succede, ed e'
    // uno dei casi in cui la notifica va mostrata lo stesso.
    dati = event.data ? event.data.json() : {};
  } catch (e) {
    try { dati = { testo: event.data.text() }; } catch (e2) { dati = {}; }
  }

  // waitUntil e' OBBLIGATORIO: senza, il browser puo' terminare il service
  // worker prima che showNotification() abbia finito, e la notifica sparisce.
  event.waitUntil(
    self.registration.showNotification(dati.titolo || 'Notifica', {
      body: dati.testo || 'Notifica ricevuta.',
      icon: '/icone/192.png',
      badge: '/icone/192.png',
      tag: 'prova', renotify: true,
      data: { url: dati.url || '/' },
    })
  );
});
Come si prova davvero questo gestore

Non leggendone il sorgente in cerca di return anticipati: quello è una supposizione. Si esegue il service worker in un ambiente finto (node:vm con self, caches e fetch simulati) e gli si mandano cinque payload: JSON completo, JSON vuoto, testo non JSON, payload illeggibile, push senza corpo. Per ognuno si verifica che showNotification sia stata chiamata esattamente una volta. È un esperimento, non un'ispezione.

Iscrizione, lato pagina

app.js

// La chiave VAPID viaggia in base64url (- e _ al posto di + e /, senza =);
// subscribe() vuole un Uint8Array. Senza questa conversione fallisce
// senza un messaggio utile, ed e' l'errore piu' comune in assoluto.
function base64UrlToUint8Array(s) {
  const pad = '='.repeat((4 - (s.length % 4)) % 4);
  const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
  const grezzo = atob(b64);
  return Uint8Array.from(grezzo, (c) => c.charCodeAt(0));
}

btnAttiva.addEventListener('click', async () => {
  // Da un TOCCO, e senza await prima: un'attesa qui rompe il legame col
  // gesto e su iOS la richiesta viene ignorata in silenzio.
  const permesso = await Notification.requestPermission();
  if (permesso !== 'granted') return;

  // ready attende un worker ATTIVO: register() da solo puo' restituirne uno
  // ancora in installazione, e subscribe() su quello fallisce senza dire perche'.
  const reg = await navigator.serviceWorker.ready;

  // Su iPhone in Safari (app NON installata) pushManager non esiste affatto.
  // Non e' un guasto: e' il modello di Apple, ed e' il caso piu' frequente.
  if (!reg.pushManager) return;

  const { chiave } = await (await fetch('/api/chiave-pubblica')).json();
  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,  // obbligatorio su Chrome, e coerente con la regola
    applicationServerKey: base64UrlToUint8Array(chiave),
  });

  await fetch('/api/iscrivi', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ iscrizione: sub.toJSON() }),
  });
});

Invio, lato server

server.js

const webpush = require('web-push');
// Il soggetto dev'essere un mailto: o un https: veri: identifica chi gestisce
// il servizio verso Google e Apple.
webpush.setVapidDetails(SOGGETTO, CHIAVE_PUBBLICA, CHIAVE_PRIVATA);

async function spedisci(voce, carico) {
  try {
    // TTL 24h: una notifica piu' vecchia di un giorno non interessa piu' a
    // nessuno, e il servizio push puo' smettere di riprovare.
    await webpush.sendNotification(
      { endpoint: voce.endpoint, keys: voce.keys }, carico,
      { TTL: 86400, urgency: 'high' }
    );
    return { ok: true };
  } catch (e) {
    // 404/410 = quell'iscrizione non esiste piu' (app disinstallata, permesso
    // revocato). Vanno CANCELLATE: e' l'unico modo che il protocollo offre per
    // sapere che un dispositivo se n'e' andato. Chi non lo fa accumula invii
    // falliti per sempre.
    if (e.statusCode === 404 || e.statusCode === 410) iscrizioni.rimuovi(voce.endpoint);
    return { ok: false, stato: e.statusCode };
  }
}
CodiceSignificaCosa fare
201consegnata al servizio pushNon significa «arrivata».
403chiavi VAPID non corrispondentiL'iscrizione è stata fatta con un'altra coppia: va rifatta dal dispositivo.
404 · 410iscrizione mortaCancellarla dall'archivio.
413payload troppo grandeRestare sotto ~4 kB.
429troppe richiesteRallentare e riprovare.

Servire il service worker

server.js

app.get('/sw.js', (req, res) => {
  res.set({
    'Content-Type': 'application/javascript; charset=utf-8',
    // Senza questo il browser puo' servire per ore un worker vecchio, e una
    // correzione al gestore push sembrerebbe non avere alcun effetto.
    'Cache-Control': 'no-cache, max-age=0',
    'Service-Worker-Allowed': '/',
  });
  res.sendFile(path.join(__dirname, 'sw.js'));
});

7Guidare l'installazione

Su iPhone non si può automatizzare. Si può però rendere il gesto ovvio.

Esistono due librerie mature — @khmyznikov/pwa-install e philfung/add-to-homescreen, entrambe MIT. Qui è stato scritto un componente proprio, per una ragione precisa: entrambe si fermano all'installazione, mentre il percorso vero è installa → concedi il permesso, e su iPhone il secondo esiste solo dopo il primo. Una libreria che copre metà del problema va comunque estesa.

Ne sono state prese però le decisioni, che valgono più del codice:

DecisionePerché
Freccia animata verso il pulsante Condividi L'unica scelta d'interfaccia per cui esista un'affermazione sui tassi di installazione. Sposta il problema da «leggi e capisci» a «guarda dove indica». Su iPhone la barra è in basso, su iPad in alto a destra.
Browser in-app riconosciuti per primi Instagram, Facebook, WhatsApp, X, TikTok, LinkedIn sono la sorgente di traffico più comune da social ed esattamente quelli in cui installare è impossibile: non hanno la voce «Aggiungi a Home». È il caso più trascurato.
Due livelli di rifiuto «Non ora» (7 giorni) e «non mostrarmelo più», con un modo discreto per riaccenderlo: una scelta definitiva senza ripensamento non è rispetto, è un vicolo cieco.
Foglio dal basso, non banner a mezza pagina È la forma che gli utenti già conoscono, e si chiude trascinandola — il primo gesto che provano.

Quale invito mostrare

Aperta dall'app installata? display-mode: standalone Salta al permesso notifiche è già il posto giusto sì no Risulta installata, ma sei nel browser? getInstalledRelatedApps · solo Chromium «Ce l'hai già: aprila dall'icona» non riproporre l'installazione sì no Browser interno di un'app? Instagram, WhatsApp, Facebook… «Esci da Instagram» da lì l'aggiunta a Home non esiste sì no Altro browser su iPhone? Istruzioni per quel browser Safari come ripiego sì no Safari su iPhone o iPad? Chrome, Edge, Firefox (iOS 16.4+) Tre passi + freccia verso Condividi si sa dov'è il pulsante sì no Il browser offre l'installazione? Android, desktop «Installa ora»: un tocco beforeinstallprompt sì no Dove cercarla nel menu del browser e le notifiche funzionano anche senza installare

L'ordine delle domande conta: i browser in-app si riconoscono prima di chiedersi se si è su iPhone, perché sono il caso più frequente da social e quello in cui installare è impossibile.

Correzione del 30 settembre 2026

Una versione precedente di questo documento diceva che su iPhone solo Safari può aggiungere un'app alla schermata Home. È falso da iOS 16.4: le note di WebKit dicono che «third-party web browsers can offer “Add to Home Screen” in the Share menu», e Chrome, Edge, Firefox e DuckDuckGo lo fanno. Verificato su un iPhone reale.

Quel che cambia fra Safari e gli altri non è la possibilità ma la certezza di dove sia il pulsante: in Safari si sa, e si può puntarci la freccia; negli altri il menu cambia da browser a browser e da versione a versione, quindi istruzioni a parole e nessuna freccia — puntarla a caso è peggio che non averla. Restano fuori solo i browser in-app, che non hanno l'autorizzazione di sistema per installare.

I casi da gestire, tutti

SituazioneCosa mostrare
Android / desktop con beforeinstallprompt Un tocco. Intercettare l'evento e metterlo da parte.
iPhone / iPad in Safari I tre passaggi + la freccia.
iPhone in Chrome / Edge / Firefox / DuckDuckGo Le istruzioni per quel browser: da iOS 16.4 «Aggiungi a Home» c'è anche lì. Niente freccia — ogni browser mette Condividi in un posto suo — e «apri in Safari» come ripiego, con copia dell'indirizzo perché x-safari-https:// non dà riscontro se fallisce.
Dentro Instagram, Facebook, WhatsApp… «Esci da nome»: da lì non si installa, e fingere il contrario fa sembrare rotta l'app.
Già installata, vista nel browser Non riproporre l'installazione: dire di aprirla dall'icona.
iOS sotto la 16.4 Installa lo stesso, ma avvertire che le notifiche non arriveranno.
Il pulsante morto

Dopo un rifiuto, beforeinstallprompt non si ripresenta nella stessa sessione. Un pulsante «Installa» lasciato acceso dopo un rifiuto è un pulsante che non farà niente — e l'utente concluderà che l'app è rotta, non che ha detto di no lui.

9L'apparato di misura

La domanda da farsi prima di dichiarare pronto un banco di prova: se fallisce, da dove leggo il perché?

Se la risposta è «lo chiedo a chi ha in mano il telefono», gli strumenti mancano e il lavoro non è finito — perché quella persona risponderà «non è arrivata», che è l'informazione che si aveva già.

Qui l'esperimento scrive il proprio verbale. Le righe che contano di più le scrive il dispositivo, non il server:

EventoChi lo annotaChe cosa dimostra
app-apertapagina Se aperta dall'icona o dal browser: su iOS è la distinzione da cui dipende tutto.
sw-registratopaginaEsiste qualcosa che può ricevere.
permesso-concesso / negatopagina Se negato, il browser non richiederà più.
invio-esitoserver Consegnata al servizio push. Non è «arrivata».
notifica-mostrataservice worker È comparsa sullo schermo, e quanti secondi dopo l'invio.
Il corollario che salva l'esperimento

Lo strumento di misura non deve mai poter guastare ciò che misura. La chiamata che annota va dopo showNotification() e assorbe i propri errori: se fallisse per prima e interrompesse la catena, il registro impedirebbe la notifica — e su Safari costerebbe l'iscrizione.

L'invio differito

Un pulsante «invia adesso» dimostra che il giro funziona, ma con l'app in primo piano non prova che la notifica arrivi da fuori. Serve un invio differito di dieci secondi: il server risponde 202 subito — la risposta deve arrivare prima che l'utente chiuda l'app, o vedrebbe un errore di rete proprio mentre fa la cosa giusta — e spedisce dopo. Sullo schermo parte un conto alla rovescia, che serve a una cosa sola: dire all'utente che deve chiudere l'app adesso.

Se l'utente ha chiuso davvero, il messaggio finale non lo leggerà mai. Ed è esattamente il caso in cui la prova sta riuscendo.

10Sicurezza

Un endpoint che agisce verso l'esterno nasce chiuso, non viene chiuso dopo.

Le rotte si separano per ampiezza dell'effetto, non per comodità. È questa separazione a permettere di avere insieme un pulsante di prova funzionante sul telefono e nessun endpoint sfruttabile da estranei:

RottaEffettoProtezione
/api/provasolo su chi chiama Aperta. Chi chiama deve esibire il proprio endpoint push: una stringa lunga e non indovinabile che solo quel telefono possiede. Al massimo uno fa squillare sé stesso.
/api/inviasu tutti i dispositivi Token sempre. Se il token non è configurato risponde chiusa, non aperta.
/api/annotascrive nel verbale Aperta — deve poterci scrivere il service worker, che non può custodire un segreto — ma con un elenco chiuso di eventi ammessi e un limite di frequenza.
/api/registrolegge il verbale Token. Contiene modelli di dispositivo, versioni di sistema e orari delle prove.
  • La chiave privata VAPID firma le notifiche a nome del sito: permessi 0600, mai nei commit, mai nei log. Rigenerarla invalida tutte le iscrizioni, perché i dispositivi sono legati alla vecchia chiave pubblica.
  • Gli endpoint push nel verbale vanno troncati: sono la credenziale con cui si spedisce a quel dispositivo. Ne bastano le ultime cifre per riconoscere «lo stesso telefono di prima».
  • Niente basic auth davanti a una PWA: manda in errore il download del service worker e del manifest, e su iOS impedisce del tutto l'aggiunta alla schermata Home.
  • La CSP deve includere worker-src 'self', o il browser blocca il service worker e non arriva nessuna notifica.

11Le trappole

Sintomo a sinistra, causa quasi certa a destra.

SintomoCausa
subscribe() fallisce senza messaggio utile Chiave VAPID non convertita da base64url a Uint8Array.
Ogni invio risponde 403 Le iscrizioni sono state fatte con un'altra coppia di chiavi: vanno rifatte dal dispositivo.
Su iPhone il permesso non viene mai chiesto L'app è stata aperta da Safari e non dall'icona.
Notification.requestPermission() ignorata Non è partita da un tocco, o c'è un await prima che ha rotto il legame col gesto.
Le notifiche smettono di arrivare su iPhone dopo un po' Un evento push non ha mostrato nulla: Safari ha revocato l'iscrizione.
Le correzioni al service worker non hanno effetto È servito dalla cache: serve Cache-Control: no-cache su /sw.js.
Ora e batteria sopra i pulsanti, in modalità app apple-mobile-web-app-status-bar-style: black-translucent. Usare default.
«Service worker in errore» in Safari non installata Falso allarme: registration.pushManager non esiste finché l'app non è installata. Va gestito, non segnalato come guasto.
Permesso concesso ma nessuna notifica Notification.permission === 'granted' non implica un'iscrizione push attiva: sono due cose distinte.
Invii falliti che si accumulano all'infinito Non si cancellano le iscrizioni su 404/410.

12Ordine di lavoro

Scritto per un agente che debba costruire un'applicazione simile da zero.

  1. Verifica il dominio prima di tutto. Serve un record DNS e un certificato valido. Senza, non si può provare nulla di ciò che conta: iOS non installa e le notifiche non partono. È il passo 1, non l'ultimo.
  2. Documentati su fonti primarie. Il supporto al Web Push su iOS è cambiato più volte; quello che un modello «sa» a memoria è vecchio per costruzione. Salva la nota così non va rifatto.
  3. Manifest, icone, service worker — nell'ordine. Il service worker dalla radice, con Service-Worker-Allowed: /.
  4. Chiavi VAPID e archivio iscrizioni. Le chiavi di produzione nascono sul server e non transitano dal repository.
  5. Il registro degli eventi, adesso e non dopo. Costruirlo alla fine significa aver lavorato al buio fino a quel momento.
  6. Percorso utente: rilevamento piattaforma, invito all'installazione, permesso da un tocco, iscrizione.
  7. Test automatici — sapendo cosa non dimostrano. Vale la pena eseguire il service worker con payload diversi, non leggerne il sorgente.
  8. Deploy idempotente: processo sul solo loopback, reverse proxy davanti, segreti generati sul server e mai sovrascritti da un aggiornamento.
  9. La prova sul telefono vero. È l'unica che conta, ed è l'unica che un agente non può fare da solo.
  10. Scrivi gli esiti, compresi i fallimenti. Un esperimento che documenta solo i successi non è un esperimento.
La regola che riassume tutte le altre

«Funziona su iOS» si dice solo dopo averlo visto su un iPhone. Ogni riga di documentazione distingue ipotesi (quello che dice la documentazione ufficiale) da esito verificato (quello che si è visto accadere su un dispositivo reale, con modello e versione annotati). Nessuna riga passa dall'una all'altro senza una prova.

Prima di dire che è finito

  • Android Chrome: installazione in un tocco, notifica ricevuta ad app chiusa
  • Android: dopo un rifiuto il pulsante «Installa» non resta acceso e morto
  • iPhone Safari non installata: il pulsante notifiche è spento con la spiegazione
  • iPhone installata: notifica ricevuta a schermo bloccato
  • Aperto da Instagram: dice di uscire, non finge che si possa installare
  • Il verbale contiene almeno un notifica-mostrata per ciascun telefono
  • /api/invia senza token risponde 401
  • prefers-reduced-motion: le animazioni informative si fermano ma non spariscono

13Gli esiti misurati

Non stime: numeri letti dal verbale, prodotti da un iPhone reale.

Comparse sul dispositivo 5 / 5 nessun fallimento
Ad app chiusa 3 schermo bloccato
Latenza reale ~1 s dalla partenza allo schermo
Test automatici 69 nessuna dipendenza di test
DataSistemaBrowserApp chiusaRitardi misurati
29/08/2026iOS 18.7Safari 26.65 su 5 10,8 s · 11,1 s · 11,8 s dopo un invio differito di 10 s
29/08/2026iOS 18.7Safari 26.6in primo piano 0,8 s · 1,6 s

Le tre prove differite sono partite dieci secondi dopo il tocco, ad app chiusa, e sono comparse circa un secondo dopo: la latenza vera di APNs è quel secondo, non gli undici. Zero invii falliti, zero iscrizioni perse.

Due difetti trovati dal verbale, non dall'occhio

  1. In Safari con l'app non installata, il registro segnava «service worker in errore». Era un falso allarme: su iOS registration.pushManager non esiste finché l'app non è installata. Uno strumento che segnala guasti inesistenti è peggio di uno strumento assente.
  2. black-translucent faceva scorrere il contenuto sotto la barra di stato: ora e batteria finivano sopra i pulsanti. Nessun test automatico l'avrebbe preso — è un difetto che si vede solo guardando.

14Fonti

Primarie, con la data della verifica. Vanno riconfermate a ogni major di iOS.