Web Push on iOS

2023-03-08 ⏳4.2 min(1.7k words)

For Chinese reader:

在中国互联网环境中,面向 C 端的 Web 技术已经被玩坏了。大多数普通人甚至都觉得所有功能都得安装 App 才能实现。所以 Web Push 对目前中国的用户基本没什么影响。

有鉴于此,本文内容语言使用英语。

Finally, the first beta of Safari 16.41 packs the long awaited feature, the Web Push. I can’t wait to install the beta version iOS, and have made a demo. In this article, I will demonstrate how to send web notification to Safari, and some of its restrictions.

One of the most important restrictions is that only the so-called Home Screen web apps have the ability to receive Web Push Notification. If one web apps is running in Safari, every time it request the permissions for Web Push will be declined. Only after user added web apps to their Home Screen, can they be able to gain the corresponding permissions.

In order to make web app Home Screen installable, you need offer the web manifest file2. There are many keys could be added to the file. But not all supported by Safari. So please read the docs carefully.

You can download the manifest file used in my demo. We need to set the display key to fullscreen, so that when we opened the web app, it will hide the Safari UI and display web content only. There is one thing to note, every path in one site can have their own manifest file. So we can create many different web apps under the same domain. In my demo, I chosen to use the path /web/push-demo/.

Another step we need to finish is to open two experimental WebKit features flags for Safari, because Web Push is experimental even in the beta release. We need to go to Setting/Safari/Advanced/Experimental-Features, and open flags for Push API and Notifications.

And then, we can do our test. Here is a global overview of how Web Push works.

  1. The user add the web apps into their Home Screen, and open the web apps.
  2. The Safari will register a service worker to handle the push event and notificationclick event.
  3. The user ask the notification permission by clicking some UI in web app.
  4. The iOS show the authorization interface, and the user allow that permission.
  5. The web app get the subscription and maybe send subscription to the server.
  6. The server use the subscription to generate notification data and send to APNS.
  7. The APNS forward notification data to client, and run it’s push event callback in the service worker.
  8. The service worker let Safari display the notification.
  9. When user click the notification, Safari will call the notificationclick handler. And in here we can open the web app and send some other data.

By the way, the aforementioned process is specified by Push API3 and Notification API4.

Talk is cheap. Show you the code.

First, we need to ask user grant the notification permission.

async function askUserPermission() {
  let r = await Notification.requestPermission();
  if (r === "granted") {
    // ...
  } else {
    // ...
  }
}

This function can only called in handler triggered by user action. If we called without any user gesture, it will always return denied. And in iOS, if the web app have not installed to Home Screen, it will always get denied.

If user granted, we need to register one service worker to handle push event.

await navigator.serviceWorker.register("sw.js");

In the sw.js, we need register two event handlers:

self.addEventListener("push", receivePushNotification);
self.addEventListener("notificationclick", openPushNotification);

The push handler is simple. What it need to do is parse the data received and display the notification.

function receivePushNotification(event) {
  const { title, body, data } = event.data.json();

  const options = {
    body: body,
    data: data, 
  };

  event.waitUntil(self.registration.showNotification(title, options));
}

The Web Push API does not constrain the data pushed to web app. Developer can push any thing with any data structure. In my demo, I will push to the client a JSON object, which contains only three key: title, body, and data.

Every time the Safari receive a push data, it will call the push event handler. And after parsing the data, we need call the showNotification API to show notification message. We can set many different options by it’s second argument. But most of them are not supported by Safari. Here we only use two bask options. The body will used as the notification description, and the data will be transfered to the notificationclick event handler.

If the user clicked the notification, Safari will call the handler for notificationclick event. This handler is a little more complicate. Because we need to open the web app here, and there is several occasions to handle.

Generally, we need to clear the notification user clicked.

event.notification.close();

And then, we need open the web app and transfer some payload to it. This is a asynchronous process. The service worker need to wait the whole process finished. Otherwise, the service worker may be stopped too early to open the web app. This is why we need the event.waitUntil method.

The waitUntil receive a promise as argument. If we want to use the async/await paradigm, we need call them immediately, so we could get a promise object.

event.waitUntil((async () => {
  // ...
})());

But how to open the web app? Many contents on the Internet say just call the clients.openWindow(url) is enough. But I do not think so, because this will open many pages, even you can not see in iOS. If you want to build a web app, you maybe want to open one page only. So we need to first find all page opened, and

In order to find the opened page, we can use the clients.matchAll method. And then check theirs focused status, and call the focus() when needed. If we found a client, we can send the payload data by calling the postMessage method.

If there is no opened client, we need to open new one. As I want to build a Single Page Application (SPA), I just use the event.target.registration.scope as the URL and open it.

const allClients = await clients.matchAll({ type: 'window' });

for (const client of allClients) {
  if (!client.focused) {
    await client.focus().catch(e => { console.log(e) });
  }
  client.postMessage(event.notification.data);
  return;
}

let url = event.target.registration.scope;
let w = await clients.openWindow(url);
w.postMessage(event.notification.data);

The full code can be fetched at here.

In the we app side, we need handle the message event:

navigator.serviceWorker.addEventListener("message", (event) => {
  console.log(event.data);
});

Every time Safari receive one notification, it will open or focus to the target web app and send it the payload data.

However, the current implementation has a crucial issue. If the web app not running, it will not get the payload. I have fire a bug5. And it will be fixed in feature release.

In order to send push message to client, the server need a subscription object.

async function createNotificationSubscription() {
  let sw = await navigator.serviceWorker.ready;
  let s = await sw.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: pushServerPublicKey
  })
  return s;
}

We need to call the pushManager.subscribe method. And it is only available when the service worker is ready. So we need await it. And the subscribe method receive one argument which has several options.

The userVisibleOnly means all the push message will displayed in the notification center. Its value must be true. And all the major browser do not support to push silent message.

The applicationServerKey is the Voluntary Application Server Identification (VAPID), which is defined in RFC8292. Essentially, VAPID is a public key of key pair on the P256 curve. And here is the GoLang code the generate VAPID.

var buf [32]byte
rand.Read(buf[:])

// before go 1.20
c := elliptic.P256()
x, y := c.ScalarBaseMult(buf[:])
b := elliptic.Marshal(c, x, y)
// after go 1.20
pk, _ := ecdh.P256().NewPrivateKey(b)
b := pk.PublicKey()

vapid := base64.RawURLEncoding.EncodeToString(b)

All the client’s subscribe will associate to this VAPID. If there server lost it’s private key or this VAPID be blocked, the server will not be able to push message to clients any more.

The subscribe object looks like:

{
 "endpoint": "https://web.push.apple.com/QBO24...",
 "expirationTime": null,
 "keys": {
  "auth": "jUBgIN...",
  "p256dh": "BLFlHO..."
 }
}

The field expirationTime means the expiration time of this subscription. And all major browser does not support this feature. The field endpoint is just an URL which will be used when send notifications. And both auth and p256dh in the keys field will be used to construct data need for sending notification.

Sending Web Push from server side is not an easy task. And requirements are defined in RFC8030. The whole process is out the scope of this article. All we need to know is the server use both the subscription object and its private key to generate an shared disposable key and use it to encrypt the who data need to push to clients. And the server use its private to generate on JWT token with it VAPID, so that the APNS can identify the server. And finally, the server push all these data to the endpoint fetched from the subscription object.

When receive push data, Safari will first use its subscription’s private key generate the shared disposable key, and decrypt the notification, and then call the push handle in the service worker.

So the APNS know nothing about the content pushed from the server to the client.

There are many web push library in many languages. In my example, I use GoLang, and choose the webpush-go6. And I also made a open API for test.

curl --data 'title=WebPush&body=Ping&data=test&subs={
 "endpoint": "https://web.push.apple.com/QNGP...",
 "keys": {
  "p256dh": "BLPL...",
  "auth": "k4m..."
 }
}' https://taoshu.in/+/push

And finally, we finished the demo. The whole live demo URL is https://taoshu.in/web/push-demo/. You can add it to your home screen, and then use the /+push API to send notification to your iPhone. Good luck.


  1. https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/↩︎

  2. https://developer.mozilla.org/en-US/docs/Web/Manifest↩︎

  3. https://developer.mozilla.org/en-US/docs/Web/API/Push_API↩︎

  4. https://developer.mozilla.org/en-US/docs/Web/API/notification↩︎

  5. This bug has been fixed by #11848 and has been fixed since iOS 16.5. On 16.6, webpush notification will play alert sounds😄↩︎

  6. https://github.com/SherClockHolmes/webpush-go↩︎