mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: keep iOS push alive when the app isn't opened for a week
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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\(\) \}/);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user