Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

After ~1 month, push to iOS Safari stops working #12

Closed
jgarplind opened this issue Apr 9, 2024 · 6 comments
Closed

After ~1 month, push to iOS Safari stops working #12

jgarplind opened this issue Apr 9, 2024 · 6 comments

Comments

@jgarplind
Copy link

Apologies in advance for convoluted issue that may not be within the scope of your library.

We are finding that push notifications stop being delivered to iOS devices after ~1 month, and the only solution seems to be for the users to re-add the app to their home screen.

Since the dashboard does not show delivery rates for iOS devices, we have no way to know if Pushy considers the messages to be delivered or not. When we fired a message from the test UI in the dashboard, we got a "message sent successfully" toast, but the message was never seen by the test user.

Rather than a fix for the root cause (which I assume to be on the iOS / Safari side of things, given similar reports for FCM-driven push (see e.g. firebase/firebase-js-sdk#8010)), I was hoping to know if there is any more information that you are able to retrieve / see on your side of things, e.g. delivery rates, error codes, or anything else that is not directly surfaced to us.

Note: We are using the fallback FCM solution, could that have any impact?

Sidenote: We are aware of the possibility that iOS stops sending notifications if we do not promptly display them to our users. However, (other than the FCM fallback) we believe that we do always show them promptly and should therefore not have this issue. Our service worker is based on yours with some added/altered domain-specific logic:

// Listen for incoming push notifications
self.addEventListener("push", function (event) {
  // Extract payload as JSON object, default to empty object
  var data = event.data.json() || {};

  // Notification title and body
  var title = data.title || "New message";
  var body = data.message || "";

  // Extract notification image URL
  var image = data.image;

  // Always navigate to chat page if no url is provided
  var fallbackUrl = "https://" + self.location.hostname + "/chat";

  // Notification options
  var options = {
    body: body,
    icon: image,
    data: {
      url: data.url || fallbackUrl,
      // Required for `Pushy.setNotificationListener` to work properly due to https://github.com/pushy/pushy-sdk-web/blob/351734d10785aec599152f5504f95e47be869190/src/lib/pushy.js#L218
      _pushy: true,
    },
  };

  // Support for notification collapsing
  if (data["_pushyCollapseKey"]) options.tag = data["_pushyCollapseKey"];

  // Wait until notification is shown
  event.waitUntil(self.registration.showNotification(title, options));

  // Set app badge count (if supported)
  // For now, we set a 1, not dealing with state at this point
  // Cleared when 'chat' is rendered
  navigator.setAppBadge(1);

  // Send to Pushy notification listener (if webpage is open)
  clients
    .matchAll({ includeUncontrolled: true, type: "window" })
    .then((clients) => {
      // Set pushy notification flag
      data._pushy = true;

      // Send to all open pages
      clients.forEach((client) => {
        client.postMessage(data, [new MessageChannel().port2]);
      });
    });

  // WebExtensions support
  // Dispatch event to current service worker
  serviceWorker.dispatchEvent(new CustomEvent("message", { detail: data }));
});

// Listen for notification click event
self.addEventListener("notificationclick", function (event) {
  // Hide notification
  event.notification.close();

  // Attempt to extract notification URL
  var url = event.notification.data.url;

  // Inspired by example from Mozilla: https://developer.mozilla.org/en-US/docs/Web/API/WindowClient
  const openChatPromise = clients
    .matchAll({
      type: "window",
      includeUncontrolled: true,
    })
    .then((clientList) => {
      // Inspired by: https://stackoverflow.com/a/75247524/4102048
      if (clientList.length === 0) {
        // App not open. Open it for the user
        return clients.openWindow(url).then((windowClient) => {
          // Focus the new window (if it exists)
          return windowClient ? windowClient.focus() : Promise.resolve();
        });
      }

      // Focus the existing app
      const client = clientList[0];
      return client.focus().then((resolvedClient) =>
        // Send a message to the client, asking it to navigate to the given URL
        resolvedClient.postMessage(
          { ...event.notification.data, action: "navigate" },
          [new MessageChannel().port2],
        ),
      );
    });

  // Clarification why `event.waitUntil` is required: https://stackoverflow.com/a/34250261/4102048
  event.waitUntil(openChatPromise);
});

Thankful for any light you could possibly shed on this issue!

@pushy
Copy link
Owner

pushy commented Apr 9, 2024

Hi @jgarplind,
Thanks for reporting this issue. We'd be glad to assist.

Since your service worker implementation calls event.waitUntil(self.registration.showNotification(title, options)); early on, the issue does not seem to be related to firebase/firebase-js-sdk#8010, which is a result of the Firebase SDK failing to call event.waitUntil(), leading Safari to assume that no visible notification was displayed to the user.

Unfortunately, the Web Push spec has made it impossible to query for the delivery status of push notifications. However, it is possible to track delivery by implementing a fetch() request in your service worker's push event listener, which informs your backend API that the notification was delivered successfully. This also requires implementing a backend API endpoint which handles this request accordingly.

Here is sample code for achieving the same:

self.addEventListener('push', function(event) {
    // Extract payload as JSON object, default to empty object
    var data = event.data.json() || {};

    // Notification title and body
    var title = data.title || "New message";
    var body = data.message || "";

    const showNotificationPromise = self.registration.showNotification(title, {
        body: message
    });

    const deliveryTrackingPromise = fetch('/api/track-delivery?pushId=' + data.pushId);

    const promiseChain = Promise.all([
        showNotificationPromise,
        deliveryTrackingPromise
    ]);

    event.waitUntil(promiseChain);
});

However, if the notification never actually makes it to your end user's device, the delivery tracking code will never run.

If you could please provide us with an affected Pushy device token, we will be glad to investigate further. Feel free to send the token via e-mail to [email protected].

As of today, none of your apps' registered devices have been reported as "Unregistered" by the Safari APNs service, and no other Error Logs are being reported in the dashboard for any of your apps, so it seems like the APNs service still accepts the notifications you're sending, however, possibly due to internal APNs logic decisions, the notification does not actually make it to your end user, even though the APNs service returned a 200 OK status code.

As per FCM Fallback Delivery - it is not related to the issue at hand, as FCM Fallback Delivery only affects Android app push notification delivery behavior.

@jgarplind
Copy link
Author

Thanks for being so quick to respond.

However, it is possible to track delivery by implementing a fetch() request in your service worker's push event listener, which informs your backend API that the notification was delivered successfully.

I agree, this is probably a good course of action regardless.

If you could please provide us with an affected Pushy device token, we will be glad to investigate further. Feel free to send the token via e-mail to [email protected].

I've now sent an email with the currently affected token. Will try to update this issue with any insights to help others who are facing similar problems.

@pushy
Copy link
Owner

pushy commented Apr 10, 2024

Hi @jgarplind,
You're very welcome. Thanks for providing an affected device token.

We've tried to send a test notification to the device, and APNs responded with HTTP status code 201, indicating success. That means APNs has accepted the notification and it has not deemed the device unregistered/expired. We cannot tell, however, whether the notification has made it to the device or not.

The issue is occurring due to either a bug in Safari, iOS, APNs, or internal APNs design logic. It seems the notification does not actually make it to your end user(s), even though the APNs service returned a HTTP 201 OK status code.

Since your service worker is already calling event.waitUntil() on self.registration.showNotification(), it doesn't seem to be caused by an implementation issue with your service worker.

As per @rchan41's updated findings, it seems to be an issue with Safari:
firebase/firebase-js-sdk#8010 (comment)

It is also actively being discussed in multiple threads on Apple's developer forum:
https://forums.developer.apple.com/forums/thread/728796

Most likely, it will be fixed in future versions of iOS/Safari. We greatly apologize for the inconvenience.

@jgarplind
Copy link
Author

Then we have come to similar conclusions.

Thank you very much for offering your expert perspective.

Most likely, it will be fixed in future versions of iOS/Safari.

Hopefully, but I am a bit hesitant to believe in Safari's future in this space after the PWA withdrawal (which has since been reverted, but it still makes it clear that it is not a top priority of Apple's).

We greatly apologize for the inconvenience.

Please don't be. This is not your issue, and you are offering an extremely valuable perspective on this issue.

The path forward for us is probably any mix of the following:

  • "track delivery by implementing a fetch() request in your service worker's push event listener, which informs your backend API that the notification was delivered successfully." as suggested by you is probably necessary, and good for robustness
  • Make it extremely easy for users to become aware and to resolve this issue on their own*
  • Possibly move to other solutions, if we cannot make these notifications reliable

*This got me thinking... so far the solution has been to "re-install" the PWA by removing it from the home screen and adding it again. Do you happen to have any insights and/or educated guesses as to whether this is actually necessary, or if it could be enough to just register a new Pushy token, without reinstalling the app? We will have to test any way, but if you can tell with some degree of confidence that either scenario is more likely, it could help us prioritize.

@pushy
Copy link
Owner

pushy commented Apr 11, 2024

Hi @jgarplind,
You're very welcome!

We do recommend native push on iOS (via a native iOS app) for its robustness. There are no such issues with APNs for native iOS apps.

As per your inquiry - it seems that just re-registering the same PWA (calling Pushy.register() again) re-activates the previously-assigned APNs device token as per @rchan41's findings:

I also noticed that requesting a new messaging token seems to "reactivate" the old one. If you subscribe with this new token, and send a notification to the topic, the iOS device will get two separate notifications.

So it seems unnecessary to reinstall the PWA. Simply call Pushy.register() again and it should re-activate the token. We haven't been able to confirm this ourselves, so please give it a try yourself as well.

@jgarplind
Copy link
Author

Your assumption was correct - re-registering works just fine without reinstalling the app.

I think we can close this issue from a Pushy-perspective now, so feel free to do so if you agree.

@pushy pushy closed this as completed Apr 12, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants