diff --git a/index.html b/index.html index 707590647..55d90738d 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - OneSignal Dev + OneSignal Dev — SDK-4180 Safari VAPID Test +
+

Browser & Safari Push Info

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
User Agent
window.safari exists
Legacy permission state
Legacy deviceToken
Notification.permission
PushSubscriptionOptions
Expected push path
+
+ +
+

Actions

+ + +
+ +
+

Log

+
+
+ - + diff --git a/package.json b/package.json index 3d7941ee9..c8eb73d05 100644 --- a/package.json +++ b/package.json @@ -76,12 +76,12 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "42.22 kB", + "limit": "42.28 kB", "gzip": true }, { "path": "./build/releases/OneSignalSDK.sw.js", - "limit": "12.28 kB", + "limit": "12.35 kB", "gzip": true }, { diff --git a/src/shared/environment/detect.test.ts b/src/shared/environment/detect.test.ts index f41c61915..3420ec0fc 100644 --- a/src/shared/environment/detect.test.ts +++ b/src/shared/environment/detect.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, test, vi } from 'vite-plus/test'; +import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import { beforeEach, describe, expect, test, vi } from 'vite-plus/test'; + +import { SubscriptionType } from '../subscriptions/constants'; +import { getSubscriptionType, useSafariLegacyPush } from './detect'; let getOneSignalApiUrl: typeof import('src/shared/environment/detect').getOneSignalApiUrl; @@ -7,6 +11,44 @@ const resetModules = async () => { getOneSignalApiUrl = (await import('src/shared/environment/detect')).getOneSignalApiUrl; }; +const FAKE_SAFARI_WEB_ID = 'web.onesignal.auto.017d7a1b-f1ef-4fce-a00c-21a546b5491d'; + +const mockSafariPushNotification = ( + permission: 'default' | 'granted' | 'denied', + deviceToken: string | null, +) => { + Object.defineProperty(window, 'safari', { + value: { + pushNotification: { + permission: () => ({ permission, deviceToken }), + }, + }, + writable: true, + configurable: true, + }); +}; + +const clearSafariWindow = () => { + Object.defineProperty(window, 'safari', { + value: undefined, + writable: true, + configurable: true, + }); +}; + +const mockVapidSupport = () => { + window.PushSubscriptionOptions = { + prototype: { + // @ts-expect-error - we're mocking the PushSubscriptionOptions type + applicationServerKey: undefined, + }, + }; +}; + +const clearVapidSupport = () => { + delete (globalThis as any).PushSubscriptionOptions; +}; + describe('Environment Helper', () => { test('can get api url ', async () => { // staging @@ -39,3 +81,93 @@ describe('Environment Helper', () => { ).toBe('https://onesignal.com/api/v1/'); }); }); + +describe('useSafariLegacyPush', () => { + beforeEach(() => { + TestEnvironment.initialize(); + clearSafariWindow(); + mockVapidSupport(); + }); + test('returns false when window.safari is undefined', () => { + expect(useSafariLegacyPush()).toBe(false); + }); + + test('returns false when safari.pushNotification is undefined', () => { + Object.defineProperty(window, 'safari', { + value: {}, + writable: true, + configurable: true, + }); + expect(useSafariLegacyPush()).toBe(false); + }); + + test('returns false when safariWebId is not configured', () => { + mockSafariPushNotification('granted', 'abc123'); + OneSignal.config!.safariWebId = undefined; + expect(useSafariLegacyPush()).toBe(false); + }); + + test('returns false when legacy permission is "default" (new user on Safari 16.4+)', () => { + mockSafariPushNotification('default', null); + OneSignal.config!.safariWebId = FAKE_SAFARI_WEB_ID; + expect(useSafariLegacyPush()).toBe(false); + }); + + test('returns false when legacy permission is "denied"', () => { + mockSafariPushNotification('denied', null); + OneSignal.config!.safariWebId = FAKE_SAFARI_WEB_ID; + expect(useSafariLegacyPush()).toBe(false); + }); + + test('returns false when permission is "granted" but deviceToken is null', () => { + mockSafariPushNotification('granted', null); + OneSignal.config!.safariWebId = FAKE_SAFARI_WEB_ID; + expect(useSafariLegacyPush()).toBe(false); + }); + + test('returns true when user has an active legacy subscription', () => { + mockSafariPushNotification('granted', 'aabbccdd11223344'); + OneSignal.config!.safariWebId = FAKE_SAFARI_WEB_ID; + expect(useSafariLegacyPush()).toBe(true); + }); + + test('returns true on Safari < 16.4 (no VAPID support) even for new users', () => { + clearVapidSupport(); + mockSafariPushNotification('default', null); + OneSignal.config!.safariWebId = FAKE_SAFARI_WEB_ID; + expect(useSafariLegacyPush()).toBe(true); + }); +}); + +describe('getSubscriptionType', () => { + beforeEach(() => { + TestEnvironment.initialize(); + clearSafariWindow(); + mockVapidSupport(); + }); + + test('returns SafariLegacyPush for existing legacy Safari subscribers', () => { + mockSafariPushNotification('granted', 'aabbccdd11223344'); + OneSignal.config!.safariWebId = FAKE_SAFARI_WEB_ID; + expect(getSubscriptionType()).toBe(SubscriptionType._SafariLegacyPush); + }); + + test('returns SafariPush (VAPID) for new Safari users on Safari 16.4+', () => { + mockSafariPushNotification('default', null); + OneSignal.config!.safariWebId = FAKE_SAFARI_WEB_ID; + expect(getSubscriptionType()).not.toBe(SubscriptionType._SafariLegacyPush); + }); + + test('does not return SafariLegacyPush when safari window exists but no legacy subscription', () => { + mockSafariPushNotification('denied', null); + OneSignal.config!.safariWebId = FAKE_SAFARI_WEB_ID; + expect(getSubscriptionType()).not.toBe(SubscriptionType._SafariLegacyPush); + }); + + test('returns SafariLegacyPush on Safari < 16.4 (no VAPID support)', () => { + clearVapidSupport(); + mockSafariPushNotification('default', null); + OneSignal.config!.safariWebId = FAKE_SAFARI_WEB_ID; + expect(getSubscriptionType()).toBe(SubscriptionType._SafariLegacyPush); + }); +}); diff --git a/src/shared/environment/detect.ts b/src/shared/environment/detect.ts index 16aee3cd9..af73332e8 100644 --- a/src/shared/environment/detect.ts +++ b/src/shared/environment/detect.ts @@ -19,8 +19,33 @@ export const supportsServiceWorkers = () => { export const windowEnvString = IS_SERVICE_WORKER ? 'Service Worker' : 'Browser'; -export const useSafariLegacyPush = () => - isBrowser() && window.safari?.pushNotification != undefined; +/** + * Returns true when the legacy Safari push path should be used. + * + * Safari 16.4+ supports standard Web Push (VAPID), but `window.safari.pushNotification` + * was never removed. Previously this function returned true whenever that API existed, + * which forced ALL macOS Safari users onto the legacy path — even on browsers that + * fully support VAPID. Now we check the actual permission state: only users who have + * already granted legacy push (and have a deviceToken) stay on the legacy path, because + * Safari doesn't support migrating from legacy push to standard web push. + * + * On Safari < 16.4, VAPID is not available so the legacy path is the only option. + */ +export const useSafariLegacyPush = (): boolean => { + if (!isBrowser() || window.safari?.pushNotification == undefined) { + return false; + } + const safariWebId = OneSignal?.config?.safariWebId; + if (!safariWebId) return false; + + const hasVapidSupport = + typeof PushSubscriptionOptions !== 'undefined' && + PushSubscriptionOptions.prototype.hasOwnProperty('applicationServerKey'); + if (!hasVapidSupport) return true; + + const { permission, deviceToken } = window.safari.pushNotification.permission(safariWebId); + return permission === 'granted' && deviceToken != null; +}; export const supportsVapidPush = typeof PushSubscriptionOptions !== 'undefined' && diff --git a/vite.config.ts b/vite.config.ts index 0a4e09467..874910ff8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -150,10 +150,9 @@ export default defineConfig({ server: { open: true, port: 4000, - https: { - cert: './certs/cert.pem', - key: './certs/dev.pem', - }, + https: getBooleanEnv(process.env.HTTPS) + ? { cert: './certs/cert.pem', key: './certs/dev.pem' } + : {}, }, test: { clearMocks: true,