> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
> Use this file to discover all available pages before exploring further.

# PWA iOS Push Notification Review

> Date: 2026-02-15 Status: All Recommendations Implemented Reviewer: AI Agent (Cloud)

**Date:** 2026-02-15\
**Status:** All Recommendations Implemented\
**Reviewer:** AI Agent (Cloud)

***

## Executive Summary

iOS 16.4+ (Safari 16.4+, released March 2023) added support for Web Push Notifications in PWAs — but **only** when the PWA is **installed to the Home Screen** (running in `standalone` display mode). This is a critical difference from Android/desktop, where push works in-browser without installation.

After a thorough review of the Encore Health OS push notification implementation against iOS PWA requirements, **14 gaps and issues** were identified, ranging from **critical blockers** that will prevent push from working on iOS entirely, to moderate and minor improvements.

### Severity Summary

| Severity     | Count | Description                                         |
| ------------ | ----- | --------------------------------------------------- |
| **CRITICAL** | 4     | Will prevent push notifications from working on iOS |
| **HIGH**     | 4     | Significant UX/reliability issues specific to iOS   |
| **MODERATE** | 3     | Correctness or robustness issues                    |
| **LOW**      | 3     | Best-practice improvements                          |

***

## iOS PWA Push Notification Requirements (Research)

### Key Requirements for iOS Push to Work

1. **PWA must be installed to Home Screen** — Push is not available in Safari browser tabs. The app must run in `standalone` or `fullscreen` display mode via Home Screen.
2. **User must explicitly grant permission** — iOS requires a user gesture (button tap) to trigger `Notification.requestPermission()`. Cannot be called on page load or in a passive context.
3. **Service worker must be registered** — The service worker that handles `push` events must be active before subscribing.
4. **VAPID keys required** — The `applicationServerKey` must be provided during `pushManager.subscribe()`.
5. **`userVisibleOnly: true` is mandatory** — iOS enforces that every push must show a visible notification to the user.
6. **Payload size limit: \~4 KB** — iOS Safari enforces a strict \~4 KB limit on push payloads (more restrictive than Chrome's \~4078 bytes).
7. **No silent push** — iOS does not support silent/background push in PWAs. Every push must display a notification.
8. **No `vibrate` on iOS** — The Vibration API is not supported on iOS. Setting `vibrate` in notification options is silently ignored, but won't cause errors.
9. **Notification actions limited** — iOS Safari supports a maximum of 2 notification actions (buttons). Excess actions are silently dropped.
10. **No `image` in notifications** — iOS Safari does not support the `image` option in `showNotification()`. Only `icon` and `badge` are respected.
11. **`tag` + `renotify` behavior** — iOS supports `tag` for notification grouping, but `renotify` behavior may differ from Chrome.
12. **Subscription can expire** — iOS may invalidate push subscriptions when the PWA is removed from Home Screen or during OS updates. Re-subscription logic is important.
13. **`clients.openWindow()` limitations** — On iOS, `clients.openWindow()` from a notification click may not work reliably if the PWA is not already running. `clients.matchAll()` + `client.focus()` + `client.navigate()` is the preferred pattern.
14. **No `requireInteraction`** — iOS does not support `requireInteraction`. Notifications auto-dismiss after a system-determined timeout.

### Safari/iOS-Specific Behavioral Notes

* **Permission prompt timing:** iOS shows the native permission prompt only on explicit user gesture. If called without a gesture, it silently returns `'denied'` without showing a prompt.
* **Subscription endpoint format:** iOS uses Apple's push service endpoints (`web.push.apple.com`), which differ from Google's FCM endpoints. The `web-push` library handles this correctly.
* **Service worker lifecycle:** iOS Safari may terminate the service worker more aggressively than Chrome. The `push` event handler must use `event.waitUntil()` to ensure the notification is shown before termination.
* **No background sync for push:** Unlike Android, iOS does not keep the service worker alive for background operations. Push events are the only reliable way to wake the service worker.

***

## Gap Analysis

### CRITICAL Issues (Push Will Not Work on iOS)

#### 1. No iOS-Specific PWA Install Requirement Guidance

**File:** `src/platform/notifications/components/PushNotificationSettings.tsx`

**Problem:** On iOS, push notifications **only work in installed PWAs** (Home Screen apps). The current "not supported" message when `isSupported` is `false` says:

> "Push notifications are not supported in this browser. Try using Chrome, Firefox, or Edge."

On iOS Safari (in-browser), `PushManager` is not available — it only becomes available after the user installs the PWA to Home Screen. The current code will show the generic "not supported" message to all iOS Safari users, with no guidance on how to enable it.

**Impact:** iOS users visiting the site in Safari will be told push isn't supported, when it actually is — they just need to install the app first.

**Fix:** Detect iOS Safari in-browser mode and show a specific message instructing the user to install the PWA to their Home Screen first. Example detection:

```typescript theme={null}
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || 
  (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || 
  (window.navigator as any).standalone === true;
const isIOSSafari = isIOS && !isStandalone;
```

When `isIOSSafari && !isSupported`, show:

> "To enable push notifications on iOS, install Encore Health OS to your Home Screen first: tap the Share button, then 'Add to Home Screen'."

***

#### 2. Permission Request Without User Gesture (iOS Silent Denial)

**File:** `src/platform/notifications/hooks/usePushSubscription.ts`, lines 93-101

**Problem:** The `subscribe()` function calls `requestPermission()` internally if permission isn't already granted. On iOS, `Notification.requestPermission()` **must** be called in direct response to a user gesture (tap/click). If there is any async gap between the user gesture and the permission request (e.g., if `subscribe()` does async work before calling `requestPermission()`), iOS Safari will silently return `'denied'` without showing the prompt.

In the current flow:

1. User taps toggle switch
2. `handleToggle(true)` calls `subscribe()`
3. `subscribe()` checks `state.permission` (synchronous state from a previous render)
4. Calls `requestPermission()` — this may or may not be within the gesture context depending on timing

**Impact:** On iOS, the permission prompt may never appear, and permission will be silently set to `'denied'`, requiring the user to go deep into iOS Settings to re-enable it.

**Fix:**

* Call `Notification.requestPermission()` **directly** in the click handler, before any async operations.
* Restructure to: request permission first (synchronously from the gesture), then subscribe.
* Consider a dedicated "Enable Notifications" button rather than a switch toggle, which makes the gesture-to-permission flow more explicit.

***

#### 3. `importScripts` for `sw-push.js` May Not Load on iOS

**File:** `vite.config.ts` (line 130), `public/sw-push.js`

**Problem:** The Workbox-generated service worker uses `importScripts: ['/sw-push.js']` to load the push handler. On iOS Safari, service workers have stricter requirements:

* `importScripts()` is supported but the imported script must be served with the correct MIME type (`application/javascript`).
* More critically, if the `sw-push.js` file is not in the precache manifest or is cache-busted incorrectly, iOS may fail to load it when the service worker starts up after an OS/browser update.
* VitePWA with Workbox's `importScripts` option injects the call into the generated SW, but the `sw-push.js` file is in `/public/` and is not precached by default with a revision hash, meaning stale cache issues are possible.

**Impact:** Push event listener may not be registered in the service worker on iOS, causing push messages to be received but silently dropped (no notification shown).

**Fix:**

* Add `sw-push.js` to the VitePWA `includeAssets` or add a cache-busting revision to prevent stale scripts.
* Better approach: inline the push handlers directly into the service worker using Workbox's `injectManifest` mode, or use VitePWA's `plugins` approach to inject custom SW code. This avoids `importScripts` entirely.
* At minimum, add `sw-push.js` to the precache manifest with a revision hash:

```typescript theme={null}
additionalManifestEntries: [
  { url: '/index.html', revision: buildId },
  { url: '/sw-push.js', revision: buildId },  // Add this
],
```

***

#### 4. VAPID Keys Not Deployed (Configuration Blocker)

**File:** `docs/pf/recommendations/VAPID_KEY_SETUP.md`

**Problem:** The VAPID key setup document shows that Steps 2 and 3 (configure Supabase secrets and Vercel environment variables) are still marked as "PENDING". Without these:

* The edge function returns 503 ("Push notifications not configured")
* The frontend has no `VITE_VAPID_PUBLIC_KEY`, so `subscribe()` bails early with a toast error

**Impact:** Push notifications cannot work at all — on any platform, not just iOS.

**Fix:** Deploy the VAPID keys:

1. Set `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` as Supabase edge function secrets
2. Set `VITE_VAPID_PUBLIC_KEY` as a Vercel environment variable (all environments)
3. Add `VITE_VAPID_PUBLIC_KEY` to `.env.local` for development

***

### HIGH Issues (Significant iOS UX/Reliability)

#### 5. `PwaInstallPrompt` Uses `beforeinstallprompt` (Not Available on iOS)

**File:** `src/platform/pwa/components/PwaInstallPrompt.tsx`

**Problem:** The install prompt component relies entirely on the `beforeinstallprompt` event, which is a **Chrome/Edge/Samsung Internet-only API**. iOS Safari does not fire this event. The component will never show on iOS devices.

Since iOS push requires Home Screen installation, there is no mechanism to guide iOS users to install the PWA.

**Impact:** iOS users have no in-app guidance to install the PWA, which is a prerequisite for push notifications.

**Fix:** Add an iOS-specific install banner that detects iOS Safari (not in standalone mode) and shows manual instructions:

* "Install Encore Health OS: Tap **Share** → **Add to Home Screen**"
* Show this banner prominently on the notification settings page and optionally on first visit

***

#### 6. `notificationclick` Handler Uses `client.navigate()` — Not Supported Everywhere

**File:** `public/sw-push.js`, lines 77-81

**Problem:** The `notificationclick` handler calls `client.navigate(url)` on an existing window client. The `WindowClient.navigate()` method is not part of the Service Worker spec's guaranteed API surface — while it works on most platforms, it has issues:

* On iOS, `client.navigate()` may throw or silently fail in certain conditions (e.g., when the PWA was suspended by the OS).
* The code doesn't have a `try/catch` around the `navigate` call.

**Impact:** Clicking a notification on iOS may not navigate to the correct page, and an unhandled error could prevent the notification click from being processed.

**Fix:**

```javascript theme={null}
// Wrap navigate in try/catch, fall back to openWindow
try {
  await client.focus();
  await client.navigate(url);
} catch (e) {
  // Fallback: open a new window
  if (clients.openWindow) {
    await clients.openWindow(url);
  }
}
```

***

#### 7. `vibrate`, `image`, and `requireInteraction` Won't Work on iOS

**File:** `public/sw-push.js`, lines 21-35

**Problem:** The notification options include:

* `vibrate: [100, 50, 100]` — Not supported on iOS (Vibration API not available)
* `image: data.image` (line 39) — Not supported on iOS Safari
* `requireInteraction: data.priority === 'high'` — Not supported on iOS

While these won't cause errors (they're silently ignored), they create a false expectation that high-priority notifications will persist on iOS and that images will display.

**Impact:** Notifications on iOS will look different from what's configured — no images, no persistent display for "high" priority, no vibration.

**Fix:**

* Document this as a known iOS limitation in the notification system
* On the server/edge function side, consider platform-aware payload construction (don't send `image` for iOS endpoints if you're tracking device type)
* Add a `device_type` or `platform` column to `pf_push_subscriptions` to enable platform-aware notification customization

***

#### 8. No Re-Subscription Logic for Expired iOS Subscriptions

**File:** `src/platform/notifications/hooks/usePushSubscription.ts`

**Problem:** iOS Safari may invalidate push subscriptions when:

* The user removes and re-adds the PWA to Home Screen
* iOS updates or resets the push token
* The subscription simply expires (Apple doesn't guarantee token stability)

The current `useEffect` on mount checks if a subscription exists but doesn't verify if it's still valid with the push service. If the subscription has been invalidated server-side (e.g., Apple returned 410 Gone), the app will think it's still subscribed (because the local `PushManager` still returns a subscription object) but pushes will fail silently.

**Impact:** Users on iOS may think notifications are enabled but will stop receiving them after iOS invalidates the token.

**Fix:**

* On mount, after finding an existing subscription, send a "verify" request to the server to check if the stored endpoint is still valid.
* If the server reports the endpoint is expired/missing, automatically re-subscribe.
* Add a periodic health check (e.g., once per session) that verifies the subscription is still active server-side.

***

### MODERATE Issues

#### 9. `device_info` Column Referenced But Doesn't Exist on `pf_push_subscriptions`

**File:** `supabase/functions/send-push-notification/index.ts`, line 204

**Problem:** The edge function references `sub.device_info` when logging delivery metadata:

```typescript theme={null}
metadata: { device: sub.device_info || 'unknown' }
```

However, the `pf_push_subscriptions` table does **not** have a `device_info` column. The table has: `id`, `user_id`, `endpoint`, `p256dh_key`, `auth_key`, `user_agent`, `created_at`, `last_used_at`, `organization_id`, `custom_fields`, `updated_at`, `created_by`, `updated_by`.

The `device_info` column exists on `hr_time_clock_punches` — a completely different table.

**Impact:** `sub.device_info` will always be `undefined`, so the metadata will always log `'unknown'`. This is a data quality issue for push delivery analytics.

**Fix:** Use `sub.user_agent` instead:

```typescript theme={null}
metadata: { device: sub.user_agent || 'unknown' }
```

***

#### 10. `web-push` Library Import via `esm.sh` May Have Deno Compatibility Issues

**File:** `supabase/functions/send-push-notification/index.ts`, line 8

**Problem:** The import uses:

```typescript theme={null}
import webPush from 'https://esm.sh/web-push@3.6.7';
```

The `web-push` library is a Node.js library that relies on Node's `crypto` module for ECDH key exchange and HKDF. While `esm.sh` can shimmy some Node APIs, the `web-push` library's cryptographic operations may not work correctly in Deno's edge function runtime because:

1. `web-push` internally uses `crypto.createECDH()`, `crypto.createHmac()`, etc. — Node-specific APIs
2. Deno's Node compatibility layer covers many of these, but edge function environments may have restrictions
3. The `esm.sh` CDN may not correctly polyfill all Node crypto primitives for Deno

If the `web-push` library fails at runtime, push notifications will fail silently with a cryptic error.

**Impact:** Push sending may fail in production with crypto-related errors that are hard to debug.

**Fix:**

* Test the edge function in a local Deno environment to verify `web-push` works
* Consider using `npm:web-push@3.6.7` import specifier instead (Supabase edge functions support `npm:` specifiers which have better Node compatibility)
* Actually, I see the VAPID\_KEY\_SETUP.md recommends `npm:web-push@3.6.7` but the actual file uses `https://esm.sh/web-push@3.6.7` — the `npm:` specifier is preferred for Supabase edge functions

***

#### 11. `send-push-notification` Edge Function Has No Authentication/Authorization

**File:** `supabase/functions/send-push-notification/index.ts`

**Problem:** The edge function accepts a `user_id` in the request body and sends push notifications to that user's devices. There is **no authentication check** — unlike `save-push-subscription` and `remove-push-subscription` which both validate the JWT token, this function blindly trusts the `user_id` parameter.

This means:

1. Any authenticated user could send push notifications to any other user
2. If the function is somehow exposed, unauthenticated calls could send notifications

**Impact:** Security vulnerability — any user can spam any other user with push notifications by calling the edge function with an arbitrary `user_id`.

**Fix:** Add authentication and authorization:

```typescript theme={null}
// Verify the caller is authenticated
const authHeader = req.headers.get('Authorization');
// For user-to-user: verify caller has permission to notify target user
// For system: verify service role key is used
```

At minimum, check that the request comes from an authenticated user or service role. For user-initiated notifications, verify the caller has the right to notify the target user (e.g., same organization, has admin role).

***

### LOW Issues

#### 12. `console.log` Statements in Service Worker and Edge Functions

**Files:** `public/sw-push.js`, `supabase/functions/send-push-notification/index.ts`

**Problem:** The codebase has multiple `console.log` and `console.error` statements. Per the project's coding standards, console statements are prohibited in production code.

Note: Edge functions use `createLogger()` from shared utilities but also have raw `console.log`/`console.warn`/`console.error` calls. The service worker (`sw-push.js`) uses `console.log`/`console.error` directly.

**Fix:**

* Replace console statements in `sw-push.js` with structured logging or remove them
* Ensure the edge function uses only the logger utility consistently

***

#### 13. Push Notification Settings "Learn More" Links to Chrome-Only Documentation

**File:** `src/platform/notifications/components/PushNotificationSettings.tsx`, line 134

**Problem:** The "Learn more" link about installing as an app points to `https://support.google.com/chrome/answer/9658361` — Chrome's documentation. This is unhelpful for iOS Safari users.

**Fix:** Either:

* Make the link platform-aware (detect iOS/Android/desktop and link to the appropriate documentation)
* Link to your own documentation page that covers all platforms

***

#### 14. `PushNotificationSettings` "Permission Denied" Recovery Instructions Are Chrome-Specific

**File:** `src/platform/notifications/components/PushNotificationSettings.tsx`, lines 60-67

**Problem:** When permission is `'denied'`, the instructions say:

1. Click the lock icon in your browser's address bar
2. Find "Notifications" in the site settings
3. Change from "Block" to "Allow"
4. Refresh this page

These instructions are Chrome-specific. On iOS Safari, the process is:

1. Open Settings app
2. Scroll to Safari (or the PWA name)
3. Tap Notifications
4. Enable notifications

**Fix:** Detect the platform and show platform-appropriate instructions for re-enabling notifications.

***

## Summary of Recommended Actions

### Immediate (Before Shipping iOS Push)

| #  | Issue                                                            | Severity | Status                                            |
| -- | ---------------------------------------------------------------- | -------- | ------------------------------------------------- |
| 4  | Deploy VAPID keys to Supabase & Vercel                           | CRITICAL | Manual config required (see VAPID\_KEY\_SETUP.md) |
| 1  | Add iOS install-first guidance on push settings page             | CRITICAL | DONE                                              |
| 2  | Fix permission request to be gesture-bound for iOS               | CRITICAL | DONE                                              |
| 3  | Fix `sw-push.js` loading reliability (add to precache or inline) | CRITICAL | DONE                                              |
| 11 | Add auth/authz to `send-push-notification` edge function         | MODERATE | DONE                                              |

### Short-Term (Improve iOS Experience)

| #  | Issue                                         | Severity | Status |
| -- | --------------------------------------------- | -------- | ------ |
| 5  | Add iOS-specific PWA install prompt           | HIGH     | DONE   |
| 6  | Add try/catch to `notificationclick` navigate | HIGH     | DONE   |
| 8  | Add subscription re-validation on mount       | HIGH     | DONE   |
| 10 | Switch `web-push` import to `npm:` specifier  | MODERATE | DONE   |
| 9  | Fix `device_info` reference to `user_agent`   | MODERATE | DONE   |

### Longer-Term (Polish)

| #  | Issue                                                                          | Severity | Status |
| -- | ------------------------------------------------------------------------------ | -------- | ------ |
| 7  | Document iOS notification option limitations, consider platform-aware payloads | HIGH     | DONE   |
| 12 | Remove/replace console statements                                              | LOW      | DONE   |
| 13 | Make "Learn more" link platform-aware                                          | LOW      | DONE   |
| 14 | Make permission-denied instructions platform-aware                             | LOW      | DONE   |

***

## Additional Recommendations

### Platform Detection Utility

Create a shared utility for iOS/platform detection that can be used across the notifications and PWA modules:

```typescript theme={null}
// src/platform/pwa/utils/platformDetection.ts
export function isIOS(): boolean {
  return /iPad|iPhone|iPod/.test(navigator.userAgent) || 
    (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
}

export function isStandalonePWA(): boolean {
  return window.matchMedia('(display-mode: standalone)').matches || 
    (window.navigator as any).standalone === true;
}

export function isIOSSafariInBrowser(): boolean {
  return isIOS() && !isStandalonePWA();
}

export function canReceivePushNotifications(): boolean {
  // iOS: only in installed PWA
  if (isIOS()) {
    return isStandalonePWA() && 'PushManager' in window;
  }
  // Other platforms: check standard APIs
  return 'Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window;
}
```

### iOS Push Test Checklist

Before claiming iOS push support is ready:

* [ ] VAPID keys deployed to Supabase secrets and Vercel env vars
* [ ] PWA installed to Home Screen on iOS 16.4+ device
* [ ] Permission prompt appears on button tap (not on page load)
* [ ] Subscription is created and stored in `pf_push_subscriptions`
* [ ] Test notification received and displayed on iOS
* [ ] Notification tap opens the app and navigates to correct URL
* [ ] Notification works when app is in background (not running)
* [ ] Subscription survives app restart
* [ ] Expired subscription is detected and user is prompted to re-subscribe
* [ ] Edge function correctly handles Apple push service endpoints

***

## References

* [Apple: Web Push for Safari](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/)
* [Apple: Sending Web Push Notifications](https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers)
* [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
* [web.dev: Push Notifications Overview](https://web.dev/articles/push-notifications-overview)
* [W3C Push API Spec](https://www.w3.org/TR/push-api/)
* [Can I Use: Push API](https://caniuse.com/push-api)
