diff --git a/client/ios/App/App/App.entitlements b/client/ios/App/App/App.entitlements index 6631ffa6f..438bb014e 100644 --- a/client/ios/App/App/App.entitlements +++ b/client/ios/App/App/App.entitlements @@ -2,5 +2,11 @@ + aps-environment + production + keychain-access-groups + + $(AppIdentifierPrefix)com.liketrek.trek.shared + diff --git a/client/ios/App/App/AppDelegate.swift b/client/ios/App/App/AppDelegate.swift index fec95acae..28851fe93 100644 --- a/client/ios/App/App/AppDelegate.swift +++ b/client/ios/App/App/AppDelegate.swift @@ -1,16 +1,41 @@ import UIKit import Capacitor +import UserNotifications @UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + // Taps on a TREK notification, and banners while the app is open. + UNUserNotificationCenter.current().delegate = self return true } + // MARK: - Push (TrekPush in TrekShellPlugin.swift) + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + TrekPush.shared.didRegister(token: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + TrekPush.shared.didFail(error) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .list, .sound]) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + if let url = response.notification.request.content.userInfo["url"] as? String { + TrekPush.shared.opened(url: url) + } + completionHandler() + } + func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index e1c6f2a5f..7e6c620ed 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -1,6 +1,9 @@ import AuthenticationServices import Capacitor import CoreLocation +import CryptoKit +import Security +import UserNotifications import LocalAuthentication import UIKit import WebKit @@ -27,6 +30,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr CAPPluginMethod(name: "startLocation", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "stopLocation", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "takeSharedFile", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "registerPush", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "takeOpenedPush", returnType: CAPPluginReturnPromise), ] /// What this binary offers the server's UI, raised with every native feature @@ -34,7 +39,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr /// and a server can require a minimum (minNativeShellApi in /api/health/features). static let shellApi = 2 /// The native features by name, so the UI can turn each on by itself. - static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles"] + static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush"] // MARK: - Shared booking documents @@ -42,6 +47,35 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr TrekSharedFiles.onReceive = { [weak self] file in DispatchQueue.main.async { self?.notifyListeners("sharedFile", data: file) } } + TrekPush.shared.onOpen = { [weak self] url in + DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) } + } + } + + // MARK: - Push + + /// Asks for permission, registers with APNs and answers with a Web Push + /// subscription the server stores like a browser's (native/push.ts): the + /// relay endpoint for this device's token, and this device's keys, which the + /// notification extension uses to decrypt. + @objc func registerPush(_ call: CAPPluginCall) { + TrekPush.shared.register { result in + switch result { + case .success(let subscription): call.resolve(subscription) + case .failure(let error): + let code = (error as? TrekPush.PushError) == .denied ? "DENIED" : "FAILED" + call.reject(error.localizedDescription, code) + } + } + } + + /// The notification the app was opened from, before the UI was there to hear of it. + @objc func takeOpenedPush(_ call: CAPPluginCall) { + if let url = TrekPush.shared.takeOpened() { + call.resolve(["url": url]) + } else { + call.resolve(["url": NSNull()]) + } } /// The document shared to TREK before the UI was there to hear of it. @@ -330,3 +364,143 @@ final class TrekLocation: NSObject, CLLocationManagerDelegate { emit(["error": ["code": code, "message": message]]) } } + +/// The app's side of TREK push: an APNs token, the device's Web Push keys in the +/// keychain group the notification extension shares, and the relay endpoint +/// that ties them together. +final class TrekPush { + static let shared = TrekPush() + static let relay = "https://push.liketrek.com/v1/apns" + /// The keychain group the notification extension reads the keys from. + static let keychainGroup = "com.liketrek.trek.shared" + + enum PushError: Error, Equatable { case denied, failed } + + private var pending: [(Result<[String: Any], Error>) -> Void] = [] + private var openedURL: String? + var onOpen: ((String) -> Void)? + + func register(_ done: @escaping (Result<[String: Any], Error>) -> Void) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + guard granted else { + done(.failure(PushError.denied)) + return + } + DispatchQueue.main.async { + self.pending.append(done) + UIApplication.shared.registerForRemoteNotifications() + } + } + } + + func didRegister(token: Data) { + let hex = token.map { String(format: "%02x", $0) }.joined() + #if DEBUG + let environment = "sandbox" + #else + let environment = "production" + #endif + let result: Result<[String: Any], Error> + if let keys = TrekPushKeys.loadOrCreate() { + result = .success([ + "endpoint": "\(TrekPush.relay)/\(environment)/\(hex)", + "keys": ["p256dh": keys.publicKey, "auth": keys.auth], + ]) + } else { + result = .failure(PushError.failed) + } + let waiting = pending + pending.removeAll() + waiting.forEach { $0(result) } + } + + func didFail(_ error: Error) { + let waiting = pending + pending.removeAll() + waiting.forEach { $0(.failure(error)) } + } + + func opened(url: String) { + if let onOpen { onOpen(url) } else { openedURL = url } + } + + func takeOpened() -> String? { + defer { openedURL = nil } + return openedURL + } +} + +/// The device's Web Push key pair (P-256) and auth secret, kept in the shared +/// keychain group so the notification extension can decrypt what the server +/// encrypted for them (RFC 8291). Base64url without padding, as the server reads them. +enum TrekPushKeys { + private static let privateKeyAccount = "push.privateKey" + private static let authAccount = "push.auth" + + static func loadOrCreate() -> (publicKey: String, auth: String)? { + if let raw = read(privateKeyAccount), let auth = read(authAccount), + let key = try? P256.KeyAgreement.PrivateKey(rawRepresentation: raw) { + return (base64url(key.publicKey.x963Representation), base64url(auth)) + } + let key = P256.KeyAgreement.PrivateKey() + var auth = Data(count: 16) + let status = auth.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + guard status == errSecSuccess, write(privateKeyAccount, key.rawRepresentation), write(authAccount, auth) else { return nil } + return (base64url(key.publicKey.x963Representation), base64url(auth)) + } + + private static func query(_ account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.liketrek.trek.push", + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + ] + } + + /// The team-prefixed group, read from the app's own entitlement at runtime. + private static var accessGroup: String { + let probe: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "push.groupProbe", + kSecAttrService as String: "com.liketrek.trek.push", + kSecReturnAttributes as String: true, + ] + var result: CFTypeRef? + var status = SecItemCopyMatching(probe as CFDictionary, &result) + if status == errSecItemNotFound { + var add = probe + add.removeValue(forKey: kSecReturnAttributes as String) + add[kSecValueData as String] = Data() + SecItemAdd(add as CFDictionary, nil) + status = SecItemCopyMatching(probe as CFDictionary, &result) + } + // The default group is the first one in the entitlement: "TEAMID.com.liketrek.trek.shared". + if status == errSecSuccess, let attributes = result as? [String: Any], + let group = attributes[kSecAttrAccessGroup as String] as? String, + let prefix = group.split(separator: ".").first { + return "\(prefix).\(TrekPush.keychainGroup)" + } + return TrekPush.keychainGroup + } + + private static func read(_ account: String) -> Data? { + var q = query(account) + q[kSecReturnData as String] = true + var result: CFTypeRef? + return SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess ? result as? Data : nil + } + + private static func write(_ account: String, _ data: Data) -> Bool { + SecItemDelete(query(account) as CFDictionary) + var q = query(account) + q[kSecValueData as String] = data + q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + return SecItemAdd(q as CFDictionary, nil) == errSecSuccess + } + + static func base64url(_ data: Data) -> String { + data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts index f85b54f9d..885c05bfa 100644 --- a/client/src/native/nativeApp.test.ts +++ b/client/src/native/nativeApp.test.ts @@ -24,6 +24,7 @@ vi.mock('./capabilities', () => ({ })); vi.mock('./geolocation', () => ({ installNativeGeolocation: vi.fn() })); vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) })); +vi.mock('./push', () => ({ startNativePush: vi.fn(async () => {}) })); vi.mock('../mobile/overlayStack', () => ({ closeTopOverlay: vi.fn(() => false) })); function backButtonHandler(): (event: { canGoBack: boolean }) => void { diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts index 091d439bf..dd50fd6f1 100644 --- a/client/src/native/nativeApp.ts +++ b/client/src/native/nativeApp.ts @@ -8,6 +8,7 @@ import { checkAppUpdate, loadNativeCapabilities } from './capabilities'; import { installNativeGeolocation } from './geolocation'; import { keepOfflineShellFresh } from './offlineShell'; import { isNativeApp, isNativeIos } from './platform'; +import { startNativePush } from './push'; import { reportNativeFailure } from './reportFailure'; import { startSharedFiles } from './sharedFiles'; import { isUnimplemented } from './trekShell'; @@ -30,6 +31,8 @@ export function startNativeApp(): void { void loadNativeCapabilities().then(() => installNativeGeolocation()); // Booking documents shared to TREK, waiting for a trip to import into. void startSharedFiles(); + // Taps on TREK notifications open the place they point at. + void loadNativeCapabilities().then(() => startNativePush()); // A tap on the status bar scrolls the list under it back to the top, as in // every iOS app (the status-bar plugin fires statusTap on iOS). diff --git a/client/src/native/push.test.ts b/client/src/native/push.test.ts new file mode 100644 index 000000000..ff903e8a0 --- /dev/null +++ b/client/src/native/push.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { hasNativeFeature } from './capabilities'; +import { isNativeIos } from './platform'; +import { + NativePushDenied, + forgetNativePush, + nativePushActive, + nativePushEndpoint, + openPushTarget, + registerNativePush, + startNativePush, +} from './push'; +import { TrekShell } from './trekShell'; + +vi.mock('./platform', () => ({ isNativeIos: vi.fn() })); +vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn() })); +vi.mock('./trekShell', () => ({ + TrekShell: { + registerPush: vi.fn(), + takeOpenedPush: vi.fn(), + addListener: vi.fn(async () => ({ remove: async () => {} })), + }, +})); + +const SUB = { + endpoint: 'https://push.liketrek.com/v1/apns/production/abcd', + keys: { p256dh: 'BPk', auth: 'aa' }, +}; + +beforeEach(() => { + window.localStorage.clear(); + vi.mocked(isNativeIos).mockReturnValue(true); + vi.mocked(hasNativeFeature).mockReturnValue(true); + vi.mocked(TrekShell.registerPush).mockReset().mockResolvedValue(SUB); + vi.mocked(TrekShell.takeOpenedPush).mockReset().mockResolvedValue({ url: null }); + window.history.replaceState({}, '', '/dashboard'); +}); + +describe('native push', () => { + it('is on only in an iOS binary that has it', () => { + expect(nativePushActive()).toBe(true); + vi.mocked(hasNativeFeature).mockReturnValue(false); + expect(nativePushActive()).toBe(false); + }); + + it('remembers the relay endpoint it subscribed with, and forgets it again', async () => { + expect(await registerNativePush()).toEqual(SUB); + expect(nativePushEndpoint()).toBe(SUB.endpoint); + forgetNativePush(); + expect(nativePushEndpoint()).toBeNull(); + }); + + it('tells a refused permission apart from a failure', async () => { + vi.mocked(TrekShell.registerPush).mockRejectedValue({ code: 'DENIED' }); + await expect(registerNativePush()).rejects.toBeInstanceOf(NativePushDenied); + vi.mocked(TrekShell.registerPush).mockRejectedValue(new Error('apns down')); + await expect(registerNativePush()).rejects.toThrow('apns down'); + expect(nativePushEndpoint()).toBeNull(); + }); + + it('opens a tapped notification inside the app, and never another origin', () => { + const pops = vi.fn(); + window.addEventListener('popstate', pops); + openPushTarget('/trips/12?tab=chat'); + expect(window.location.pathname).toBe('/trips/12'); + expect(window.location.search).toBe('?tab=chat'); + openPushTarget('https://evil.example/phish'); + expect(window.location.pathname).toBe('/trips/12'); + expect(pops).toHaveBeenCalledTimes(1); + window.removeEventListener('popstate', pops); + }); + + it('follows the notification the app was opened from', async () => { + vi.mocked(TrekShell.takeOpenedPush).mockResolvedValue({ url: '/trips/3' }); + await startNativePush(); + expect(window.location.pathname).toBe('/trips/3'); + expect(TrekShell.addListener).toHaveBeenCalledWith('pushOpened', expect.any(Function)); + }); +}); diff --git a/client/src/native/push.ts b/client/src/native/push.ts new file mode 100644 index 000000000..628d125df --- /dev/null +++ b/client/src/native/push.ts @@ -0,0 +1,84 @@ +import type { PushSubscriptionInput } from '@trek/shared'; +import { hasNativeFeature } from './capabilities'; +import { isNativeIos } from './platform'; +import { reportNativeFailure } from './reportFailure'; +import { TrekShell } from './trekShell'; + +/** + * Push inside the app: the same Web Push subscription a browser makes, only + * the app makes it (TrekShell.registerPush). Its endpoint is liketrek's relay + * for this device's APNs token and its keys live on the device, so the server + * encrypts exactly as for a browser and the relay hands on ciphertext that the + * app's notification extension decrypts. push/webPush.ts takes this path + * wherever the binary reports the nativePush feature; the settings card, the + * test push and the logout teardown stay what they are. + * + * The endpoint is remembered on the device, the way a browser remembers its + * subscription, so the card can say on or off and the logout can name it. + */ +const KEY = 'trek.nativePushEndpoint'; + +export function nativePushActive(): boolean { + return isNativeIos() && hasNativeFeature('nativePush'); +} + +export function nativePushEndpoint(): string | null { + try { + return window.localStorage.getItem(KEY); + } catch { + return null; + } +} + +function rememberEndpoint(endpoint: string | null): void { + try { + if (endpoint) window.localStorage.setItem(KEY, endpoint); + else window.localStorage.removeItem(KEY); + } catch { + // Storage refused: the card then reads off and offers to switch it on again. + } +} + +export class NativePushDenied extends Error {} + +/** Permission, APNs and the device keys, as a subscription the server can store. */ +export async function registerNativePush(): Promise { + try { + const subscription = await TrekShell.registerPush(); + rememberEndpoint(subscription.endpoint); + return subscription; + } catch (err) { + if ((err as { code?: string } | null)?.code === 'DENIED') throw new NativePushDenied(); + throw err; + } +} + +export function forgetNativePush(): void { + rememberEndpoint(null); +} + +/** Opens the place a tapped notification points at, inside the app's own routing. */ +export function openPushTarget(url: string): void { + let target: URL; + try { + target = new URL(url, window.location.origin); + } catch { + return; + } + // Only this server's pages: a notification never navigates the app elsewhere. + if (target.origin !== window.location.origin) return; + window.history.pushState({}, '', `${target.pathname}${target.search}${target.hash}`); + window.dispatchEvent(new PopStateEvent('popstate')); +} + +/** Follows taps on notifications, including the one the app was opened from. */ +export async function startNativePush(): Promise { + if (!nativePushActive()) return; + void TrekShell.addListener('pushOpened', ({ url }) => openPushTarget(url)); + try { + const { url } = await TrekShell.takeOpenedPush(); + if (url) openPushTarget(url); + } catch (err) { + reportNativeFailure(err); + } +} diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 097323b60..3d2d044e7 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -55,6 +55,14 @@ export interface TrekShellPlugin { ): Promise<{ remove: () => Promise }>; /** Feature sharedFiles: the booking document shared to TREK before the UI ran. */ takeSharedFile(): Promise<{ file: SharedFilePayload | null }>; + /** Feature nativePush: permission, APNs and the device keys, as a Web Push subscription. Rejects with DENIED. */ + registerPush(): Promise<{ endpoint: string; keys: { p256dh: string; auth: string } }>; + /** Feature nativePush: the notification the app was opened from, before the UI ran. */ + takeOpenedPush(): Promise<{ url: string | null }>; + addListener( + event: 'pushOpened', + listener: (payload: { url: string }) => void + ): Promise<{ remove: () => Promise }>; } /** A document shared to TREK, as the native side reads it. */ diff --git a/client/src/push/webPush.native.test.ts b/client/src/push/webPush.native.test.ts new file mode 100644 index 000000000..5119fbc9d --- /dev/null +++ b/client/src/push/webPush.native.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { pushApi } from '../api/push'; +import { + NativePushDenied, + forgetNativePush, + nativePushActive, + nativePushEndpoint, + registerNativePush, +} from '../native/push'; +import { + WebPushError, + disableWebPush, + enableWebPush, + forgetPushDeviceOnLogout, + getWebPushSupport, + readWebPushDeviceState, +} from './webPush'; + +vi.mock('../native/push', () => { + class NativePushDenied extends Error {} + return { + NativePushDenied, + nativePushActive: vi.fn(), + nativePushEndpoint: vi.fn(), + registerNativePush: vi.fn(), + forgetNativePush: vi.fn(), + }; +}); +vi.mock('../api/push', () => ({ pushApi: { subscribe: vi.fn(), unsubscribe: vi.fn(), getPublicKey: vi.fn() } })); + +const SUB = { endpoint: 'https://push.liketrek.com/v1/apns/production/abcd', keys: { p256dh: 'BPk', auth: 'aa' } }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(nativePushActive).mockReturnValue(true); + vi.mocked(nativePushEndpoint).mockReturnValue(null); + vi.mocked(registerNativePush).mockResolvedValue(SUB); + vi.mocked(pushApi.subscribe).mockResolvedValue(undefined as never); + vi.mocked(pushApi.unsubscribe).mockResolvedValue(undefined as never); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })) + ); +}); + +describe('web push inside the app', () => { + it('counts as supported without a service worker, and reads on or off from the remembered endpoint', async () => { + expect(getWebPushSupport()).toEqual({ supported: true, reason: null }); + expect(await readWebPushDeviceState()).toBe('off'); + vi.mocked(nativePushEndpoint).mockReturnValue(SUB.endpoint); + expect(await readWebPushDeviceState()).toBe('on'); + }); + + it('switches on with the app subscription, and rolls it back when the server refuses', async () => { + await enableWebPush('server-key'); + expect(pushApi.subscribe).toHaveBeenCalledWith(SUB); + + vi.mocked(pushApi.subscribe).mockRejectedValueOnce(new Error('400')); + await expect(enableWebPush('server-key')).rejects.toBeInstanceOf(WebPushError); + expect(forgetNativePush).toHaveBeenCalled(); + }); + + it('reads a refused permission as denied', async () => { + vi.mocked(registerNativePush).mockRejectedValue(new NativePushDenied()); + await expect(enableWebPush('server-key')).rejects.toMatchObject({ code: 'denied' }); + }); + + it('switches off and forgets the device at logout through the endpoint it remembers', async () => { + vi.mocked(nativePushEndpoint).mockReturnValue(SUB.endpoint); + await disableWebPush(); + expect(pushApi.unsubscribe).toHaveBeenCalledWith(SUB.endpoint); + expect(forgetNativePush).toHaveBeenCalled(); + + await forgetPushDeviceOnLogout(500); + expect(fetch).toHaveBeenCalledWith( + '/api/notifications/push/subscriptions', + expect.objectContaining({ method: 'DELETE' }) + ); + }); +}); diff --git a/client/src/push/webPush.ts b/client/src/push/webPush.ts index b394cb810..c470be6d1 100644 --- a/client/src/push/webPush.ts +++ b/client/src/push/webPush.ts @@ -2,6 +2,7 @@ import type { PushSubscriptionInput, PushUnsubscribeRequest } from '@trek/shared import { pushApi } from '../api/push' import { isEffectivelyOffline } from '../sync/networkMode' import { isIosDevice, isIosStandalone } from '../utils/iosDevice' +import { NativePushDenied, forgetNativePush, nativePushActive, nativePushEndpoint, registerNativePush } from '../native/push' /** * Web Push on this device: whether it can work here, and switching it on and @@ -72,6 +73,9 @@ function hasPushApis(): boolean { */ export function getWebPushSupport(): WebPushSupport { if (typeof window === 'undefined') return unsupported('unsupported') + // Inside the app the app makes the subscription (native/push.ts), with no + // service worker and no Notification API in the WebView. + if (nativePushActive()) return { supported: true, reason: null } // A service worker, and push with it, exists only in a secure context: HTTPS, // or localhost while developing. A plain http LAN address never gets one. if (window.isSecureContext === false) return unsupported('insecure') @@ -175,6 +179,7 @@ export async function loadPushPublicKey(): Promise { /** What the settings card shows for this device. Never rejects. */ export async function readWebPushDeviceState(): Promise { + if (nativePushActive()) return nativePushEndpoint() ? 'on' : 'off' const { reason } = getWebPushSupport() if (reason) return reason if (getPushPermission() === 'denied') return 'denied' @@ -215,6 +220,7 @@ async function subscribeWithKey( * spend it. The permission request is therefore the first thing that runs. */ export async function enableWebPush(publicKey: string, registration?: ServiceWorkerRegistration | null): Promise { + if (nativePushActive()) return enableNativePush() const permission = Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission() if (permission === 'denied') throw new WebPushError('denied') @@ -247,6 +253,12 @@ export async function enableWebPush(publicKey: string, registration?: ServiceWor * push service answers 404 or 410 for on its next send. */ export async function disableWebPush(): Promise { + if (nativePushActive()) { + const endpoint = nativePushEndpoint() + if (endpoint) await bestEffort('forgetting the device on the server', () => pushApi.unsubscribe(endpoint)) + forgetNativePush() + return + } const subscription = await currentPushSubscription() if (!subscription) return await bestEffort('forgetting the device on the server', () => pushApi.unsubscribe(subscription.endpoint)) @@ -265,6 +277,12 @@ export async function disableWebPush(): Promise { * to switch it on. Never rejects. */ export async function resyncPushSubscription(): Promise { + if (nativePushActive()) { + // The app's subscription carries no server key, so only the row is restored. + if (!nativePushEndpoint() || isEffectivelyOffline()) return + await bestEffort('re-registering this device', async () => pushApi.subscribe(await registerNativePush())) + return + } if (!getWebPushSupport().supported || getPushPermission() !== 'granted' || isEffectivelyOffline()) return await bestEffort('re-registering this device', async () => { const registration = await existingRegistration() @@ -325,6 +343,14 @@ async function forgetOnServerAtLogout(endpoint: string, timeoutMs: number): Prom * or 410. */ export async function forgetPushDeviceOnLogout(timeoutMs = LOGOUT_PUSH_TIMEOUT_MS): Promise { + if (nativePushActive()) { + const endpoint = nativePushEndpoint() + forgetNativePush() + if (endpoint) { + await withTimeout(bestEffort('forgetting this device at logout', () => forgetOnServerAtLogout(endpoint, timeoutMs)), timeoutMs, undefined) + } + return + } if (!hasPushApis()) return const teardown = bestEffort('forgetting this device at logout', async () => { const subscription = await currentPushSubscription() @@ -336,3 +362,19 @@ export async function forgetPushDeviceOnLogout(timeoutMs = LOGOUT_PUSH_TIMEOUT_M }) await withTimeout(teardown, timeoutMs, undefined) } + +/** Push on for the app: its own subscription, then the server, rolled back if the server refuses. */ +async function enableNativePush(): Promise { + let subscription: PushSubscriptionInput + try { + subscription = await registerNativePush() + } catch (err) { + throw new WebPushError(err instanceof NativePushDenied ? 'denied' : 'failed', err) + } + try { + await pushApi.subscribe(subscription) + } catch (err) { + forgetNativePush() + throw err instanceof WebPushError ? err : new WebPushError('failed', err) + } +}