skip to Main Content

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


  1. Chosen as BEST ANSWER

    I was able to solve it.

    This was the key to the puzzle.

    const main = async () => {
        check();
        Notification.requestPermission().then(async function() {
            await registerServiceWorker()
        });
    }
    

    Full code.

    panel.js

    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 main = async () => {
        check();
        Notification.requestPermission().then(async function() {
            await registerServiceWorker()
        });
    }
    
    const showLocalNotification = (title, body, swRegistration) => {
        const options = {
            body,
        };
        swRegistration.showNotification(title, options);
    }
    
    

    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 => {
        console.log("saving");
      const SERVER_URL = 'A-NGROK-URL/save-subscription'
      const response = await fetch(SERVER_URL, {
        method: 'post',
        headers: {
          'Content-Type': 'application/json',
          'Bypass-Tunnel-Reminder': 'shit',
        },
        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(
          'BPh1TJ1mbPDgdlJTmogpLcQH1FqAxZpjId7WfEQu6xrME2QGkBaLc0inC6mSFuF17L8_rGno_MDZFrZWmEwwE3k'
        )
        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);
    };
    
    

    Back-end to test is exactly the same.


  2. 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 with denied no matter what user chose. You can check if that is the case by using alert(permission) for example.

    Your code can be simplified too, of course to this:

    Notification.requestPermission().then(function(permission) {
      if (permission !== 'granted') {
        throw new Error('Permission not granted for Notification')
      }
    })
    

    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:

      document.getElementById("enable-notifications").addEventListener("click", (event) => {
        event.preventDefault()
        askNotificationPermission().then(alert)
      })
      
      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;
      }
    

    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 if ServiceWorker.activate event can be classified as such.

    So basically flow should be something like this:

    1. Register ServiceWorker or get its registration via getRegistration function – if it succeeds then continue.
    2. On some button/link click – ask for notification permissions with 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)
    3. Try to subscribe to 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 second Promise.then function since alert always returns with a value of undefined. Here’s the relevant code:

    askNotificationPermission().then(alert).then(registerServiceWorker);
    

    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:

    askNotificationPermission().then(registerServiceWorker);
    
    async function registerServiceWorker(permissionStatus) {
      if (permissionStatus !== "granted") return alert("no permission!")
    
      const swRegistration = await navigator.serviceWorker.register('/assets/js/order-dashboard/serviceworker.js');
      const subscription = swRegistration.pushManager.subscribe(...)
      // save subscription at backend
      fetch(.., {body: JSON.stringify(subscription)})
    }
    

    Can’t be bothered to check documentation right now, but navigator.serviceWorker.register might be also a problem when calling multiple times – use navigator.serviceWorker.getRegistration to check if registration itself already exists instead or re-registering. Also, set scope 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!

    Login or Signup to reply.
  3. 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 on saveSubscription (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:

    const swRegistration = await registerServiceWorker();
    const permission = await requestNotificationPermission();
    

    To:

    const permission = await requestNotificationPermission();
    const swRegistration = await registerServiceWorker();
    

    It will have no negative effects for Chrome a.o.

    Login or Signup to reply.
  4. 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

    {
      "name": "pwa-ios-test",
      "short_name": "pwa-ios-test",
      "description": "pwa-ios-test",
      "display": "standalone",
      "theme_color": "#ffffff",
      "icons": [
        {
          "src": "pwa-192x192.png",
          "sizes": "192x192",
          "type": "image/png"
        },
        {
          "src": "pwa-512x512.png",
          "sizes": "512x512",
          "type": "image/png"
        },
        {
          "src": "pwa-512x512.png",
          "sizes": "512x512",
          "type": "image/png",
          "purpose": "any maskable"
        }
      ]
    }
    

    index.html

    <!DOCTYPE html>
    <html>
      <head>
        <link href="manifest.json" rel="manifest">
    ...
    </html>
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search