From 6a27c596724fc31e24645ac282c291d72afb19e9 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:10:44 +0100
Subject: [PATCH 1/4] feat: play the Involved sound for new mail
---
.../bulwarkmobile/BulwarkFcmModule.kt | 6 ++-
.../bulwarkmobile/BulwarkMessagingService.kt | 37 ++++++++++++-
android/app/src/main/res/raw/keep.xml | 6 +--
app.config.js | 5 +-
plugins/ios-push/NotificationService.swift | 4 ++
src/lib/__tests__/native-mail-sound.test.ts | 52 +++++++++++++++++++
6 files changed, 102 insertions(+), 8 deletions(-)
create mode 100644 src/lib/__tests__/native-mail-sound.test.ts
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt
index 15eabd80..f3fd4ef1 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt
@@ -153,8 +153,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
}
}
- val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
+ val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.channelId(ctx))
.setSmallIcon(R.drawable.ic_notification)
+ // Android 7 has no channels: the notification names its sound.
+ .setSound(BulwarkMessagingService.soundUri(ctx))
.setLargeIcon(largeIcon)
.setContentTitle(title)
// body is the subject with the "(No subject)" fallback; an empty
@@ -284,7 +286,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
- val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
+ val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.channelId(ctx))
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(groupTitle)
.setContentText(if (count == 1) "1 new message" else "$count new messages")
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt
index 3959e5b6..5ce3a2be 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt
@@ -6,6 +6,8 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
+import android.media.AudioAttributes
+import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
@@ -49,7 +51,10 @@ class BulwarkMessagingService : FirebaseMessagingService() {
}
companion object {
- const val CHANNEL_ID = "bulwark_mail"
+ // Plays the Involved sound; see channelId().
+ const val CHANNEL_ID = "bulwark_mail_involved"
+ // The first mail channel, with the system's default sound.
+ const val LEGACY_CHANNEL_ID = "bulwark_mail"
private const val TAG = "BulwarkPush"
private const val START_WAKE_LOCK_MS = 10_000L
@@ -103,10 +108,39 @@ class BulwarkMessagingService : FirebaseMessagingService() {
startWakeLock = null
}
+ /**
+ * The channel new-mail notifications go to. A channel's sound can't
+ * change after it is created, so the Involved sound (the webmail's
+ * default, lib/notification-sound.ts) needs a channel of its own; the
+ * old one stays as the user left it. Someone who turned mail
+ * notifications off in the old channel keeps them off: their
+ * notifications keep going there.
+ */
+ fun channelId(context: Context): String {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return CHANNEL_ID
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val legacy = manager.getNotificationChannel(LEGACY_CHANNEL_ID)
+ return if (legacy != null && legacy.importance == NotificationManager.IMPORTANCE_NONE) {
+ LEGACY_CHANNEL_ID
+ } else {
+ CHANNEL_ID
+ }
+ }
+
+ /** The Involved sound (res/raw, kept from the shrinker by res/raw/keep.xml). */
+ fun soundUri(context: Context): Uri =
+ Uri.parse("android.resource://${context.packageName}/raw/involved_notification")
+
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (channelId(context) != CHANNEL_ID) return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
+ val sound = soundUri(context)
+ val attributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
val channel = NotificationChannel(
CHANNEL_ID,
"Mail notifications",
@@ -114,6 +148,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
).apply {
description = "Incoming email alerts"
enableVibration(true)
+ setSound(sound, attributes)
}
manager.createNotificationChannel(channel)
}
diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml
index 865f0412..c1083c93 100644
--- a/android/app/src/main/res/raw/keep.xml
+++ b/android/app/src/main/res/raw/keep.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/app.config.js b/app.config.js
index ee306356..aad9b209 100644
--- a/app.config.js
+++ b/app.config.js
@@ -106,8 +106,9 @@ module.exports = {
},
],
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
- // Bundles the Involved sound calendar reminders play on iOS
- // (src/lib/calendar-notifications.ts). Android has it in res/raw.
+ // Bundles the Involved sound calendar reminders (src/lib/calendar-
+ // notifications.ts) and new mail (plugins/ios-push) play on iOS.
+ // Android has it in res/raw.
[
'expo-notifications',
{
diff --git a/plugins/ios-push/NotificationService.swift b/plugins/ios-push/NotificationService.swift
index 0557455f..e639d372 100644
--- a/plugins/ios-push/NotificationService.swift
+++ b/plugins/ios-push/NotificationService.swift
@@ -30,6 +30,10 @@ final class NotificationService: UNNotificationServiceExtension {
contentHandler(request.content)
return
}
+ // The webmail's default new-mail sound. The app bundles the file
+ // (expo-notifications in app.config.js); iOS looks it up in the app's
+ // bundle, so the placeholder plays it too.
+ content.sound = UNNotificationSound(named: UNNotificationSoundName("involved_notification.wav"))
placeholder = content
let push = request.content.userInfo["body"] as? [String: Any]
work = Task { [weak self] in
diff --git a/src/lib/__tests__/native-mail-sound.test.ts b/src/lib/__tests__/native-mail-sound.test.ts
new file mode 100644
index 00000000..c84757d8
--- /dev/null
+++ b/src/lib/__tests__/native-mail-sound.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+
+// New mail plays the Involved sound, the webmail's default
+// (lib/notification-sound.ts). Native code, so these read the sources: on
+// Android a channel of its own (a channel's sound is fixed once created), on
+// iOS the notification service extension names the bundled file.
+
+const ROOT = path.resolve(__dirname, '../../..');
+const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
+const KOTLIN = 'android/app/src/main/java/com/anonymous/bulwarkmobile';
+
+describe('Involved sound for new mail', () => {
+ const service = read(`${KOTLIN}/BulwarkMessagingService.kt`);
+ const fcm = read(`${KOTLIN}/BulwarkFcmModule.kt`);
+
+ it('creates a new Android channel with the Involved sound and keeps the old id', () => {
+ expect(service).toMatch(/const val CHANNEL_ID = "bulwark_mail_involved"/);
+ expect(service).toMatch(/const val LEGACY_CHANNEL_ID = "bulwark_mail"/);
+ expect(service).toContain('android.resource://${context.packageName}/raw/involved_notification');
+ expect(service).toMatch(/setSound\(sound, attributes\)/);
+ expect(service).toContain('AudioAttributes.USAGE_NOTIFICATION');
+ });
+
+ it('keeps using the old channel when the user turned it off', () => {
+ const fn = service.slice(service.indexOf('fun channelId('), service.indexOf('fun soundUri('));
+ expect(fn).toContain('getNotificationChannel(LEGACY_CHANNEL_ID)');
+ expect(fn).toContain('IMPORTANCE_NONE');
+ // No new channel is created for such a user.
+ const ensure = service.slice(service.indexOf('fun ensureChannel('));
+ expect(ensure).toMatch(/if \(channelId\(context\) != CHANNEL_ID\) return/);
+ });
+
+ it('posts mail notifications to the channel channelId() picks', () => {
+ expect(fcm).not.toContain('BulwarkMessagingService.CHANNEL_ID');
+ expect(fcm.match(/NotificationCompat\.Builder\(ctx, BulwarkMessagingService\.channelId\(ctx\)\)/g)).toHaveLength(2);
+ });
+
+ it('ships the sound on both platforms', () => {
+ expect(fs.existsSync(path.join(ROOT, 'android/app/src/main/res/raw/involved_notification.mp3'))).toBe(true);
+ expect(fs.existsSync(path.join(ROOT, 'assets/sounds/involved_notification.wav'))).toBe(true);
+ expect(read('app.config.js')).toContain("sounds: ['./assets/sounds/involved_notification.wav']");
+ });
+
+ it('names the bundled sound in the iOS notification extension, placeholder included', () => {
+ const nse = read('plugins/ios-push/NotificationService.swift');
+ const sound = nse.indexOf('content.sound = UNNotificationSound(named: UNNotificationSoundName("involved_notification.wav"))');
+ expect(sound).toBeGreaterThan(-1);
+ expect(sound).toBeLessThan(nse.indexOf('placeholder = content'));
+ });
+});
From c704c7e6af48514ca5638d40dce94343a9535b44 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:17:14 +0100
Subject: [PATCH 2/4] fix: keep iOS push alive when the app isn't opened for a
week
---
plugins/ios-push/Jmap.swift | 72 +++++++++-
plugins/ios-push/NotificationService.swift | 18 ++-
plugins/ios-push/PushRenewal.swift | 106 ++++++++++++++
plugins/ios-push/PushRenewalTask.swift | 62 ++++++++
plugins/ios-push/SharedKeychain.swift | 37 +++++
plugins/ios-push/index.js | 83 ++++++++++-
src/lib/__tests__/ios-push-renewal.test.ts | 158 +++++++++++++++++++++
src/lib/ios-push-shared.ts | 12 ++
src/lib/push-notifications.ts | 23 +++
9 files changed, 564 insertions(+), 7 deletions(-)
create mode 100644 plugins/ios-push/PushRenewal.swift
create mode 100644 plugins/ios-push/PushRenewalTask.swift
create mode 100644 src/lib/__tests__/ios-push-renewal.test.ts
diff --git a/plugins/ios-push/Jmap.swift b/plugins/ios-push/Jmap.swift
index 6f1ed71e..5cb6fa60 100644
--- a/plugins/ios-push/Jmap.swift
+++ b/plugins/ios-push/Jmap.swift
@@ -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"
diff --git a/plugins/ios-push/NotificationService.swift b/plugins/ios-push/NotificationService.swift
index e639d372..6dbd518e 100644
--- a/plugins/ios-push/NotificationService.swift
+++ b/plugins/ios-push/NotificationService.swift
@@ -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__ {credentialsKey, apiUrl, label}
+/// bulwark_push_account__ {credentialsKey, apiUrl, label,
+/// subscriptionId, deviceClientId,
+/// subscriptionExpires}
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
/// 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 {
diff --git a/plugins/ios-push/PushRenewal.swift b/plugins/ios-push/PushRenewal.swift
new file mode 100644
index 00000000..68ac8523
--- /dev/null
+++ b/plugins/ios-push/PushRenewal.swift
@@ -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)
+ }
+}
diff --git a/plugins/ios-push/PushRenewalTask.swift b/plugins/ios-push/PushRenewalTask.swift
new file mode 100644
index 00000000..0c9cbd9f
--- /dev/null
+++ b/plugins/ios-push/PushRenewalTask.swift
@@ -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() }
+ }
+}
diff --git a/plugins/ios-push/SharedKeychain.swift b/plugins/ios-push/SharedKeychain.swift
index 77668029..bc68fd30 100644
--- a/plugins/ios-push/SharedKeychain.swift
+++ b/plugins/ios-push/SharedKeychain.swift
@@ -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]
diff --git a/plugins/ios-push/index.js b/plugins/ios-push/index.js
index 2ce065de..4dcfb573 100644
--- a/plugins/ios-push/index.js
+++ b/plugins/ios-push/index.js
@@ -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 };
diff --git a/src/lib/__tests__/ios-push-renewal.test.ts b/src/lib/__tests__/ios-push-renewal.test.ts
new file mode 100644
index 00000000..8b04a8b4
--- /dev/null
+++ b/src/lib/__tests__/ios-push-renewal.test.ts
@@ -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, opts: { teamId: string }): Record;
+ 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\(\) \}/);
+ });
+});
diff --git a/src/lib/ios-push-shared.ts b/src/lib/ios-push-shared.ts
index 2f28e57e..d33c97f1 100644
--- a/src/lib/ios-push-shared.ts
+++ b/src/lib/ios-push-shared.ts
@@ -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 {
diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts
index 1993c44e..d83b4807 100644
--- a/src/lib/push-notifications.ts
+++ b/src/lib/push-notifications.ts
@@ -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.
From ba150060cfa7511b9784aaba30af6969cac89e4b Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:22:32 +0100
Subject: [PATCH 3/4] feat: use TLS client certificates on iOS #3
---
app.config.js | 2 +
plugins/ios-app-sources.js | 34 ++
plugins/ios-client-cert/BulwarkClientCert.m | 13 +
.../ios-client-cert/BulwarkClientCert.swift | 350 ++++++++++++++++++
plugins/ios-client-cert/index.js | 18 +
src/lib/__tests__/client-cert-ios.test.ts | 67 ++++
src/lib/client-cert.ts | 28 +-
7 files changed, 500 insertions(+), 12 deletions(-)
create mode 100644 plugins/ios-app-sources.js
create mode 100644 plugins/ios-client-cert/BulwarkClientCert.m
create mode 100644 plugins/ios-client-cert/BulwarkClientCert.swift
create mode 100644 plugins/ios-client-cert/index.js
create mode 100644 src/lib/__tests__/client-cert-ios.test.ts
diff --git a/app.config.js b/app.config.js
index aad9b209..0bc1c3e7 100644
--- a/app.config.js
+++ b/app.config.js
@@ -106,6 +106,8 @@ module.exports = {
},
],
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
+ // TLS client certificates (#3): the iOS side of src/lib/client-cert.ts.
+ './plugins/ios-client-cert',
// Bundles the Involved sound calendar reminders (src/lib/calendar-
// notifications.ts) and new mail (plugins/ios-push) play on iOS.
// Android has it in res/raw.
diff --git a/plugins/ios-app-sources.js b/plugins/ios-app-sources.js
new file mode 100644
index 00000000..98b7cf48
--- /dev/null
+++ b/plugins/ios-app-sources.js
@@ -0,0 +1,34 @@
+// Compiles sources that live next to a config plugin into the iOS app target:
+// copied into ios// at prebuild (ios/ is generated, never edited by
+// hand) and added to the app's Sources build phase.
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { IOSConfig, withDangerousMod, withXcodeProject } = require('@expo/config-plugins');
+
+function withAppSources(config, { dir, files }) {
+ config = withDangerousMod(config, [
+ 'ios',
+ async (cfg) => {
+ const appDir = path.join(cfg.modRequest.platformProjectRoot, cfg.modRequest.projectName);
+ for (const file of files) {
+ fs.copyFileSync(path.join(dir, file), path.join(appDir, file));
+ }
+ return cfg;
+ },
+ ]);
+ config = withXcodeProject(config, (cfg) => {
+ const projectName = cfg.modRequest.projectName;
+ for (const file of files) {
+ IOSConfig.XcodeUtils.addBuildSourceFileToGroup({
+ filepath: `${projectName}/${file}`,
+ groupName: projectName,
+ project: cfg.modResults,
+ });
+ }
+ return cfg;
+ });
+ return config;
+}
+
+module.exports = { withAppSources };
diff --git a/plugins/ios-client-cert/BulwarkClientCert.m b/plugins/ios-client-cert/BulwarkClientCert.m
new file mode 100644
index 00000000..202647f9
--- /dev/null
+++ b/plugins/ios-client-cert/BulwarkClientCert.m
@@ -0,0 +1,13 @@
+// Exposes BulwarkClientCert.swift to React Native as NativeModules.BulwarkClientCert,
+// with the methods of Android's BulwarkClientCertModule.kt.
+
+#import
+
+@interface RCT_EXTERN_MODULE(BulwarkClientCert, NSObject)
+
+RCT_EXTERN_METHOD(getAlias:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(clearAlias:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(pickAlias:(NSString *)host resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(fetchSecure:(NSDictionary *)request resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+
+@end
diff --git a/plugins/ios-client-cert/BulwarkClientCert.swift b/plugins/ios-client-cert/BulwarkClientCert.swift
new file mode 100644
index 00000000..ce05e101
--- /dev/null
+++ b/plugins/ios-client-cert/BulwarkClientCert.swift
@@ -0,0 +1,350 @@
+import Foundation
+import React
+import Security
+import UIKit
+import UniformTypeIdentifiers
+
+/// TLS client certificates on iOS (#3): the counterpart of Android's
+/// BulwarkClientCertModule.kt, with the same methods and results, so
+/// src/lib/client-cert.ts drives both.
+///
+/// pickAlias(host) the user picks a .p12/.pfx file in Files and types its
+/// password; the identity goes into the app's keychain.
+/// Resolves with the certificate's name, or null when the
+/// user cancels.
+/// getAlias() that name, or null.
+/// clearAlias() forgets the certificate and removes it from the keychain.
+/// fetchSecure(r) one request over URLSession that answers the server's
+/// client-certificate challenge with the identity.
+///
+/// iOS has no store of client certificates that apps can pick from, like
+/// Android's KeyChain: identities installed with a configuration profile are
+/// only reachable by Apple's own apps. So the user brings the file.
+///
+/// React Native's own fetch can't answer the challenge: its URLSession
+/// delegate (RCTHTTPRequestHandler) takes the default handling, and the
+/// configuration hook RN offers can't add a delegate. Hence fetchSecure, as
+/// on Android, which src/lib/client-cert.ts routes every request through once
+/// a certificate is set.
+@objc(BulwarkClientCert)
+final class BulwarkClientCert: NSObject, UIDocumentPickerDelegate {
+ @objc static func requiresMainQueueSetup() -> Bool { false }
+
+ private var pending: (resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock, host: String?)?
+
+ // MARK: alias management
+
+ @objc(getAlias:rejecter:)
+ func getAlias(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
+ resolve(ClientIdentityStore.alias)
+ }
+
+ @objc(clearAlias:rejecter:)
+ func clearAlias(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
+ ClientIdentityStore.clear()
+ SecureSession.reset()
+ resolve(nil)
+ }
+
+ @objc(pickAlias:resolver:rejecter:)
+ func pickAlias(
+ _ host: String?,
+ resolver resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock
+ ) {
+ DispatchQueue.main.async {
+ guard let presenter = RCTPresentedViewController() else {
+ reject("no_activity", "No screen to show the file picker on", nil)
+ return
+ }
+ if let previous = self.pending {
+ previous.resolve(nil)
+ }
+ self.pending = (resolve, reject, host)
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.pkcs12], asCopy: true)
+ picker.delegate = self
+ picker.allowsMultipleSelection = false
+ presenter.present(picker, animated: true)
+ }
+ }
+
+ func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
+ settle { resolve, _ in resolve(nil) }
+ }
+
+ func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
+ guard let url = urls.first, let data = try? Data(contentsOf: url) else {
+ settle { _, reject in reject("pick_failed", "The certificate file could not be read", nil) }
+ return
+ }
+ try? FileManager.default.removeItem(at: url)
+ // The picker may still be on its way out; an alert presented over it
+ // would not show.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
+ self.askPassword(for: data, wrongPassword: false)
+ }
+ }
+
+ /// Asks for the file's password; again after a wrong one.
+ private func askPassword(for data: Data, wrongPassword: Bool) {
+ guard let presenter = RCTPresentedViewController() else {
+ settle { _, reject in reject("no_activity", "No screen to ask for the password on", nil) }
+ return
+ }
+ let host = pending?.host
+ let message = wrongPassword
+ ? "That password is wrong. Try again."
+ : (host.map { "The certificate for \($0) is protected by a password." } ?? "The certificate is protected by a password.")
+ let alert = UIAlertController(title: "Certificate password", message: message, preferredStyle: .alert)
+ alert.addTextField { field in
+ field.isSecureTextEntry = true
+ field.textContentType = .password
+ }
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
+ self.settle { resolve, _ in resolve(nil) }
+ })
+ alert.addAction(UIAlertAction(title: "Import", style: .default) { [weak alert] _ in
+ let password = alert?.textFields?.first?.text ?? ""
+ switch ClientIdentityStore.importPKCS12(data, password: password) {
+ case .success(let alias):
+ SecureSession.reset()
+ self.settle { resolve, _ in resolve(alias) }
+ case .wrongPassword:
+ self.askPassword(for: data, wrongPassword: true)
+ case .failure(let message):
+ self.settle { _, reject in reject("pick_failed", message, nil) }
+ }
+ })
+ presenter.present(alert, animated: true)
+ }
+
+ private func settle(_ body: (RCTPromiseResolveBlock, RCTPromiseRejectBlock) -> Void) {
+ guard let current = pending else { return }
+ pending = nil
+ body(current.resolve, current.reject)
+ }
+
+ // MARK: secure fetch
+
+ /// request: {url, method, headers: {k: v}, bodyBase64: string|null, timeoutMs: number}
+ /// Resolves with {status, statusText, headers, bodyBase64}. Redirects come
+ /// back as they are: src/lib/client-cert.ts decides which to follow.
+ @objc(fetchSecure:resolver:rejecter:)
+ func fetchSecure(
+ _ request: NSDictionary,
+ resolver resolve: @escaping RCTPromiseResolveBlock,
+ rejecter reject: @escaping RCTPromiseRejectBlock
+ ) {
+ guard let urlString = request["url"] as? String, !urlString.isEmpty, let url = URL(string: urlString) else {
+ reject("bad_args", "url is required", nil)
+ return
+ }
+ var urlRequest = URLRequest(url: url)
+ urlRequest.httpMethod = ((request["method"] as? String) ?? "GET").uppercased()
+ urlRequest.timeoutInterval = ((request["timeoutMs"] as? NSNumber)?.doubleValue ?? 30_000) / 1000
+ urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
+ if let headers = request["headers"] as? [String: Any] {
+ for (name, value) in headers {
+ // URLSession sets the length from the body itself.
+ if name.caseInsensitiveCompare("Content-Length") == .orderedSame { continue }
+ if let value = value as? String { urlRequest.setValue(value, forHTTPHeaderField: name) }
+ }
+ }
+ if let bodyBase64 = request["bodyBase64"] as? String {
+ guard let body = Data(base64Encoded: bodyBase64) else {
+ reject("bad_args", "bodyBase64 is not base64", nil)
+ return
+ }
+ urlRequest.httpBody = body
+ }
+ SecureSession.shared.dataTask(with: urlRequest) { data, response, error in
+ if let error {
+ reject("fetch_failed", error.localizedDescription, error)
+ return
+ }
+ guard let http = response as? HTTPURLResponse else {
+ reject("fetch_failed", "No HTTP response", nil)
+ return
+ }
+ var headers: [String: String] = [:]
+ for (name, value) in http.allHeaderFields {
+ if let name = name as? String { headers[name] = "\(value)" }
+ }
+ resolve([
+ "status": http.statusCode,
+ "statusText": HTTPURLResponse.localizedString(forStatusCode: http.statusCode),
+ "headers": headers,
+ "bodyBase64": (data ?? Data()).base64EncodedString(),
+ ])
+ }.resume()
+ }
+}
+
+/// The URLSession fetchSecure uses. Replaced when the certificate changes, so
+/// no connection keeps a TLS session made with the old one.
+private enum SecureSession {
+ private static let lock = NSLock()
+ private static var session: URLSession?
+
+ static var shared: URLSession {
+ lock.lock()
+ defer { lock.unlock() }
+ if let session { return session }
+ let config = URLSessionConfiguration.ephemeral
+ config.urlCache = nil
+ let created = URLSession(configuration: config, delegate: SecureSessionDelegate(), delegateQueue: nil)
+ session = created
+ return created
+ }
+
+ static func reset() {
+ lock.lock()
+ let old = session
+ session = nil
+ lock.unlock()
+ old?.finishTasksAndInvalidate()
+ }
+}
+
+private final class SecureSessionDelegate: NSObject, URLSessionTaskDelegate {
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ didReceive challenge: URLAuthenticationChallenge,
+ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
+ ) {
+ guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate,
+ let credential = ClientIdentityStore.credential() else {
+ // Server trust and everything else: the system's own checks.
+ completionHandler(.performDefaultHandling, nil)
+ return
+ }
+ completionHandler(.useCredential, credential)
+ }
+
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ willPerformHTTPRedirection response: HTTPURLResponse,
+ newRequest request: URLRequest,
+ completionHandler: @escaping (URLRequest?) -> Void
+ ) {
+ // Hand the 3xx back; JS follows only same-host hops (mayFollowRedirect).
+ completionHandler(nil)
+ }
+}
+
+enum ImportResult {
+ case success(String)
+ case wrongPassword
+ case failure(String)
+}
+
+/// The picked identity: kept in the app's keychain, found again by its
+/// persistent reference. The name and the intermediate certificates (public)
+/// sit in UserDefaults.
+enum ClientIdentityStore {
+ private static let aliasKey = "bulwark.clientCert.alias"
+ private static let refKey = "bulwark.clientCert.ref"
+ private static let chainKey = "bulwark.clientCert.chain"
+
+ static var alias: String? {
+ UserDefaults.standard.string(forKey: aliasKey)
+ }
+
+ static func importPKCS12(_ data: Data, password: String) -> ImportResult {
+ var items: CFArray?
+ let options = [kSecImportExportPassphrase as String: password] as CFDictionary
+ let status = SecPKCS12Import(data as CFData, options, &items)
+ if status == errSecAuthFailed { return .wrongPassword }
+ guard status == errSecSuccess,
+ let first = (items as? [[String: Any]])?.first,
+ let identityValue = first[kSecImportItemIdentity as String] else {
+ return .failure("The file holds no certificate with a private key (\(status))")
+ }
+ let identity = identityValue as! SecIdentity
+ var leaf: SecCertificate?
+ guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else {
+ return .failure("The certificate could not be read")
+ }
+ let leafData = SecCertificateCopyData(leaf) as Data
+ let name = (SecCertificateCopySubjectSummary(leaf) as String?) ?? "Client certificate"
+ let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate] ?? [])
+ .map { SecCertificateCopyData($0) as Data }
+ .filter { $0 != leafData }
+
+ clear()
+ let add: [String: Any] = [
+ kSecValueRef as String: identity,
+ kSecAttrLabel as String: name,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ kSecReturnPersistentRef as String: true,
+ ]
+ var added: CFTypeRef?
+ let addStatus = SecItemAdd(add as CFDictionary, &added)
+ let ref: Data?
+ if addStatus == errSecSuccess {
+ ref = (added as? Data) ?? persistentRef(ofIdentityWith: leafData)
+ } else if addStatus == errSecDuplicateItem {
+ // Already in the keychain (picked before, or left by an earlier install).
+ ref = persistentRef(ofIdentityWith: leafData)
+ } else {
+ return .failure("The certificate could not be stored (\(addStatus))")
+ }
+ guard let ref else { return .failure("The stored certificate could not be found") }
+ let defaults = UserDefaults.standard
+ defaults.set(ref, forKey: refKey)
+ defaults.set(name, forKey: aliasKey)
+ defaults.set(chain, forKey: chainKey)
+ return .success(name)
+ }
+
+ static func clear() {
+ let defaults = UserDefaults.standard
+ if let ref = defaults.data(forKey: refKey) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassIdentity,
+ kSecValuePersistentRef as String: ref,
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+ defaults.removeObject(forKey: refKey)
+ defaults.removeObject(forKey: aliasKey)
+ defaults.removeObject(forKey: chainKey)
+ }
+
+ static func credential() -> URLCredential? {
+ let defaults = UserDefaults.standard
+ guard let ref = defaults.data(forKey: refKey) else { return nil }
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassIdentity,
+ kSecValuePersistentRef as String: ref,
+ kSecReturnRef as String: true,
+ ]
+ var item: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item else { return nil }
+ let identity = item as! SecIdentity
+ let chain = (defaults.array(forKey: chainKey) as? [Data] ?? [])
+ .compactMap { SecCertificateCreateWithData(nil, $0 as CFData) }
+ return URLCredential(identity: identity, certificates: chain.isEmpty ? nil : chain, persistence: .forSession)
+ }
+
+ private static func persistentRef(ofIdentityWith certificateData: Data) -> Data? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassIdentity,
+ kSecMatchLimit as String: kSecMatchLimitAll,
+ kSecReturnRef as String: true,
+ kSecReturnPersistentRef as String: true,
+ ]
+ var items: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &items) == errSecSuccess,
+ let list = items as? [[String: Any]] else { return nil }
+ for entry in list {
+ guard let value = entry[kSecValueRef as String], let ref = entry[kSecValuePersistentRef as String] as? Data else { continue }
+ var certificate: SecCertificate?
+ guard SecIdentityCopyCertificate(value as! SecIdentity, &certificate) == errSecSuccess, let certificate else { continue }
+ if SecCertificateCopyData(certificate) as Data == certificateData { return ref }
+ }
+ return nil
+ }
+}
diff --git a/plugins/ios-client-cert/index.js b/plugins/ios-client-cert/index.js
new file mode 100644
index 00000000..2396c7e8
--- /dev/null
+++ b/plugins/ios-client-cert/index.js
@@ -0,0 +1,18 @@
+// Config plugin: TLS client certificates on iOS (#3).
+//
+// Compiles the BulwarkClientCert native module (BulwarkClientCert.swift, and
+// BulwarkClientCert.m that registers it with React Native) into the app. It
+// has the methods of Android's BulwarkClientCertModule.kt, so
+// src/lib/client-cert.ts works the same on both.
+
+const { createRunOncePlugin } = require('@expo/config-plugins');
+const { withAppSources } = require('../ios-app-sources');
+
+const FILES = ['BulwarkClientCert.swift', 'BulwarkClientCert.m'];
+
+function withIosClientCert(config) {
+ return withAppSources(config, { dir: __dirname, files: FILES });
+}
+
+module.exports = createRunOncePlugin(withIosClientCert, 'bulwark-ios-client-cert', '1.0.0');
+module.exports.FILES = FILES;
diff --git a/src/lib/__tests__/client-cert-ios.test.ts b/src/lib/__tests__/client-cert-ios.test.ts
new file mode 100644
index 00000000..d2ac76d4
--- /dev/null
+++ b/src/lib/__tests__/client-cert-ios.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it, vi } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { clientCertModuleOf } from '../client-cert';
+
+// TLS client certificates on iOS (#3). The app used the native module on
+// Android only; iOS now has one with the same methods (plugins/ios-client-cert),
+// so client-cert.ts takes the module wherever react-native offers it.
+
+const ROOT = path.resolve(__dirname, '../../..');
+const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
+
+describe('client-cert.ts on iOS', () => {
+ it('uses the BulwarkClientCert module whatever the platform', () => {
+ const module = { getAlias: vi.fn(), pickAlias: vi.fn(), clearAlias: vi.fn(), fetchSecure: vi.fn() };
+ expect(clientCertModuleOf({ NativeModules: { BulwarkClientCert: module } })).toBe(module);
+ expect(clientCertModuleOf({ NativeModules: {} })).toBeNull();
+ expect(read('src/lib/client-cert.ts')).not.toMatch(/Platform\.OS !== 'android'/);
+ });
+});
+
+describe('the iOS BulwarkClientCert module', () => {
+ const bridge = read('plugins/ios-client-cert/BulwarkClientCert.m');
+ const swift = read('plugins/ios-client-cert/BulwarkClientCert.swift');
+ const kotlin = read('android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkClientCertModule.kt');
+
+ const selectors = [...bridge.matchAll(/RCT_EXTERN_METHOD\(([\s\S]*?)\)\r?\n/g)].map((m) =>
+ // "pickAlias:(NSString *)host resolver:(...)resolve rejecter:(...)reject" -> "pickAlias:resolver:rejecter:"
+ m[1].replace(/\([^)]*\)\w+/g, '').replace(/\s+/g, ''),
+ );
+
+ it('registers under the Android module\'s name with the same methods', () => {
+ expect(bridge).toContain('@interface RCT_EXTERN_MODULE(BulwarkClientCert, NSObject)');
+ expect(kotlin).toContain('const val MODULE_NAME = "BulwarkClientCert"');
+ const jsNames = selectors.map((s) => s.split(':')[0]).sort();
+ const androidNames = [...kotlin.matchAll(/@ReactMethod\s+fun (\w+)\(/g)].map((m) => m[1]).sort();
+ expect(jsNames).toEqual(androidNames);
+ expect(jsNames).toEqual(['clearAlias', 'fetchSecure', 'getAlias', 'pickAlias']);
+ });
+
+ it('implements every exported selector in Swift', () => {
+ expect(swift).toContain('@objc(BulwarkClientCert)');
+ for (const selector of selectors) expect(swift).toContain(`@objc(${selector})`);
+ });
+
+ it('answers client-certificate challenges and leaves server trust to the system', () => {
+ expect(swift).toMatch(/authenticationMethod == NSURLAuthenticationMethodClientCertificate/);
+ expect(swift).toContain('completionHandler(.useCredential, credential)');
+ expect(swift).toContain('completionHandler(.performDefaultHandling, nil)');
+ });
+
+ it('hands redirects back to JS, which follows only same-host hops', () => {
+ const redirect = swift.slice(swift.indexOf('willPerformHTTPRedirection'));
+ expect(redirect.slice(0, 400)).toContain('completionHandler(nil)');
+ });
+
+ it('resolves fetchSecure with the shape secureFetch rebuilds a Response from', () => {
+ for (const key of ['"status"', '"statusText"', '"headers"', '"bodyBase64"']) expect(swift).toContain(key);
+ });
+
+ it('is compiled into the app by its config plugin', () => {
+ const plugin = createRequire(__filename)(path.join(ROOT, 'plugins/ios-client-cert/index.js')) as { FILES: string[] };
+ expect(plugin.FILES).toEqual(['BulwarkClientCert.swift', 'BulwarkClientCert.m']);
+ expect(read('app.config.js')).toContain("'./plugins/ios-client-cert'");
+ });
+});
diff --git a/src/lib/client-cert.ts b/src/lib/client-cert.ts
index d945acc5..556161be 100644
--- a/src/lib/client-cert.ts
+++ b/src/lib/client-cert.ts
@@ -2,8 +2,11 @@
// clear flow and exposes a `secureFetch` that mirrors the global `fetch` API
// closely enough that callers can swap them out.
//
-// On non-Android platforms (or when the user has not picked a cert) we fall
-// straight back to the global `fetch` so we don't pay any bridge overhead.
+// Android (BulwarkClientCertModule.kt) picks a certificate from the system's
+// KeyChain; iOS (plugins/ios-client-cert) imports a .p12 file into the app's
+// keychain. Both have the same methods. Where the module is missing (web,
+// tests), or when the user has not picked a cert, we fall straight back to
+// the global `fetch` so we don't pay any bridge overhead.
type Native = {
getAlias(): Promise;
@@ -29,17 +32,18 @@ type Native = {
let nativeProbed = false;
let nativeModule: Native | null = null;
+/** The client-certificate module react-native offers, on any platform that has one. */
+export function clientCertModuleOf(rn: { NativeModules: Record }): Native | null {
+ return (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? null;
+}
+
function getNative(): Native | null {
if (nativeProbed) return nativeModule;
nativeProbed = true;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
- const rn = require('react-native') as {
- Platform: { OS: string };
- NativeModules: Record;
- };
- if (rn.Platform.OS !== 'android') return (nativeModule = null);
- nativeModule = (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? null;
+ const rn = require('react-native') as { NativeModules: Record };
+ nativeModule = clientCertModuleOf(rn);
} catch {
nativeModule = null;
}
@@ -63,7 +67,7 @@ export async function getClientCertAlias(): Promise {
export async function pickClientCertAlias(host: string | null): Promise {
const native = getNative();
- if (!native) throw new Error('Client certificates require Android');
+ if (!native) throw new Error('Client certificates are not available on this device');
const alias = await native.pickAlias(host);
cachedAlias = alias;
return alias;
@@ -144,9 +148,9 @@ function buildResponse(raw: {
/**
* Drop-in replacement for `fetch` that routes through the native client when
- * the user has selected a client certificate. When no cert is set (or we're
- * on iOS), it just calls the platform `fetch` and the caller pays no
- * bridge overhead.
+ * the user has selected a client certificate. When no cert is set (or there
+ * is no native module), it just calls the platform `fetch` and the caller
+ * pays no bridge overhead.
*/
export interface SecureFetchInit extends RequestInit {
/** Native-path deadline (ms); blob transfers pass a longer one. */
From fbe54576042935cbc0751d7612e2efbbc4a7d1e2 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:29:52 +0100
Subject: [PATCH 4/4] feat: add an iOS inbox widget
---
app.config.js | 2 +
plugins/ios-widget/BulwarkWidget.swift | 268 ++++++++++++++++++++++
plugins/ios-widget/BulwarkWidgetKit.m | 10 +
plugins/ios-widget/BulwarkWidgetKit.swift | 32 +++
plugins/ios-widget/index.js | 159 +++++++++++++
src/widgets/__tests__/ios-widget.test.ts | 163 +++++++++++++
src/widgets/ios.ts | 116 ++++++++++
src/widgets/render.tsx | 14 +-
src/widgets/sync.ts | 12 +-
9 files changed, 768 insertions(+), 8 deletions(-)
create mode 100644 plugins/ios-widget/BulwarkWidget.swift
create mode 100644 plugins/ios-widget/BulwarkWidgetKit.m
create mode 100644 plugins/ios-widget/BulwarkWidgetKit.swift
create mode 100644 plugins/ios-widget/index.js
create mode 100644 src/widgets/__tests__/ios-widget.test.ts
create mode 100644 src/widgets/ios.ts
diff --git a/app.config.js b/app.config.js
index 0bc1c3e7..fc51e255 100644
--- a/app.config.js
+++ b/app.config.js
@@ -108,6 +108,8 @@ module.exports = {
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
// TLS client certificates (#3): the iOS side of src/lib/client-cert.ts.
'./plugins/ios-client-cert',
+ // The iOS inbox widget (WidgetKit); src/widgets/ios.ts feeds it.
+ ['./plugins/ios-widget', { teamId: APPLE_TEAM_ID }],
// Bundles the Involved sound calendar reminders (src/lib/calendar-
// notifications.ts) and new mail (plugins/ios-push) play on iOS.
// Android has it in res/raw.
diff --git a/plugins/ios-widget/BulwarkWidget.swift b/plugins/ios-widget/BulwarkWidget.swift
new file mode 100644
index 00000000..f37d2e86
--- /dev/null
+++ b/plugins/ios-widget/BulwarkWidget.swift
@@ -0,0 +1,268 @@
+import SwiftUI
+import WidgetKit
+
+/// The iOS inbox widget: the active account's inbox unread count and newest
+/// messages. The app writes what it shows (src/widgets/ios.ts) into the
+/// keychain group it shares with its extensions and reloads the widget after
+/// each write; a tap opens the app on the message through its deep link.
+
+/// Kind the app reloads (BulwarkWidgetKit.swift).
+let inboxWidgetKind = "BulwarkInbox"
+
+/// src/widgets/ios.ts IosWidgetSnapshot.
+struct InboxSnapshot: Decodable {
+ struct Message: Decodable, Identifiable {
+ let id: String
+ let from: String
+ let initials: String
+ let color: String
+ let subject: String
+ let preview: String
+ let receivedAt: Double
+ let unread: Bool
+ let url: String
+ }
+
+ struct Strings: Decodable {
+ let inbox: String
+ let unread: String
+ let empty: String
+ let signedOut: String
+ let noSubject: String
+ }
+
+ let version: Int
+ let generatedAt: Double
+ let signedIn: Bool
+ let theme: String
+ let locale: String
+ let hour12: Bool
+ let account: String?
+ let unread: Int
+ let inboxUrl: String
+ let composeUrl: String
+ let messages: [Message]
+ let strings: Strings
+
+ /// IOS_WIDGET_KEY in src/widgets/ios.ts.
+ static func load() -> InboxSnapshot? {
+ guard let data = SharedKeychain.read("bulwark_widget_inbox") else { return nil }
+ return try? JSONDecoder().decode(InboxSnapshot.self, from: data)
+ }
+}
+
+struct InboxEntry: TimelineEntry {
+ let date: Date
+ let snapshot: InboxSnapshot?
+}
+
+struct InboxProvider: TimelineProvider {
+ func placeholder(in context: Context) -> InboxEntry {
+ InboxEntry(date: Date(), snapshot: nil)
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (InboxEntry) -> Void) {
+ completion(InboxEntry(date: Date(), snapshot: InboxSnapshot.load()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let entry = InboxEntry(date: Date(), snapshot: InboxSnapshot.load())
+ // The app reloads the widget when it writes new data; this only keeps
+ // "today" and the times current.
+ completion(Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(30 * 60))))
+ }
+}
+
+/// The widget palette of src/widgets/theme.ts, roughly.
+struct Palette {
+ let background: Color
+ let foreground: Color
+ let muted: Color
+ let accent: Color
+
+ init(dark: Bool) {
+ background = dark ? Color(hex: "#18181b") : Color(hex: "#ffffff")
+ foreground = dark ? Color(hex: "#fafafa") : Color(hex: "#09090b")
+ muted = dark ? Color(hex: "#a1a1aa") : Color(hex: "#71717a")
+ accent = Color(hex: "#db2d54")
+ }
+}
+
+extension Color {
+ init(hex: String) {
+ var value: UInt64 = 0
+ Scanner(string: hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))).scanHexInt64(&value)
+ self.init(
+ red: Double((value >> 16) & 0xff) / 255,
+ green: Double((value >> 8) & 0xff) / 255,
+ blue: Double(value & 0xff) / 255
+ )
+ }
+}
+
+extension View {
+ /// iOS 17 wants the background declared and adds the margins itself.
+ @ViewBuilder func widgetBackground(_ color: Color) -> some View {
+ if #available(iOS 17.0, *) {
+ containerBackground(for: .widget) { color }
+ } else {
+ padding(14).background(color)
+ }
+ }
+}
+
+func listTime(_ ms: Double, locale: String, hour12: Bool) -> String {
+ let date = Date(timeIntervalSince1970: ms / 1000)
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: locale)
+ if Calendar.current.isDateInToday(date) {
+ formatter.dateFormat = hour12 ? "h:mm a" : "HH:mm"
+ } else {
+ formatter.setLocalizedDateFormatFromTemplate("MMMd")
+ }
+ return formatter.string(from: date)
+}
+
+struct MessageRow: View {
+ let message: InboxSnapshot.Message
+ let time: String
+ let palette: Palette
+
+ var body: some View {
+ HStack(spacing: 8) {
+ ZStack {
+ Circle().fill(Color(hex: message.color))
+ Text(message.initials)
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundColor(.white)
+ }
+ .frame(width: 26, height: 26)
+ VStack(alignment: .leading, spacing: 1) {
+ HStack(spacing: 4) {
+ Text(message.from)
+ .font(.system(size: 13, weight: message.unread ? .semibold : .regular))
+ .foregroundColor(palette.foreground)
+ .lineLimit(1)
+ Spacer(minLength: 4)
+ Text(time)
+ .font(.system(size: 11))
+ .foregroundColor(message.unread ? palette.accent : palette.muted)
+ }
+ Text(message.subject)
+ .font(.system(size: 12))
+ .foregroundColor(message.unread ? palette.foreground : palette.muted)
+ .lineLimit(1)
+ }
+ }
+ }
+}
+
+struct InboxWidgetView: View {
+ @Environment(\.widgetFamily) private var family
+ @Environment(\.colorScheme) private var systemScheme
+ let entry: InboxEntry
+
+ private var dark: Bool {
+ switch entry.snapshot?.theme {
+ case "dark": return true
+ case "light": return false
+ default: return systemScheme == .dark
+ }
+ }
+
+ private var rows: Int {
+ switch family {
+ case .systemSmall: return 1
+ case .systemMedium: return 2
+ default: return 6
+ }
+ }
+
+ var body: some View {
+ let palette = Palette(dark: dark)
+ content(palette)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .widgetBackground(palette.background)
+ .widgetURL(URL(string: entry.snapshot?.inboxUrl ?? "bulwarkmobile://mail/folder/inbox"))
+ }
+
+ @ViewBuilder private func content(_ palette: Palette) -> some View {
+ if let snapshot = entry.snapshot, snapshot.signedIn {
+ VStack(alignment: .leading, spacing: family == .systemSmall ? 6 : 8) {
+ header(snapshot, palette)
+ if snapshot.messages.isEmpty {
+ Spacer(minLength: 0)
+ Text(snapshot.strings.empty)
+ .font(.system(size: 13))
+ .foregroundColor(palette.muted)
+ Spacer(minLength: 0)
+ } else {
+ ForEach(snapshot.messages.prefix(rows)) { message in
+ let row = MessageRow(
+ message: message,
+ time: listTime(message.receivedAt, locale: snapshot.locale, hour12: snapshot.hour12),
+ palette: palette
+ )
+ // A small widget opens what it shows through widgetURL alone.
+ if family != .systemSmall, let url = URL(string: message.url) {
+ Link(destination: url) { row }
+ } else {
+ row
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+ } else {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Bulwark Mail")
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundColor(palette.foreground)
+ Text(entry.snapshot?.strings.signedOut ?? "Open Bulwark to load your inbox.")
+ .font(.system(size: 13))
+ .foregroundColor(palette.muted)
+ }
+ }
+ }
+
+ private func header(_ snapshot: InboxSnapshot, _ palette: Palette) -> some View {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ VStack(alignment: .leading, spacing: 0) {
+ Text(snapshot.strings.inbox)
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundColor(palette.foreground)
+ if let account = snapshot.account, family != .systemSmall {
+ Text(account)
+ .font(.system(size: 11))
+ .foregroundColor(palette.muted)
+ .lineLimit(1)
+ }
+ }
+ Spacer(minLength: 4)
+ if snapshot.unread > 0 {
+ Text("\(snapshot.unread)")
+ .font(.system(size: family == .systemSmall ? 22 : 15, weight: .bold))
+ .foregroundColor(palette.accent)
+ .accessibilityLabel("\(snapshot.unread) \(snapshot.strings.unread)")
+ }
+ }
+ }
+}
+
+struct InboxWidget: Widget {
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: inboxWidgetKind, provider: InboxProvider()) { entry in
+ InboxWidgetView(entry: entry)
+ }
+ .configurationDisplayName("Inbox")
+ .description("Unread mail and the newest messages.")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
+
+@main
+struct BulwarkWidgets: WidgetBundle {
+ var body: some Widget {
+ InboxWidget()
+ }
+}
diff --git a/plugins/ios-widget/BulwarkWidgetKit.m b/plugins/ios-widget/BulwarkWidgetKit.m
new file mode 100644
index 00000000..829c02bf
--- /dev/null
+++ b/plugins/ios-widget/BulwarkWidgetKit.m
@@ -0,0 +1,10 @@
+// Exposes BulwarkWidgetKit.swift to React Native as NativeModules.BulwarkWidgetKit.
+
+#import
+
+@interface RCT_EXTERN_MODULE(BulwarkWidgetKit, NSObject)
+
+RCT_EXTERN_METHOD(reloadAll:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+RCT_EXTERN_METHOD(placedCount:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
+
+@end
diff --git a/plugins/ios-widget/BulwarkWidgetKit.swift b/plugins/ios-widget/BulwarkWidgetKit.swift
new file mode 100644
index 00000000..9ca625fc
--- /dev/null
+++ b/plugins/ios-widget/BulwarkWidgetKit.swift
@@ -0,0 +1,32 @@
+import Foundation
+import React
+import WidgetKit
+
+/// Lets the app redraw its WidgetKit widget (plugins/ios-widget) after it
+/// wrote new data (src/widgets/ios.ts), and ask whether one is placed at all,
+/// so it doesn't build a snapshot nobody sees.
+@objc(BulwarkWidgetKit)
+final class BulwarkWidgetKit: NSObject {
+ /// inboxWidgetKind in BulwarkWidget.swift.
+ static let inboxKind = "BulwarkInbox"
+
+ @objc static func requiresMainQueueSetup() -> Bool { false }
+
+ @objc(reloadAll:rejecter:)
+ func reloadAll(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
+ WidgetCenter.shared.reloadTimelines(ofKind: BulwarkWidgetKit.inboxKind)
+ resolve(nil)
+ }
+
+ @objc(placedCount:rejecter:)
+ func placedCount(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
+ WidgetCenter.shared.getCurrentConfigurations { result in
+ switch result {
+ case .success(let widgets):
+ resolve(widgets.filter { $0.kind == BulwarkWidgetKit.inboxKind }.count)
+ case .failure(let error):
+ reject("widgetkit", error.localizedDescription, error)
+ }
+ }
+ }
+}
diff --git a/plugins/ios-widget/index.js b/plugins/ios-widget/index.js
new file mode 100644
index 00000000..9cd5420a
--- /dev/null
+++ b/plugins/ios-widget/index.js
@@ -0,0 +1,159 @@
+// Config plugin: the iOS inbox widget (WidgetKit).
+//
+// - A widget extension target ("BulwarkWidget", org.bulwarkmail.mobile.Widget)
+// built from BulwarkWidget.swift, plus SharedKeychain.swift from
+// plugins/ios-push: the widget reads what it shows from the keychain group
+// the app shares with its extensions (src/widgets/ios.ts writes it). No app
+// group: that would need a new capability on every provisioning profile.
+// - The BulwarkWidgetKit native module in the app (BulwarkWidgetKit.swift/.m),
+// which reloads the widget after the app wrote new data.
+//
+// Signing: a signed build names a profile per target. When the app's profile
+// is given (IOS_APP_PROFILE, the release workflow) but no widget profile
+// (IOS_WIDGET_PROFILE), the widget target is left out so the archive still
+// signs; the simulator and unsigned builds always include it.
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { withDangerousMod, withXcodeProject, createRunOncePlugin } = require('@expo/config-plugins');
+const { withAppSources } = require('../ios-app-sources');
+
+const TARGET = 'BulwarkWidget';
+const DEPLOYMENT_TARGET = '15.1';
+const SHARED_GROUP_SUFFIX = 'org.bulwarkmail.shared';
+// The widget's own sources, and the ones it borrows from plugins/ios-push.
+const WIDGET_FILES = ['BulwarkWidget.swift'];
+const BORROWED_FILES = ['SharedKeychain.swift'];
+const SOURCE_FILES = [...WIDGET_FILES, ...BORROWED_FILES];
+const APP_FILES = ['BulwarkWidgetKit.swift', 'BulwarkWidgetKit.m'];
+
+function infoPlist({ version, buildNumber }) {
+ return `
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Bulwark Mail
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ ${version}
+ CFBundleVersion
+ ${buildNumber}
+ BulwarkKeychainGroup
+ $(DEVELOPMENT_TEAM).${SHARED_GROUP_SUFFIX}
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
+`;
+}
+
+function entitlementsPlist() {
+ return `
+
+
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)${SHARED_GROUP_SUFFIX}
+
+
+
+`;
+}
+
+/** Whether this build gets the widget target (see the signing note above). */
+function includeWidget({ appProfile, widgetProfile }) {
+ return !appProfile || !!widgetProfile;
+}
+
+function withWidgetFiles(config) {
+ return withDangerousMod(config, [
+ 'ios',
+ async (cfg) => {
+ const dir = path.join(cfg.modRequest.platformProjectRoot, TARGET);
+ fs.mkdirSync(dir, { recursive: true });
+ for (const file of WIDGET_FILES) fs.copyFileSync(path.join(__dirname, file), path.join(dir, file));
+ for (const file of BORROWED_FILES) fs.copyFileSync(path.join(__dirname, '../ios-push', file), path.join(dir, file));
+ fs.writeFileSync(
+ path.join(dir, `${TARGET}-Info.plist`),
+ infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
+ );
+ fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist());
+ return cfg;
+ },
+ ]);
+}
+
+function addWidgetTarget(cfg, { teamId, widgetProfile }) {
+ const project = cfg.modResults;
+ if (project.pbxTargetByName(TARGET)) return;
+
+ const objects = project.hash.project.objects;
+ objects.PBXTargetDependency = objects.PBXTargetDependency || {};
+ objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
+
+ const group = project.addPbxGroup(
+ [...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`],
+ TARGET,
+ TARGET,
+ );
+ const mainGroupId = project.getFirstProject().firstProject.mainGroup;
+ project.addToPbxGroup(group.uuid, mainGroupId);
+
+ const bundleId = `${cfg.ios.bundleIdentifier}.Widget`;
+ const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId);
+ project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
+ project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
+ project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
+
+ const configs = project.pbxXCBuildConfigurationSection();
+ for (const key of Object.keys(configs)) {
+ const entry = configs[key];
+ if (!entry.buildSettings || entry.buildSettings.PRODUCT_NAME !== `"${TARGET}"`) continue;
+ const s = entry.buildSettings;
+ s.CODE_SIGN_ENTITLEMENTS = `${TARGET}/${TARGET}.entitlements`;
+ s.IPHONEOS_DEPLOYMENT_TARGET = DEPLOYMENT_TARGET;
+ s.TARGETED_DEVICE_FAMILY = '"1,2"';
+ s.SWIFT_VERSION = '5.0';
+ s.CLANG_ENABLE_MODULES = 'YES';
+ s.GENERATE_INFOPLIST_FILE = 'NO';
+ if (teamId) s.DEVELOPMENT_TEAM = teamId;
+ if (widgetProfile && entry.name === 'Release') s.PROVISIONING_PROFILE_SPECIFIER = `"${widgetProfile}"`;
+ }
+}
+
+function withIosWidget(config, options = {}) {
+ const teamId = process.env.APPLE_TEAM_ID || options.teamId || '';
+ const appProfile = process.env.IOS_APP_PROFILE || options.appProfile || '';
+ const widgetProfile = process.env.IOS_WIDGET_PROFILE || options.widgetProfile || '';
+ config = withAppSources(config, { dir: __dirname, files: APP_FILES });
+ if (!includeWidget({ appProfile, widgetProfile })) {
+ console.warn('plugins/ios-widget: IOS_APP_PROFILE is set but IOS_WIDGET_PROFILE is not; building without the widget.');
+ return config;
+ }
+ config = withWidgetFiles(config);
+ config = withXcodeProject(config, (cfg) => {
+ addWidgetTarget(cfg, { teamId, widgetProfile });
+ return cfg;
+ });
+ return config;
+}
+
+module.exports = createRunOncePlugin(withIosWidget, 'bulwark-ios-widget', '1.0.0');
+// For the tests (src/widgets/__tests__/ios-widget.test.ts).
+module.exports.internals = { includeWidget, infoPlist, entitlementsPlist, SOURCE_FILES, APP_FILES, TARGET };
diff --git a/src/widgets/__tests__/ios-widget.test.ts b/src/widgets/__tests__/ios-widget.test.ts
new file mode 100644
index 00000000..e9a63eb4
--- /dev/null
+++ b/src/widgets/__tests__/ios-widget.test.ts
@@ -0,0 +1,163 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+
+// The iOS inbox widget (WidgetKit, plugins/ios-widget). It can't run JS: the
+// app writes it a finished snapshot into the keychain group it shares with
+// its extensions and asks WidgetKit to redraw.
+
+const rn = vi.hoisted(() => ({
+ Platform: { OS: 'ios' },
+ AppState: { addEventListener: () => ({ remove: () => undefined }) },
+ NativeModules: {} as Record,
+}));
+vi.mock('react-native', () => rn);
+vi.mock('react-native-android-widget', () => ({
+ FlexWidget: () => null,
+ OverlapWidget: () => null,
+ SvgWidget: () => null,
+ TextWidget: () => null,
+ ImageWidget: () => null,
+ getWidgetInfo: vi.fn(async () => []),
+ requestWidgetUpdate: vi.fn(async () => undefined),
+}));
+vi.mock('../../lib/ios-push-shared', () => ({
+ sharedStoreOptions: () => ({ accessGroup: 'TEAM.org.bulwarkmail.shared', keychainAccessible: 1 }),
+}));
+
+import * as SecureStore from 'expo-secure-store';
+import { IOS_WIDGET_KEY, iosWidgetSnapshot } from '../ios';
+import { redrawAll, hasPlacedWidgets, invalidatePlacedCache } from '../render';
+import { handleAppState } from '../sync';
+import { saveSnapshot } from '../snapshot';
+import { links } from '../clicks';
+import { PREVIEW_NOW, sampleSnapshot } from '../sample-snapshot';
+
+const ROOT = path.resolve(__dirname, '../../..');
+const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
+const flush = async () => {
+ for (let i = 0; i < 20; i++) await Promise.resolve();
+};
+
+describe('the snapshot the iOS widget draws', () => {
+ const s = sampleSnapshot(PREVIEW_NOW);
+
+ it('carries the inbox unread count and the newest messages with their deep links', () => {
+ const out = iosWidgetSnapshot(s);
+ const inbox = s.mail.folders.find((f) => f.role === 'inbox');
+ expect(out.unread).toBe(inbox?.unread);
+ expect(out.messages.length).toBe(Math.min(6, s.mail.inbox.length));
+ expect(out.messages[0].id).toBe(s.mail.inbox[0].id);
+ expect(out.messages[0].url).toBe(links.message(s.mail.inbox[0]));
+ expect(out.messages[0].url.startsWith('bulwarkmobile://mail/message/')).toBe(true);
+ expect(out.inboxUrl).toBe('bulwarkmobile://mail/folder/inbox');
+ expect(out.strings.inbox).toBe('Inbox');
+ });
+
+ it('names the account only when there is more than one', () => {
+ expect(iosWidgetSnapshot({ ...s, accounts: s.accounts.slice(0, 1) }).account).toBeNull();
+ const two = [{ id: 'a', label: 'ada@example.org', color: '#000000', unread: 1 }, { id: 'b', label: 'bea@example.org', color: '#000000', unread: 0 }];
+ expect(iosWidgetSnapshot({ ...s, accounts: two, activeAccountId: 'b' }).account).toBe('bea@example.org');
+ });
+
+ it('fills in a missing subject', () => {
+ const blank = { ...s, mail: { ...s.mail, inbox: [{ ...s.mail.inbox[0], subject: '' }] } };
+ expect(iosWidgetSnapshot(blank).messages[0].subject).toBe('(no subject)');
+ });
+});
+
+describe('the app hands the widget its data on iOS', () => {
+ const reloadAll = vi.fn(async () => undefined);
+ const placedCount = vi.fn(async () => 1);
+
+ beforeEach(async () => {
+ vi.mocked(SecureStore.setItemAsync).mockClear();
+ reloadAll.mockClear();
+ rn.NativeModules.BulwarkWidgetKit = { reloadAll, placedCount };
+ invalidatePlacedCache();
+ await saveSnapshot(sampleSnapshot(PREVIEW_NOW));
+ });
+
+ afterEach(() => {
+ delete rn.NativeModules.BulwarkWidgetKit;
+ });
+
+ it('writes the snapshot to the shared keychain group and reloads the widget', async () => {
+ await redrawAll();
+ expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
+ IOS_WIDGET_KEY,
+ expect.any(String),
+ expect.objectContaining({ accessGroup: 'TEAM.org.bulwarkmail.shared' }),
+ );
+ const written = JSON.parse(vi.mocked(SecureStore.setItemAsync).mock.calls[0][1] as string);
+ expect(written.messages.length).toBeGreaterThan(0);
+ expect(reloadAll).toHaveBeenCalledTimes(1);
+ });
+
+ it('asks WidgetKit whether a widget is placed', async () => {
+ expect(await hasPlacedWidgets()).toBe(true);
+ placedCount.mockResolvedValueOnce(0);
+ invalidatePlacedCache();
+ expect(await hasPlacedWidgets()).toBe(false);
+ });
+
+ it('hands the widget what the app has when it goes to the background', async () => {
+ handleAppState('background');
+ await flush();
+ expect(SecureStore.setItemAsync).toHaveBeenCalledWith(IOS_WIDGET_KEY, expect.any(String), expect.anything());
+ });
+});
+
+describe('the WidgetKit side', () => {
+ const swift = read('plugins/ios-widget/BulwarkWidget.swift');
+ const kit = read('plugins/ios-widget/BulwarkWidgetKit.swift');
+ const bridge = read('plugins/ios-widget/BulwarkWidgetKit.m');
+ const plugin = createRequire(__filename)(path.join(ROOT, 'plugins/ios-widget/index.js')) as {
+ internals: {
+ includeWidget(o: { appProfile: string; widgetProfile: string }): boolean;
+ infoPlist(o: { version: string; buildNumber: string }): string;
+ entitlementsPlist(): string;
+ SOURCE_FILES: string[];
+ APP_FILES: string[];
+ };
+ };
+
+ it('reads the key the app writes, with the fields it writes', () => {
+ expect(swift).toContain(`SharedKeychain.read("${IOS_WIDGET_KEY}")`);
+ const fields = Object.keys(iosWidgetSnapshot(sampleSnapshot(PREVIEW_NOW)));
+ for (const field of fields) expect(swift).toMatch(new RegExp(`let ${field}: `));
+ for (const field of Object.keys(iosWidgetSnapshot(sampleSnapshot(PREVIEW_NOW)).messages[0])) {
+ expect(swift).toMatch(new RegExp(`let ${field}: `));
+ }
+ });
+
+ it('opens a tapped message through its deep link', () => {
+ expect(swift).toContain('Link(destination: url)');
+ expect(swift).toContain('.widgetURL(');
+ });
+
+ it('reloads the widget kind the extension declares', () => {
+ expect(swift).toContain('let inboxWidgetKind = "BulwarkInbox"');
+ expect(kit).toContain('static let inboxKind = "BulwarkInbox"');
+ expect(bridge).toContain('RCT_EXTERN_MODULE(BulwarkWidgetKit, NSObject)');
+ expect(bridge).toContain('RCT_EXTERN_METHOD(reloadAll:');
+ expect(bridge).toContain('RCT_EXTERN_METHOD(placedCount:');
+ });
+
+ it('builds a WidgetKit extension in the shared keychain group', () => {
+ const { internals } = plugin;
+ expect(internals.infoPlist({ version: '1.0.0', buildNumber: '7' })).toContain('com.apple.widgetkit-extension');
+ expect(internals.entitlementsPlist()).toContain('$(AppIdentifierPrefix)org.bulwarkmail.shared');
+ expect(internals.SOURCE_FILES).toEqual(['BulwarkWidget.swift', 'SharedKeychain.swift']);
+ expect(internals.APP_FILES).toEqual(['BulwarkWidgetKit.swift', 'BulwarkWidgetKit.m']);
+ expect(read('app.config.js')).toContain("'./plugins/ios-widget'");
+ });
+
+ it('leaves the widget out of a signed build that has no profile for it', () => {
+ const { includeWidget } = plugin.internals;
+ expect(includeWidget({ appProfile: '', widgetProfile: '' })).toBe(true);
+ expect(includeWidget({ appProfile: 'app-uuid', widgetProfile: 'widget-uuid' })).toBe(true);
+ expect(includeWidget({ appProfile: 'app-uuid', widgetProfile: '' })).toBe(false);
+ });
+});
diff --git a/src/widgets/ios.ts b/src/widgets/ios.ts
new file mode 100644
index 00000000..3ce458cd
--- /dev/null
+++ b/src/widgets/ios.ts
@@ -0,0 +1,116 @@
+// The iOS inbox widget (WidgetKit, plugins/ios-widget). It can't run JS, so
+// the app hands it a small finished snapshot: the active account's inbox
+// unread count and newest messages, cut from the data the Android widgets
+// draw (./snapshot.ts), with the texts already translated and each message's
+// deep link (./clicks.ts) ready to open.
+//
+// The snapshot sits in the keychain group the app already shares with its
+// extensions (src/lib/ios-push-shared.ts): an app group would need a new
+// capability on every provisioning profile, the keychain group needs none.
+// After each write the app asks WidgetKit to redraw (BulwarkWidgetKit).
+
+import { NativeModules, Platform } from 'react-native';
+import * as SecureStore from 'expo-secure-store';
+import { sharedStoreOptions } from '../lib/ios-push-shared';
+import { links } from './clicks';
+import { makeFmt } from './format';
+import { normalizeHex } from './theme';
+import type { WidgetSnapshot } from './snapshot';
+
+/** Read by plugins/ios-widget/BulwarkWidget.swift. */
+export const IOS_WIDGET_KEY = 'bulwark_widget_inbox';
+
+/** Messages a large widget can show; smaller ones take the first few. */
+const MAX_MESSAGES = 6;
+
+export interface IosWidgetMessage {
+ id: string;
+ from: string;
+ initials: string;
+ color: string;
+ subject: string;
+ preview: string;
+ receivedAt: number;
+ unread: boolean;
+ /** Opens the message in the app. */
+ url: string;
+}
+
+export interface IosWidgetSnapshot {
+ version: 1;
+ generatedAt: number;
+ signedIn: boolean;
+ theme: 'light' | 'dark' | 'system';
+ locale: string;
+ hour12: boolean;
+ /** The active account's label; null with a single account. */
+ account: string | null;
+ unread: number;
+ inboxUrl: string;
+ composeUrl: string;
+ messages: IosWidgetMessage[];
+ strings: { inbox: string; unread: string; empty: string; signedOut: string; noSubject: string };
+}
+
+export function iosWidgetSnapshot(s: WidgetSnapshot): IosWidgetSnapshot {
+ const f = makeFmt(s.locale, s.hour12);
+ const noSubject = f.t('widgets.mail.no_subject', '(no subject)');
+ const inbox = s.mail.folders.find((folder) => folder.role === 'inbox');
+ const account = s.accounts.length > 1 ? s.accounts.find((a) => a.id === s.activeAccountId)?.label ?? null : null;
+ return {
+ version: 1,
+ generatedAt: s.generatedAt,
+ signedIn: s.signedIn,
+ theme: s.theme,
+ locale: s.locale,
+ hour12: s.hour12,
+ account,
+ unread: inbox?.unread ?? 0,
+ inboxUrl: links.inbox(),
+ composeUrl: links.compose(),
+ messages: s.mail.inbox.slice(0, MAX_MESSAGES).map((m) => ({
+ id: m.id,
+ from: m.fromName || m.fromEmail,
+ initials: m.initials,
+ color: normalizeHex(m.color),
+ subject: m.subject || noSubject,
+ preview: m.preview,
+ receivedAt: m.receivedAt,
+ unread: m.unread,
+ url: links.message(m),
+ })),
+ strings: {
+ inbox: f.t('widgets.mail.inbox', 'Inbox'),
+ unread: f.t('widgets.mail.unread', 'unread'),
+ empty: f.t('widgets.mail.empty_inbox', 'Your inbox is empty'),
+ signedOut: f.t('widgets.state.signed_out', 'Sign in to Bulwark'),
+ noSubject,
+ },
+ };
+}
+
+type WidgetKitModule = { reloadAll?: () => Promise; placedCount?: () => Promise };
+
+function widgetKit(): WidgetKitModule | undefined {
+ return NativeModules.BulwarkWidgetKit as WidgetKitModule | undefined;
+}
+
+/** Whether an inbox widget is on the home screen (or the module can't tell). */
+export async function iosWidgetPlaced(): Promise {
+ const count = widgetKit()?.placedCount;
+ if (!count) return false;
+ try {
+ return (await count()) > 0;
+ } catch {
+ return true;
+ }
+}
+
+/** Hand the widget what it shows and have WidgetKit redraw it. */
+export async function writeIosWidget(s: WidgetSnapshot): Promise {
+ if (Platform.OS !== 'ios') return;
+ const options = sharedStoreOptions();
+ if (!options) return;
+ await SecureStore.setItemAsync(IOS_WIDGET_KEY, JSON.stringify(iosWidgetSnapshot(s)), options);
+ await widgetKit()?.reloadAll?.().catch(() => undefined);
+}
diff --git a/src/widgets/render.tsx b/src/widgets/render.tsx
index 308f82ff..7bd9141d 100644
--- a/src/widgets/render.tsx
+++ b/src/widgets/render.tsx
@@ -16,6 +16,7 @@ import { serial } from './serial';
import { readView } from './state';
import type { WidgetSnapshot } from './snapshot';
import { DARK, LIGHT, type WidgetPalette } from './theme';
+import { iosWidgetPlaced, writeIosWidget } from './ios';
import type { Layout, WidgetLocalState } from './layouts/types';
import * as mail from './layouts/mail';
import * as moreMail from './layouts/more-mail';
@@ -161,11 +162,13 @@ let queued: Promise | null = null;
* one drawn is the newest; calls made while one is still waiting share it.
*/
export function redrawAll(): Promise {
- if (Platform.OS !== 'android') return Promise.resolve();
+ if (Platform.OS !== 'android' && Platform.OS !== 'ios') return Promise.resolve();
if (queued) return queued;
const run = serial(async () => {
queued = null;
- await updateAllWidgets(await readView());
+ // iOS draws its WidgetKit widget itself from a snapshot handed over (./ios.ts).
+ if (Platform.OS === 'ios') await writeIosWidget(await readView());
+ else await updateAllWidgets(await readView());
});
queued = run;
return run;
@@ -180,10 +183,11 @@ let placedCache: { at: number; value: boolean } | null = null;
/** Whether any Bulwark widget is on a home screen; cached briefly. */
export async function hasPlacedWidgets(maxAgeMs = 60_000): Promise {
- if (Platform.OS !== 'android') return false;
+ if (Platform.OS !== 'android' && Platform.OS !== 'ios') return false;
if (placedCache && Date.now() - placedCache.at < maxAgeMs) return placedCache.value;
- const counts = await Promise.all(WIDGET_NAMES.map((n) => getWidgetInfo(n).then((l) => l.length).catch(() => 0)));
- const value = counts.some((c) => c > 0);
+ const value = Platform.OS === 'ios'
+ ? await iosWidgetPlaced()
+ : (await Promise.all(WIDGET_NAMES.map((n) => getWidgetInfo(n).then((l) => l.length).catch(() => 0)))).some((c) => c > 0);
placedCache = { at: Date.now(), value };
return value;
}
diff --git a/src/widgets/sync.ts b/src/widgets/sync.ts
index 863c35c5..efc7e9f5 100644
--- a/src/widgets/sync.ts
+++ b/src/widgets/sync.ts
@@ -48,6 +48,12 @@ export function handleAppState(next: string): void {
if (next !== 'background') return;
if (timer) clearTimeout(timer);
timer = null;
+ // iOS has no headless task to fetch in: hand the WidgetKit widget what the
+ // app already has, which needs no network.
+ if (Platform.OS === 'ios') {
+ void redrawAll().catch(() => undefined);
+ return;
+ }
const native = NativeModules.BulwarkWidgets as { refreshInBackground?: () => Promise } | undefined;
void (native?.refreshInBackground?.() ?? Promise.resolve(false))
.catch(() => false)
@@ -56,9 +62,9 @@ export function handleAppState(next: string): void {
});
}
-/** Start following app state; returns the cleanup. No-op off Android. */
+/** Start following app state; returns the cleanup. No-op off Android and iOS. */
export function startWidgetSync(): () => void {
- if (Platform.OS !== 'android') return () => {};
+ if (Platform.OS !== 'android' && Platform.OS !== 'ios') return () => {};
const { useEmailStore } = require('../stores/email-store') as typeof import('../stores/email-store');
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
const { useSettingsStore } = require('../stores/settings-store') as typeof import('../stores/settings-store');
@@ -93,7 +99,7 @@ export function startWidgetSync(): () => void {
/** Wipe what the widgets show after the last account signed out. */
export async function signOutWidgets(): Promise {
- if (Platform.OS !== 'android') return;
+ if (Platform.OS !== 'android' && Platform.OS !== 'ios') return;
// Signed out of every account, not merely offline or mid-switch.
if ((await readRegistry()).accounts.length > 0) return;
await replaceAll({ ...emptySnapshot(), generatedAt: Date.now() });