mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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 {
|
enum JmapError: Error {
|
||||||
case noCredentials
|
case noCredentials
|
||||||
|
/// The access token needs a refresh the caller didn't allow.
|
||||||
|
case needsRefresh
|
||||||
case http(Int)
|
case http(Int)
|
||||||
case badResponse
|
case badResponse
|
||||||
}
|
}
|
||||||
@@ -47,11 +49,18 @@ enum Jmap {
|
|||||||
/// access token that is about to expire. A refreshed bundle is written back
|
/// 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
|
/// 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).
|
/// 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 }
|
guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials }
|
||||||
var creds = StoredCredentials(json: json)
|
var creds = StoredCredentials(json: json)
|
||||||
if let token = creds.accessToken {
|
if let token = creds.accessToken {
|
||||||
let nowMs = Date().timeIntervalSince1970 * 1000
|
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,
|
if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000,
|
||||||
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
|
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
|
||||||
let url = URL(string: endpoint) {
|
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 {
|
static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult {
|
||||||
var request = URLRequest(url: apiUrl)
|
var request = URLRequest(url: apiUrl)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import Foundation
|
|||||||
import os
|
import os
|
||||||
import UserNotifications
|
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
|
/// Turns the relay's content-blind push ("New message" plus ids) into a real
|
||||||
/// notification: sender, subject and preview, fetched over JMAP on this
|
/// notification: sender, subject and preview, fetched over JMAP on this
|
||||||
/// device with the account's own credentials. Nothing about the message ever
|
/// device with the account's own credentials. Nothing about the message ever
|
||||||
/// passes through the relay or Apple.
|
/// passes through the relay or Apple.
|
||||||
///
|
///
|
||||||
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
|
/// 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{...}}
|
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
|
||||||
/// <credentialsKey> the account's stored credentials
|
/// <credentialsKey> the account's stored credentials
|
||||||
/// If anything is missing or the server can't be reached, the relay's
|
/// 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]
|
let push = request.content.userInfo["body"] as? [String: Any]
|
||||||
work = Task { [weak self] in
|
work = Task { [weak self] in
|
||||||
let enriched = await NotificationService.enrich(content, push: push)
|
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)
|
self?.finish(enriched)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +62,7 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func sanitize(_ id: String) -> String {
|
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 {
|
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 Foundation
|
||||||
|
import os
|
||||||
import Security
|
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
|
/// Items the app shares with this extension through a keychain access group
|
||||||
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
|
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
|
||||||
/// expo-secure-store: generic passwords whose account and generic attributes
|
/// expo-secure-store: generic passwords whose account and generic attributes
|
||||||
@@ -31,6 +36,38 @@ enum SharedKeychain {
|
|||||||
return item as? Data
|
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]? {
|
static func readJSON(_ key: String) -> [String: Any]? {
|
||||||
guard let data = read(key) else { return nil }
|
guard let data = read(key) else { return nil }
|
||||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
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
|
// 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
|
// before iOS shows the notification. Its sources live next to this file and
|
||||||
// are copied into ios/ at prebuild, like everything else in ios/.
|
// 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):
|
// Options (app.config.js):
|
||||||
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
|
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
|
||||||
@@ -20,7 +25,10 @@
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const {
|
const {
|
||||||
|
IOSConfig,
|
||||||
|
withAppDelegate,
|
||||||
withEntitlementsPlist,
|
withEntitlementsPlist,
|
||||||
|
withInfoPlist,
|
||||||
withXcodeProject,
|
withXcodeProject,
|
||||||
withDangerousMod,
|
withDangerousMod,
|
||||||
createRunOncePlugin,
|
createRunOncePlugin,
|
||||||
@@ -28,7 +36,17 @@ const {
|
|||||||
|
|
||||||
const TARGET = 'NotificationService';
|
const TARGET = 'NotificationService';
|
||||||
const DEPLOYMENT_TARGET = '15.1';
|
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() {
|
function sharedGroupSuffix() {
|
||||||
return 'org.bulwarkmail.shared';
|
return 'org.bulwarkmail.shared';
|
||||||
@@ -111,11 +129,70 @@ function withExtensionFiles(config) {
|
|||||||
infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
|
infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist());
|
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;
|
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 }) {
|
function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
|
||||||
if (!appProfile && !extensionProfile) return;
|
if (!appProfile && !extensionProfile) return;
|
||||||
const project = cfg.modResults;
|
const project = cfg.modResults;
|
||||||
@@ -137,6 +214,7 @@ function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
|
|||||||
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
|
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
|
||||||
return withXcodeProject(config, (cfg) => {
|
return withXcodeProject(config, (cfg) => {
|
||||||
addExtensionTarget(cfg, { teamId });
|
addExtensionTarget(cfg, { teamId });
|
||||||
|
addAppSources(cfg);
|
||||||
setReleaseProfiles(cfg, { appProfile, extensionProfile });
|
setReleaseProfiles(cfg, { appProfile, extensionProfile });
|
||||||
return cfg;
|
return cfg;
|
||||||
});
|
});
|
||||||
@@ -189,7 +267,10 @@ function withIosPush(config, options = {}) {
|
|||||||
config = withAppEntitlements(config, { apsEnvironment });
|
config = withAppEntitlements(config, { apsEnvironment });
|
||||||
config = withExtensionFiles(config);
|
config = withExtensionFiles(config);
|
||||||
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
|
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
|
||||||
|
config = withRenewal(config, { teamId });
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');
|
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.
|
* group account that has this account's id.
|
||||||
*/
|
*/
|
||||||
label?: string;
|
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> {
|
export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise<void> {
|
||||||
|
|||||||
@@ -1073,9 +1073,32 @@ export async function shareAccountWithExtension(
|
|||||||
credentialsKey: credentialsKey(accountId),
|
credentialsKey: credentialsKey(accountId),
|
||||||
apiUrl,
|
apiUrl,
|
||||||
...(username ? { label: username } : {}),
|
...(username ? { label: username } : {}),
|
||||||
|
...(await sharedSubscription(accountId)),
|
||||||
}).catch(() => undefined);
|
}).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.
|
* Whether a local account (username@host) is the login a relay label names.
|
||||||
* The label is the username the account registered with the relay.
|
* The label is the username the account registered with the relay.
|
||||||
|
|||||||
Reference in New Issue
Block a user