I have implemented the Push and Notification API following this tutorial. It works perfectly fine in Chrome on Mac OS.
But now I am trying to get it to work on Safari on iOS (16.4.3).
I have added my application to the home screen to make it a PWA.
I have a button #enable-notifications
to execute the following code:
JS code of my app
document.getElementById("enable-notifications").addEventListener("click", () => {
main();
});
const check = () => {
if (!('serviceWorker' in navigator)) {
throw new Error('No Service Worker support!')
}
if (!('PushManager' in window)) {
throw new Error('No Push API Support!')
}
}
const registerServiceWorker = async () => {
const swRegistration = await navigator.serviceWorker.register('/assets/js/order-dashboard/serviceworker.js');
return swRegistration;
}
const requestNotificationPermission = async () => {
Promise.resolve(Notification.requestPermission()).then(function(permission) {
if (permission !== 'granted') {
throw new Error('Permission not granted for Notification')
}
});
}
const main = async () => {
check();
const swRegistration = await registerServiceWorker();
const permission = await requestNotificationPermission();
}
const showLocalNotification = (title, body, swRegistration) => {
const options = {
body,
};
swRegistration.showNotification(title, options);
}
This is serviceworker.js:
// urlB64ToUint8Array is a magic function that will encode the base64 public key
// to Array buffer which is needed by the subscription option
const urlB64ToUint8Array = base64String => {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
// saveSubscription saves the subscription to the backend
const saveSubscription = async subscription => {
const SERVER_URL = 'https://good-months-invite-109-132-150-239.loca.lt/save-subscription'
const response = await fetch(SERVER_URL, {
method: 'post',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
})
console.log(response);
return response.json()
}
self.addEventListener('activate', async () => {
// This will be called only once when the service worker is activated.
try {
const applicationServerKey = urlB64ToUint8Array(
'BDLVKNq32B-Dr3HRd4wQ2oNZL9mw5JAGhB1XGCdKlDE9_KDEw7uTOLuPKH-374RRolaa0rr7UyfrJd7tvRvp304'
)
const options = { applicationServerKey, userVisibleOnly: true }
const subscription = await self.registration.pushManager.subscribe(options)
const response = await saveSubscription(subscription)
console.log(response)
} catch (err) {
console.log('Error', err)
}
})
self.addEventListener("push", function(event) {
if (event.data) {
console.log("Push event!!! ", event.data.text());
showLocalNotification("Yolo", event.data.text(), self.registration);
} else {
console.log("Push event but no data");
}
});
const showLocalNotification = (title, body, swRegistration) => {
const options = {
body
// here you can add more properties like icon, image, vibrate, etc.
};
swRegistration.showNotification(title, options);
};
And this is my node.js back-end:
const express = require('express')
const cors = require('cors')
const bodyParser = require('body-parser')
const webpush = require('web-push')
const app = express()
app.use(cors())
app.use(bodyParser.json())
const port = 4000
app.get('/', (req, res) => res.send('Hello World!'))
const dummyDb = { subscription: null } //dummy in memory store
const saveToDatabase = async subscription => {
// Since this is a demo app, I am going to save this in a dummy in memory store. Do not do this in your apps.
// Here you should be writing your db logic to save it.
dummyDb.subscription = subscription
}
// The new /save-subscription endpoint
app.post('/save-subscription', async (req, res) => {
const subscription = req.body
await saveToDatabase(subscription) //Method to save the subscription to Database
console.log("saved");
res.json({ message: 'success' })
})
const vapidKeys = {
publicKey:
'BDLVKNq32B-Dr3HRd4wQ2oNZL9mw5JAGhB1XGCdKlDE9_KDEw7uTOLuPKH-374RRolaa0rr7UyfrJd7tvRvp304',
privateKey: 'BGbNIt2twl1XsbDHPNe_w6FrKsWcZrys6anByEKyCGo',
}
//setting our previously generated VAPID keys
webpush.setVapidDetails(
'mailto:[email protected]',
vapidKeys.publicKey,
vapidKeys.privateKey
)
//function to send the notification to the subscribed device
const sendNotification = (subscription, dataToSend) => {
webpush.sendNotification(subscription, dataToSend)
}
//route to test send notification
app.get('/send-notification', (req, res) => {
const subscription = dummyDb.subscription //get subscription from your databse here.
const message = 'Hello World'
sendNotification(subscription, message)
res.json({ message: 'message sent' })
})
app.listen(port, () => console.log(`Example app listening on port ${port}!`))
When I click #enable-notifications
on iOS I get the pop-up to allow notifications. But then nothing happens. My back-end also does not get called.
What seems to be the issue here?
Edit:
I have tested in on Safari Mac OS (works), Chrome MacOS (works), Chrome Windows (other device, works),… Only Safari iOS doesn’t work.
Edit:
navigator.serviceWorker.controller
returns null.
Edit:
Updated my JS-code.
document.getElementById("enable-notifications").addEventListener("click", (event) => {
event.preventDefault()
askNotificationPermission().then(alert).then(registerServiceWorker);
})
function askNotificationPermission() {
return new Promise((resolve, reject) => {
if (checkNotificationPromise()) {
Notification.requestPermission().then(resolve);
} else {
Notification.requestPermission(resolve)
}
})
}
function checkNotificationPromise() {
try {
Notification.requestPermission().then();
} catch(e) {
return false;
}
return true;
}
const registerServiceWorker = async () => {
// alert("registering service worker");
const swRegistration = await navigator.serviceWorker.register('/assets/js/order-dashboard/serviceworker.js');
// alert(swRegistration.active);
console.log(swRegistration);
return swRegistration;
}
Now the message to accept notifications gets prompted twice + the serviceworker isn’t even contacting my server on the other browsers anymore…
4
Answers
I was able to solve it.
This was the key to the puzzle.
Full code.
panel.js
serviceworker.js
Back-end to test is exactly the same.
It might be related with the problem listed in here https://developer.apple.com/forums/thread/725619?answerId=749431022#749431022
Basically it seems that
Notification.requestPermission()
always resolves withdenied
no matter what user chose. You can check if that is the case by usingalert(permission)
for example.Your code can be simplified too, of course to this:
No need to use that extra
Promise.resolve
call there.UPDATE
Just noticed that your code uses
Notification.requestPermission
promisified version, but some browsers and Safari among them uses legacy callback style version.Here’s across browser compatible code:
Now check what is alert showing to you.
UPDATE #2
Not sure if
PushManager.subscribe
function even works inside ServiceWorker like you are trying to do there since it works only on user gesture (on a button click, for example). Not sure ifServiceWorker.activate
event can be classified as such.So basically flow should be something like this:
ServiceWorker
or get its registration viagetRegistration
function – if it succeeds then continue.requestPermission
to be sure that user will be shown a notification permission dialog on its first use and next time to just check if permission was "granted" (however, as stated already then on iOS it seems to be always "denied" so just ignore it basically)PushManager
– if it succeeds you get all the subscription details which you can send to backend and then register to "push" events in your serviceworker and show notifications from there.Your flow is mix and match currently. Reorganize your flow and add alert statements to see if order is correct and if every promise resolves as you expect etc.
UPDATE #3
Not sure why notification popup is shown twice, but one of the problems you have there is that you’re passing
undefined
to the secondPromise.then
function sincealert
always returns with a value ofundefined
. Here’s the relevant code:This
alert
was only suggested to see what’s the value of permission status. Anyway, more correct code would be something in the lines of:Can’t be bothered to check documentation right now, but
navigator.serviceWorker.register
might be also a problem when calling multiple times – usenavigator.serviceWorker.getRegistration
to check if registration itself already exists instead or re-registering. Also, setscope
for registration just in case.Anyway, it’s hard to determine why code isn’t working for you exactly without having access to all of the code, but add
alert
statements everywhere where necessary to understand why and where it breaks. Good luck!You’re getting a request denied upon hitting your
#enable-notifications
before the user can make its choice in the iOS popup. This results in push notifications being denied onsaveSubscription
(worker), resulting in no$_POST
to server.The user then makes its choice and you get push notifications allowed (permission returns
granted
) but there is no attempt at subscribing after.To remedy for iOS simply swap the order of these 2 lines:
To:
It will have no negative effects for Chrome a.o.
I was experiencing the same issue. I was able to solve it by adding a manifest.json file with the "display": "standalone" directive, which I had omitted completely and link to the file in the html file.
After deploying the fix, making sure to clear the website data (cache) for the site in Safari settings and adding the site to the Home Screen, it worked. No need to enable Web Push and Notifications in Safari Experimental Features.
manifest.json
index.html