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 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: unawaitche 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
| Desiderio | Realtà |
|---|---|
| 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.
| # | Requisito | Se manca |
|---|---|---|
| 1 | HTTPS con certificato valido (o localhost) |
Il service worker non si registra affatto. |
| 2 | Manifest con display: standalone o fullscreen |
iOS non la tratta come web app: niente installazione, niente push. |
| 3 | Service worker servito dalla radice | Da /js/sw.js il suo raggio d'azione sarebbe solo /js/. |
| 4 | Permesso chiesto da un tocco | La richiesta viene ignorata senza dire nulla. |
| 5 | Su iOS: app aggiunta alla Home | In Safari il pulsante non farà mai nulla. |
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.
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ì
| File | Ruolo | Perché così |
|---|---|---|
server.js | statici + API | Ascolta solo su 127.0.0.1: da fuori si arriva unicamente passando dal reverse proxy. |
sw.js | service worker | Nella radice, non in public/: lo scope non può risalire sopra la cartella da cui è stato scaricato. |
lib/iscrizioni.js | archivio dispositivi | File JSON con scrittura atomica. Un database qui aggiungerebbe un servizio da tenere vivo senza risolvere nulla. |
lib/registro.js | verbale eventi | JSONL: si aggiunge in coda senza rileggere, e una scrittura interrotta perde al massimo una riga. |
lib/utenti.js · lib/sessione.js | utenti e accesso | Password scrypt, cookie firmato senza archivio delle sessioni, revoca per versione. |
lib/notizie.js · lib/pianificatore.js | feed e riepiloghi | Ogni feed isolato: uno che fallisce non ferma gli altri, e l'esito resta registrato. |
componente/pwa-invito.js | invito 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.
«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
| Momento | Cosa succede |
|---|---|
| Creazione | Solo l'amministratore, da /admin o dal terminale. Il server genera una password iniziale che si vede una volta. |
| Primo accesso | Con la password iniziale si può fare una cosa sola: cambiarla. Finché non succede, il pianificatore ignora l'account. |
| Sessione | Cookie 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 push | Il dispositivo è dell'utente della sessione, mai di quello dichiarato dalla pagina. |
| Uscita | Il server toglie tutti i dispositivi dell'utente: chi ha il telefono dopo di lui non riceve le sue notizie. |
| Ruolo admin | Si 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.
| # | Passo | Chi lo sa | Se si ferma qui |
|---|---|---|---|
| 1 | La pagina registra il service worker | pagina | HTTPS mancante, o file servito dal posto sbagliato. |
| 2 | L'utente tocca e concede il permesso | pagina | Se risponde no è definitivo: il browser non richiederà più. |
| 3 | pushManager.subscribe() con la chiave VAPID | pagina | Chiave non convertita da base64url a Uint8Array. |
| 4 | Il server salva l'iscrizione | server | Nulla da segnalare: è la parte facile. |
| 5 | Il server firma e consegna a FCM/APNs | server | 403 = chiavi diverse. 410 = iscrizione morta. |
| 6 | Il service worker mostra la notifica | dispositivo | Telefono offline, o notifiche silenziate per quell'app. |
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" }
}
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 || '/' },
})
);
});
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 };
}
}
| Codice | Significa | Cosa fare |
|---|---|---|
201 | consegnata al servizio push | Non significa «arrivata». |
403 | chiavi VAPID non corrispondenti | L'iscrizione è stata fatta con un'altra coppia: va rifatta dal dispositivo. |
404 · 410 | iscrizione morta | Cancellarla dall'archivio. |
413 | payload troppo grande | Restare sotto ~4 kB. |
429 | troppe richieste | Rallentare 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:
| Decisione | Perché |
|---|---|
| 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
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.
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
| Situazione | Cosa 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. |
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.
8I link nell'app installata
«Se la PWA è installata, il link deve aprirla»: sì su Android, mai su iOS.
| Piattaforma | Link da altre app | Link nel browser |
|---|---|---|
| Android + Chrome (WebAPK) | sì, affidabile | sì |
| Android + Edge / Firefox | no | no |
| ChromeOS | sì | sì |
| macOS — app nel Dock da Safari | sì | sì |
| macOS / Windows — Chrome, Edge | no | solo dalle proprie schede |
| iOS / iPadOS | mai | mai |
Cosa mettere nel manifest, dove funziona:
handle_links: "preferred"— l'utente può sempre annullarlo dalle impostazioni dell'app: è una preferenza, non una garanzia, e un'interfaccia che la dà per certa mente;launch_handler: { client_mode: "navigate-existing" }— porta all'indirizzo la finestra già aperta invece di aprirne una seconda;related_applications: [{ platform: "webapp", url: "<url assoluto del manifest>" }]— abilitagetInstalledRelatedApps()a riconoscere sé stessa.
related_applications.url dev'essere assoluto: scritto a
mano nel file, copiando il progetto su un altro dominio la rilevazione smette
di funzionare in silenzio. Conviene generarlo lato server dall'origine
della richiesta.
La rilevazione ha tre stati, non due: true,
false e null («non lo so», che è la risposta su iOS).
Trattare null come false significa affermare che
l'app non è installata quando non se ne sa nulla.
Infine: non esiste alcuna API per lanciare la PWA installata da una pagina
di browser. La cattura avviene prima, a livello di sistema: se il link è
già aperto nel browser, è tardi. Il massimo onesto è riconoscerlo e dire di
aprirla dall'icona. E su Android il WebAPK si aggiorna con ritardo — fino a circa
un giorno — quindi handle_links non ha effetto immediato sulle
installazioni esistenti.
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:
| Evento | Chi lo annota | Che cosa dimostra |
|---|---|---|
app-aperta | pagina | Se aperta dall'icona o dal browser: su iOS è la distinzione da cui dipende tutto. |
sw-registrato | pagina | Esiste qualcosa che può ricevere. |
permesso-concesso / negato | pagina | Se negato, il browser non richiederà più. |
invio-esito | server | Consegnata al servizio push. Non è «arrivata». |
notifica-mostrata | service worker | È comparsa sullo schermo, e quanti secondi dopo l'invio. |
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:
| Rotta | Effetto | Protezione |
|---|---|---|
/api/prova | solo 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/invia | su tutti i dispositivi | Token sempre. Se il token non è configurato risponde chiusa, non aperta. |
/api/annota | scrive 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/registro | legge 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.
| Sintomo | Causa |
|---|---|
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.
- 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.
- 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.
- Manifest, icone, service worker — nell'ordine. Il service
worker dalla radice, con
Service-Worker-Allowed: /. - Chiavi VAPID e archivio iscrizioni. Le chiavi di produzione nascono sul server e non transitano dal repository.
- Il registro degli eventi, adesso e non dopo. Costruirlo alla fine significa aver lavorato al buio fino a quel momento.
- Percorso utente: rilevamento piattaforma, invito all'installazione, permesso da un tocco, iscrizione.
- Test automatici — sapendo cosa non dimostrano. Vale la pena eseguire il service worker con payload diversi, non leggerne il sorgente.
- Deploy idempotente: processo sul solo loopback, reverse proxy davanti, segreti generati sul server e mai sovrascritti da un aggiornamento.
- La prova sul telefono vero. È l'unica che conta, ed è l'unica che un agente non può fare da solo.
- Scrivi gli esiti, compresi i fallimenti. Un esperimento che documenta solo i successi non è un esperimento.
«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-mostrataper ciascun telefono /api/inviasenza token risponde401prefers-reduced-motion: le animazioni informative si fermano ma non spariscono
13Gli esiti misurati
Non stime: numeri letti dal verbale, prodotti da un iPhone reale.
| Data | Sistema | Browser | App chiusa | Ritardi misurati |
|---|---|---|---|---|
| 29/08/2026 | iOS 18.7 | Safari 26.6 | 5 su 5 | 10,8 s · 11,1 s · 11,8 s dopo un invio differito di 10 s |
| 29/08/2026 | iOS 18.7 | Safari 26.6 | in 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
- In Safari con l'app non installata, il registro segnava
«service worker in errore». Era un falso allarme: su iOS
registration.pushManagernon esiste finché l'app non è installata. Uno strumento che segnala guasti inesistenti è peggio di uno strumento assente. black-translucentfaceva 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.
- Web Push for Web Apps on iOS and iPadOS — WebKit. Supporto da iOS 16.4, solo per web app aggiunte alla Home, permesso solo in risposta a un'interazione diretta.
- Push API — MDN.
- web-push — la libreria di invio, versione 3.6.7.
- Navigation management into installed PWAs
— Chrome.
launch_handlerda Chrome 110, cattura dei link da Chrome 139 su desktop. - pwa-url-handler explainer
— WICG.
handle_links. - Get Installed Related Apps API — Chrome.
- khmyznikov/pwa-install e philfung/add-to-homescreen — MIT, le due librerie da cui sono state prese le decisioni d'interfaccia.