feat(native): push inside the iOS app, through an encrypted relay

The app subscribes to Web Push like a browser, only the app makes the
subscription: it asks for permission, registers with APNs and keeps its own
P-256 key pair and auth secret in a keychain group a notification extension
can share. The endpoint is liketrek's relay for this device's token, so the
server encrypts exactly as for a browser and nobody on the way can read the
notification. The settings card, the test push, sign-in resync and the logout
teardown all take this path where the binary has nativePush; a tapped
notification opens the place it points at, also from a cold start.

The App ID has push enabled, the app carries the aps-environment and
keychain-group entitlements.
This commit is contained in:
Maurice
2026-11-06 13:37:59 +01:00
parent 30158cddc3
commit ad69bbf4a9
10 changed files with 505 additions and 3 deletions
+6
View File
@@ -2,5 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.liketrek.trek.shared</string>
</array>
</dict>
</plist>
+27 -2
View File
@@ -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.
+175 -1
View File
@@ -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: "")
}
}
+1
View File
@@ -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 {
+3
View File
@@ -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).
+79
View File
@@ -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));
});
});
+84
View File
@@ -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<PushSubscriptionInput> {
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<void> {
if (!nativePushActive()) return;
void TrekShell.addListener('pushOpened', ({ url }) => openPushTarget(url));
try {
const { url } = await TrekShell.takeOpenedPush();
if (url) openPushTarget(url);
} catch (err) {
reportNativeFailure(err);
}
}
+8
View File
@@ -55,6 +55,14 @@ export interface TrekShellPlugin {
): Promise<{ remove: () => Promise<void> }>;
/** 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<void> }>;
}
/** A document shared to TREK, as the native side reads it. */
+80
View File
@@ -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' })
);
});
});
+42
View File
@@ -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<string | null> {
/** What the settings card shows for this device. Never rejects. */
export async function readWebPushDeviceState(): Promise<WebPushDeviceState> {
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<void> {
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<void> {
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<void> {
* to switch it on. Never rejects.
*/
export async function resyncPushSubscription(): Promise<void> {
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<void> {
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<void> {
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)
}
}