fix: keep iOS push alive when the app isn't opened for a week

This commit is contained in:
Linus Rath
2026-10-31 15:17:14 +01:00
parent 6a27c59672
commit c704c7e6af
9 changed files with 564 additions and 7 deletions
+71 -1
View File
@@ -17,6 +17,8 @@ struct JmapFetchResult {
enum JmapError: Error {
case noCredentials
/// The access token needs a refresh the caller didn't allow.
case needsRefresh
case http(Int)
case badResponse
}
@@ -47,11 +49,18 @@ enum Jmap {
/// access token that is about to expire. A refreshed bundle is written back
/// to the shared keychain item so the app picks it up (and doesn't refresh
/// again with a refresh token the server has already replaced).
static func authorization(credentialsKey: String) async throws -> String {
///
/// `allowRefresh: false` throws instead of refreshing: the app's foreground
/// renewal leaves that to the app's own code, which runs then too and would
/// race it for the same refresh token.
static func authorization(credentialsKey: String, allowRefresh: Bool = true) async throws -> String {
guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials }
var creds = StoredCredentials(json: json)
if let token = creds.accessToken {
let nowMs = Date().timeIntervalSince1970 * 1000
if !allowRefresh, let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000 {
throw JmapError.needsRefresh
}
if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000,
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
let url = URL(string: endpoint) {
@@ -99,6 +108,67 @@ enum Jmap {
)
}
/// One JMAP request; the method responses as the server sent them.
static func call(apiUrl: URL, authorization: String, using: [String], methodCalls: [[Any]]) async throws -> [[Any]] {
var request = URLRequest(url: apiUrl)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(authorization, forHTTPHeaderField: "Authorization")
let body: [String: Any] = ["using": using, "methodCalls": methodCalls]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let responses = root["methodResponses"] as? [[Any]] else { throw JmapError.badResponse }
return responses
}
struct ListedPushSubscription {
let id: String
let deviceClientId: String?
let expires: String?
}
static let pushSubscriptionProperties = ["id", "deviceClientId", "expires"]
/// Subscription `id` as the PushSubscription/get answer `callId` lists it.
static func listedPushSubscription(_ responses: [[Any]], callId: String, id: String) -> ListedPushSubscription? {
for response in responses where response.count >= 3 {
guard response[0] as? String == "PushSubscription/get", response[2] as? String == callId,
let args = response[1] as? [String: Any],
let list = args["list"] as? [[String: Any]] else { continue }
for item in list where item["id"] as? String == id {
return ListedPushSubscription(
id: id,
deviceClientId: item["deviceClientId"] as? String,
expires: item["expires"] as? String
)
}
}
return nil
}
static func pushSubscription(apiUrl: URL, id: String, authorization: String) async throws -> ListedPushSubscription? {
let methodCalls: [[Any]] = [
["PushSubscription/get", ["ids": [id], "properties": pushSubscriptionProperties], "0"],
]
let responses = try await call(apiUrl: apiUrl, authorization: authorization, using: ["urn:ietf:params:jmap:core"], methodCalls: methodCalls)
return listedPushSubscription(responses, callId: "0", id: id)
}
/// Asks for a later expiry and reads back the one the server settled on:
/// servers clamp it, and the update's answer doesn't say.
static func extendPushSubscription(apiUrl: URL, id: String, expires: String, authorization: String) async throws -> ListedPushSubscription? {
let methodCalls: [[Any]] = [
["PushSubscription/set", ["update": [id: ["expires": expires]]], "0"],
["PushSubscription/get", ["ids": [id], "properties": pushSubscriptionProperties], "1"],
]
let responses = try await call(apiUrl: apiUrl, authorization: authorization, using: ["urn:ietf:params:jmap:core"], methodCalls: methodCalls)
return listedPushSubscription(responses, callId: "1", id: id)
}
static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult {
var request = URLRequest(url: apiUrl)
request.httpMethod = "POST"
+13 -5
View File
@@ -2,16 +2,15 @@ import Foundation
import os
import UserNotifications
/// Ids and steps only, never credentials or message content.
let pushLog = Logger(subsystem: "org.bulwarkmail.mobile.NotificationService", category: "push")
/// Turns the relay's content-blind push ("New message" plus ids) into a real
/// notification: sender, subject and preview, fetched over JMAP on this
/// device with the account's own credentials. Nothing about the message ever
/// passes through the relay or Apple.
///
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl, label}
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl, label,
/// subscriptionId, deviceClientId,
/// subscriptionExpires}
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
/// <credentialsKey> the account's stored credentials
/// If anything is missing or the server can't be reached, the relay's
@@ -38,6 +37,15 @@ final class NotificationService: UNNotificationServiceExtension {
let push = request.content.userInfo["body"] as? [String: Any]
work = Task { [weak self] in
let enriched = await NotificationService.enrich(content, push: push)
// A timeout from here on shows what was fetched.
self?.placeholder = enriched
// Keep the account's subscription alive (PushRenewal.swift). Does
// nothing until it is less than six days from expiring, so this costs
// a request or two about once a day. After the fetch, not beside it:
// both may refresh the same OAuth token.
if let accountId = push?["accountId"] as? String, !accountId.isEmpty {
await PushRenewal.renewIfDue(jmapAccountId: accountId)
}
self?.finish(enriched)
}
}
@@ -54,7 +62,7 @@ final class NotificationService: UNNotificationServiceExtension {
}
static func sanitize(_ id: String) -> String {
String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" })
SharedKeychain.keyPart(id)
}
static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent {
+106
View File
@@ -0,0 +1,106 @@
import Foundation
/// Keeps push alive on iOS. Stalwart (0.16 and 1.0) drops a PushSubscription
/// seven days after its last refresh, and the app's JS refreshes it only when
/// the app starts. On Android a push handled in the background extends it
/// (src/lib/push-background-task.ts extendPushSubscription); on iOS pushes
/// reach the notification extension, not the app's JS, so someone who reads
/// mail from the notifications without opening the app lost push after a
/// week. The same steps run here instead: in the notification extension for
/// the pushed account, and in the app (PushRenewalTask.swift) on foreground
/// and from a background refresh task for every account.
///
/// The app shares each account's subscription in the account's entry
/// (src/lib/ios-push-shared.ts IosPushAccount): subscriptionId,
/// deviceClientId and subscriptionExpires. Only a subscription that is
/// recorded there and has less than six days left (or an unknown expiry) is
/// looked at; then the server's copy is read first, since the record can be
/// older than it, and only this device's own subscription is extended.
enum PushRenewal {
static let accountPrefix = "bulwark_push_account__"
/// BACKGROUND_EXTEND_BELOW_MS in src/lib/push-notifications.ts.
static let extendBelowSeconds: TimeInterval = 6 * 24 * 60 * 60
/// SUBSCRIPTION_EXPIRES_DAYS in src/lib/push-notifications.ts; servers clamp it.
static let wantedSeconds: TimeInterval = 90 * 24 * 60 * 60
static func parseDate(_ value: String?) -> Date? {
guard let value, !value.isEmpty else { return nil }
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value)
}
/// A JMAP UTCDate, without fractional seconds.
static func formatDate(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter.string(from: date)
}
/// pushSubscriptionNeedsExtension in src/lib/push-notifications.ts.
static func needsExtension(_ expires: String?, now: Date = Date()) -> Bool {
guard let at = parseDate(expires) else { return true }
return at.timeIntervalSince(now) < extendBelowSeconds
}
/// A server that reused the id may hand back another device's subscription.
static func isOwn(_ listed: Jmap.ListedPushSubscription, deviceClientId: String?) -> Bool {
guard let theirs = listed.deviceClientId, let ours = deviceClientId else { return true }
return theirs == ours
}
/// The pushed account's subscription, if it is due.
static func renewIfDue(jmapAccountId: String) async {
await renew(key: accountPrefix + SharedKeychain.keyPart(jmapAccountId), allowRefresh: true)
}
/// Every shared account's subscription that is due, one after the other.
static func renewAll(allowRefresh: Bool) async {
for key in SharedKeychain.keys(withPrefix: accountPrefix) {
if Task.isCancelled { return }
await renew(key: key, allowRefresh: allowRefresh)
}
}
/// True when the subscription was extended. Best effort: anything that
/// fails is tried again on the next push, foreground or refresh task.
@discardableResult
static func renew(key: String, allowRefresh: Bool) async -> Bool {
guard let account = SharedKeychain.readJSON(key),
let subscriptionId = account["subscriptionId"] as? String, !subscriptionId.isEmpty,
let credentialsKey = account["credentialsKey"] as? String,
let apiString = account["apiUrl"] as? String,
let apiUrl = URL(string: apiString) else { return false }
guard needsExtension(account["subscriptionExpires"] as? String) else { return false }
let deviceClientId = account["deviceClientId"] as? String
do {
let authorization = try await Jmap.authorization(credentialsKey: credentialsKey, allowRefresh: allowRefresh)
guard let current = try await Jmap.pushSubscription(apiUrl: apiUrl, id: subscriptionId, authorization: authorization),
isOwn(current, deviceClientId: deviceClientId) else {
// Lapsed or revoked: the app's resync tells the two apart.
pushLog.info("subscription \(subscriptionId, privacy: .public) is not on the server")
return false
}
// The recorded expiry was stale: the server's copy has time left.
if let expires = current.expires, !needsExtension(expires) {
record(key: key, account: account, expires: expires)
return false
}
let wanted = formatDate(Date().addingTimeInterval(wantedSeconds))
let extended = try await Jmap.extendPushSubscription(
apiUrl: apiUrl, id: subscriptionId, expires: wanted, authorization: authorization)
if let expires = extended?.expires { record(key: key, account: account, expires: expires) }
pushLog.info("extended subscription \(subscriptionId, privacy: .public)")
return true
} catch {
pushLog.error("renewal failed: \(String(describing: error), privacy: .public)")
return false
}
}
private static func record(key: String, account: [String: Any], expires: String) {
var json = account
json["subscriptionExpires"] = expires
SharedKeychain.update(key, json: json)
}
}
+62
View File
@@ -0,0 +1,62 @@
import BackgroundTasks
import Foundation
import UIKit
/// The app's side of PushRenewal: every shared account's subscription is
/// renewed when the app comes back to the foreground (the JS refresh runs
/// only on a cold start), and from a background app refresh task that iOS
/// runs now and then while the app is not used at all.
///
/// The config plugin (plugins/ios-push/index.js) compiles this into the app,
/// calls register() from AppDelegate's didFinishLaunching (BGTaskScheduler
/// wants its handlers registered before launch ends), and lists the task id
/// in Info.plist (BGTaskSchedulerPermittedIdentifiers, UIBackgroundModes
/// fetch).
enum PushRenewalTask {
static let identifier = "org.bulwarkmail.mobile.push-renewal"
/// A subscription lasts seven days; asking twice a day leaves iOS room to
/// skip runs (it decides when, and whether, the task runs).
static let interval: TimeInterval = 12 * 60 * 60
private static var observers: [NSObjectProtocol] = []
static func register() {
let registered = BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
guard let refresh = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false)
return
}
handle(refresh)
}
if !registered { pushLog.error("push renewal task not registered") }
let center = NotificationCenter.default
observers = [
center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { _ in
// No token refresh here: the app's own code refreshes on foreground.
Task { await PushRenewal.renewAll(allowRefresh: false) }
},
center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in
schedule()
},
]
}
static func schedule() {
let request = BGAppRefreshTaskRequest(identifier: identifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
// The simulator has no background refresh.
pushLog.error("push renewal task not scheduled: \(String(describing: error), privacy: .public)")
}
}
private static func handle(_ task: BGAppRefreshTask) {
schedule()
let work = Task {
await PushRenewal.renewAll(allowRefresh: true)
task.setTaskCompleted(success: !Task.isCancelled)
}
task.expirationHandler = { work.cancel() }
}
}
+37
View File
@@ -1,6 +1,11 @@
import Foundation
import os
import Security
/// Ids and steps only, never credentials or message content. Shared by the
/// notification extension and the app's push renewal (PushRenewal.swift).
let pushLog = os.Logger(subsystem: Bundle.main.bundleIdentifier ?? "org.bulwarkmail.mobile", category: "push")
/// Items the app shares with this extension through a keychain access group
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
/// expo-secure-store: generic passwords whose account and generic attributes
@@ -31,6 +36,38 @@ enum SharedKeychain {
return item as? Data
}
/// SecureStore keys: letters, digits, ".", "-", "_" only. The app builds
/// the same key part (src/lib/ios-push-shared.ts keyPart).
static func keyPart(_ id: String) -> String {
String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" })
}
/// The keys of the shared items whose key starts with `prefix`.
static func keys(withPrefix prefix: String) -> [String] {
guard let group else { return [] }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccessGroup as String: group,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
]
var items: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &items)
guard status == errSecSuccess, let list = items as? [[String: Any]] else {
if status != errSecItemNotFound {
pushLog.error("keychain listing failed: \(status, privacy: .public)")
}
return []
}
return list.compactMap { item in
let account = item[kSecAttrAccount as String]
let key = (account as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? (account as? String)
guard let key, key.hasPrefix(prefix) else { return nil }
return key
}
}
static func readJSON(_ key: String) -> [String: Any]? {
guard let data = read(key) else { return nil }
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
+82 -1
View File
@@ -8,6 +8,11 @@
// only ids; the extension fetches sender and subject over JMAP on the device
// before iOS shows the notification. Its sources live next to this file and
// are copied into ios/ at prebuild, like everything else in ios/.
// - Push renewal (PushRenewal.swift): Stalwart drops a subscription seven days
// after its last refresh. The extension extends the pushed account's, and
// the app (PushRenewalTask.swift, compiled into the app target) every
// account's on foreground and from a background app refresh task, which
// AppDelegate registers at launch and Info.plist permits.
//
// Options (app.config.js):
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
@@ -20,7 +25,10 @@
const fs = require('node:fs');
const path = require('node:path');
const {
IOSConfig,
withAppDelegate,
withEntitlementsPlist,
withInfoPlist,
withXcodeProject,
withDangerousMod,
createRunOncePlugin,
@@ -28,7 +36,17 @@ const {
const TARGET = 'NotificationService';
const DEPLOYMENT_TARGET = '15.1';
const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift'];
const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift'];
// Compiled into the app as well: the renewal on foreground and in the
// background refresh task.
const APP_SOURCE_FILES = ['SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift', 'PushRenewalTask.swift'];
// PushRenewalTask.identifier
const RENEWAL_TASK_ID = 'org.bulwarkmail.mobile.push-renewal';
const RENEWAL_REGISTER_CALL = 'PushRenewalTask.register()';
// Where Expo's AppDelegate.swift hands launch to its superclass; the
// registration has to run before that returns (Expo's Maps plugin uses the
// same anchor).
const APP_DELEGATE_LAUNCH = /^([ \t]*)return super\.application\(\w+, didFinishLaunchingWithOptions: \w+\)/m;
function sharedGroupSuffix() {
return 'org.bulwarkmail.shared';
@@ -111,11 +129,70 @@ function withExtensionFiles(config) {
infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
);
fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist());
const appDir = path.join(cfg.modRequest.platformProjectRoot, cfg.modRequest.projectName);
for (const file of APP_SOURCE_FILES) {
fs.copyFileSync(path.join(__dirname, file), path.join(appDir, file));
}
return cfg;
},
]);
}
/** Compile the renewal sources into the app target. */
function addAppSources(cfg) {
const projectName = cfg.modRequest.projectName;
for (const file of APP_SOURCE_FILES) {
IOSConfig.XcodeUtils.addBuildSourceFileToGroup({
filepath: `${projectName}/${file}`,
groupName: projectName,
project: cfg.modResults,
});
}
}
/**
* The app's Info.plist entries for the renewal: the refresh task id, the
* background mode it needs, and the keychain group SharedKeychain reads
* (the extension's Info.plist names the same group).
*/
function setRenewalInfoPlist(plist, { teamId }) {
const ids = new Set(plist.BGTaskSchedulerPermittedIdentifiers ?? []);
ids.add(RENEWAL_TASK_ID);
plist.BGTaskSchedulerPermittedIdentifiers = [...ids];
const modes = new Set(plist.UIBackgroundModes ?? []);
modes.add('fetch');
plist.UIBackgroundModes = [...modes];
plist.BulwarkKeychainGroup = `${teamId || '$(DEVELOPMENT_TEAM)'}.${sharedGroupSuffix()}`;
return plist;
}
/** AppDelegate.swift with the renewal task registered during launch. */
function addRenewalToAppDelegate(contents) {
if (contents.includes(RENEWAL_REGISTER_CALL)) return contents;
if (!APP_DELEGATE_LAUNCH.test(contents)) {
throw new Error('plugins/ios-push: AppDelegate.swift has no didFinishLaunching return to register push renewal before');
}
return contents.replace(
APP_DELEGATE_LAUNCH,
(line, indent) => `${indent}// plugins/ios-push: BGTaskScheduler handlers must exist before launch ends.\n${indent}${RENEWAL_REGISTER_CALL}\n${line}`,
);
}
function withRenewal(config, { teamId }) {
config = withInfoPlist(config, (cfg) => {
setRenewalInfoPlist(cfg.modResults, { teamId });
return cfg;
});
config = withAppDelegate(config, (cfg) => {
if (cfg.modResults.language !== 'swift') {
throw new Error('plugins/ios-push: push renewal expects a Swift AppDelegate');
}
cfg.modResults.contents = addRenewalToAppDelegate(cfg.modResults.contents);
return cfg;
});
return config;
}
function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
if (!appProfile && !extensionProfile) return;
const project = cfg.modResults;
@@ -137,6 +214,7 @@ function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
return withXcodeProject(config, (cfg) => {
addExtensionTarget(cfg, { teamId });
addAppSources(cfg);
setReleaseProfiles(cfg, { appProfile, extensionProfile });
return cfg;
});
@@ -189,7 +267,10 @@ function withIosPush(config, options = {}) {
config = withAppEntitlements(config, { apsEnvironment });
config = withExtensionFiles(config);
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
config = withRenewal(config, { teamId });
return config;
}
module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');
// For the tests (src/lib/__tests__/ios-push-renewal.test.ts).
module.exports.internals = { addRenewalToAppDelegate, setRenewalInfoPlist, SOURCE_FILES, APP_SOURCE_FILES, RENEWAL_TASK_ID };
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
vi.mock('../ios-push-shared', () => ({
apnsEnvironment: () => 'production',
writeIosPushAccount: vi.fn(async () => undefined),
removeIosPushAccount: vi.fn(async () => undefined),
}));
import { shareAccountWithExtension } from '../push-notifications';
import { writeIosPushAccount } from '../ios-push-shared';
// iOS push lapsed a week after the app was last opened: Stalwart drops a
// subscription seven days after its last refresh, and only the app's JS
// refreshed it. The native side now extends it (plugins/ios-push/
// PushRenewal.swift) from the notification extension, on foreground and from
// a background refresh task, with what the app shares about the subscription.
const ROOT = path.resolve(__dirname, '../../..');
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
describe('the subscription the app shares with the native renewal', () => {
const API = 'https://mail.example.org/jmap/';
const LOCAL = 'ada@mail.example.org';
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
(Platform as { OS: string }).OS = 'ios';
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [LOCAL]: 'c' }));
});
afterEach(() => {
(Platform as { OS: string }).OS = 'android';
});
it('names the subscription id, this device\'s client id and the recorded expiry', async () => {
await AsyncStorage.setItem(`push:subscriptionId:v2:${LOCAL}`, 'b');
await AsyncStorage.setItem(`push:deviceClientId:v2:${LOCAL}`, 'dev-1');
await AsyncStorage.setItem(`push:subscriptionExpires:v1:${LOCAL}`, '2026-11-07T10:00:00Z');
await shareAccountWithExtension(LOCAL, 'c', API, 'ada');
expect(writeIosPushAccount).toHaveBeenCalledWith('c', {
credentialsKey: expect.any(String),
apiUrl: API,
label: 'ada',
subscriptionId: 'b',
deviceClientId: 'dev-1',
subscriptionExpires: '2026-11-07T10:00:00Z',
});
});
it('shares no subscription for an account the user turned push off for', async () => {
await AsyncStorage.setItem(`push:subscriptionId:v2:${LOCAL}`, 'b');
await AsyncStorage.setItem(`push:optedOut:v1:${LOCAL}`, '1');
await shareAccountWithExtension(LOCAL, 'c', API);
expect(writeIosPushAccount).toHaveBeenCalledWith('c', { credentialsKey: expect.any(String), apiUrl: API });
});
});
describe('the config plugin wires the renewal into the app', () => {
const plugin = createRequire(__filename)(path.join(ROOT, 'plugins/ios-push/index.js')) as {
internals: {
addRenewalToAppDelegate(contents: string): string;
setRenewalInfoPlist(plist: Record<string, unknown>, opts: { teamId: string }): Record<string, unknown>;
SOURCE_FILES: string[];
APP_SOURCE_FILES: string[];
RENEWAL_TASK_ID: string;
};
};
const { internals } = plugin;
// Expo SDK 54's AppDelegate.swift, shortened.
const APP_DELEGATE = [
'public class AppDelegate: ExpoAppDelegate {',
' public override func application(',
' _ application: UIApplication,',
' didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil',
' ) -> Bool {',
' bindReactNativeFactory(factory)',
'',
' return super.application(application, didFinishLaunchingWithOptions: launchOptions)',
' }',
'}',
].join('\n');
it('registers the refresh task in didFinishLaunching, before it returns', () => {
const out = internals.addRenewalToAppDelegate(APP_DELEGATE);
const call = out.indexOf(' PushRenewalTask.register()\n');
expect(call).toBeGreaterThan(out.indexOf('bindReactNativeFactory'));
expect(call).toBeLessThan(out.indexOf('return super.application('));
// Once, however often prebuild runs.
expect(internals.addRenewalToAppDelegate(out)).toBe(out);
});
it('fails the prebuild rather than skipping the registration', () => {
expect(() => internals.addRenewalToAppDelegate('class AppDelegate {}')).toThrow(/didFinishLaunching/);
});
it('permits the task, adds the fetch background mode and names the shared keychain group', () => {
const plist = internals.setRenewalInfoPlist({ UIBackgroundModes: ['remote-notification'] }, { teamId: 'TEAM' });
expect(plist.BGTaskSchedulerPermittedIdentifiers).toEqual(['org.bulwarkmail.mobile.push-renewal']);
expect(plist.UIBackgroundModes).toEqual(['remote-notification', 'fetch']);
expect(plist.BulwarkKeychainGroup).toBe('TEAM.org.bulwarkmail.shared');
});
it('compiles the renewal into the extension and the app', () => {
expect(internals.SOURCE_FILES).toContain('PushRenewal.swift');
expect(internals.APP_SOURCE_FILES).toEqual(
expect.arrayContaining(['SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift', 'PushRenewalTask.swift']),
);
expect(internals.APP_SOURCE_FILES).not.toContain('NotificationService.swift');
for (const file of [...internals.SOURCE_FILES, ...internals.APP_SOURCE_FILES]) {
expect(fs.existsSync(path.join(ROOT, 'plugins/ios-push', file))).toBe(true);
}
expect(read('plugins/ios-push/PushRenewalTask.swift')).toContain(`static let identifier = "${internals.RENEWAL_TASK_ID}"`);
});
});
describe('the native renewal', () => {
const renewal = read('plugins/ios-push/PushRenewal.swift');
const task = read('plugins/ios-push/PushRenewalTask.swift');
const nse = read('plugins/ios-push/NotificationService.swift');
const js = read('src/lib/push-notifications.ts');
it('extends at the same point and asks for the same lifetime as the Android push task', () => {
expect(js).toMatch(/const BACKGROUND_EXTEND_BELOW_MS = 6 \* 24 \* 60 \* 60 \* 1000;/);
expect(renewal).toMatch(/static let extendBelowSeconds: TimeInterval = 6 \* 24 \* 60 \* 60\r?\n/);
expect(js).toMatch(/const SUBSCRIPTION_EXPIRES_DAYS = 90;/);
expect(renewal).toMatch(/static let wantedSeconds: TimeInterval = 90 \* 24 \* 60 \* 60\r?\n/);
});
it('reads the server\'s copy first and extends only this device\'s own subscription', () => {
const body = renewal.slice(renewal.indexOf('static func renew(key:'));
const get = body.indexOf('Jmap.pushSubscription(');
const own = body.indexOf('isOwn(current, deviceClientId: deviceClientId)');
const set = body.indexOf('Jmap.extendPushSubscription(');
expect(get).toBeGreaterThan(-1);
expect(own).toBeGreaterThan(get);
expect(set).toBeGreaterThan(own);
});
it('runs from the notification extension after the fetch for the pushed account', () => {
const renew = nse.indexOf('await PushRenewal.renewIfDue(jmapAccountId: accountId)');
expect(renew).toBeGreaterThan(nse.indexOf('await NotificationService.enrich('));
expect(renew).toBeLessThan(nse.indexOf('self?.finish(enriched)'));
});
it('runs from the app on foreground without refreshing tokens, and from a background refresh task', () => {
expect(task).toMatch(/willEnterForegroundNotification[\s\S]*?PushRenewal\.renewAll\(allowRefresh: false\)/);
expect(task).toContain('BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier');
expect(task).toMatch(/BGAppRefreshTaskRequest\(identifier: identifier\)/);
expect(task).toMatch(/task\.expirationHandler = \{ work\.cancel\(\) \}/);
});
});
+12
View File
@@ -69,6 +69,18 @@ export interface IosPushAccount {
* group account that has this account's id.
*/
label?: string;
/**
* This device's PushSubscription on the account's server, so the native
* side keeps it alive (plugins/ios-push/PushRenewal.swift): Stalwart drops
* a subscription seven days after its last refresh, and on iOS the app's JS
* runs only when the app is opened. The native side extends it only while
* the server still lists it with this device's client id, and writes the
* expiry it settled on back here.
*/
subscriptionId?: string;
deviceClientId?: string;
/** When the subscription expires, as last recorded (UTCDate). */
subscriptionExpires?: string;
}
export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise<void> {
+23
View File
@@ -1073,9 +1073,32 @@ export async function shareAccountWithExtension(
credentialsKey: credentialsKey(accountId),
apiUrl,
...(username ? { label: username } : {}),
...(await sharedSubscription(accountId)),
}).catch(() => undefined);
}
/**
* The account's subscription as the iOS native renewal needs it
* (IosPushAccount.subscriptionId). Nothing for an account the user turned
* push off for, or without a recorded subscription: nothing to keep alive.
*/
async function sharedSubscription(
accountId: string,
): Promise<{ subscriptionId?: string; deviceClientId?: string; subscriptionExpires?: string }> {
const [subscriptionId, deviceClientId, expires, optedOut] = await Promise.all([
AsyncStorage.getItem(subscriptionIdKey(accountId)),
AsyncStorage.getItem(deviceClientIdKey(accountId)),
AsyncStorage.getItem(subscriptionExpiresKey(accountId)),
AsyncStorage.getItem(optedOutKey(accountId)),
]);
if (!subscriptionId || optedOut) return {};
return {
subscriptionId,
...(deviceClientId ? { deviceClientId } : {}),
...(expires ? { subscriptionExpires: expires } : {}),
};
}
/**
* Whether a local account (username@host) is the login a relay label names.
* The label is the username the account registered with the relay.