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..fc51e255 100644 --- a/app.config.js +++ b/app.config.js @@ -106,8 +106,13 @@ 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. + // 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. [ 'expo-notifications', { 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/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 0557455f..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 @@ -30,10 +29,23 @@ 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 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) } } @@ -50,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/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/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/__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/__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')); + }); +}); 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. */ 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. 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() });