mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
Merge branch 'cu-native' into catch-up-2026-11
This commit is contained in:
@@ -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)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
// Android 7 has no channels: the notification names its sound.
|
||||||
|
.setSound(BulwarkMessagingService.soundUri(ctx))
|
||||||
.setLargeIcon(largeIcon)
|
.setLargeIcon(largeIcon)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
// body is the subject with the "(No subject)" fallback; an empty
|
// body is the subject with the "(No subject)" fallback; an empty
|
||||||
@@ -284,7 +286,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
intent,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
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)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(groupTitle)
|
.setContentTitle(groupTitle)
|
||||||
.setContentText(if (count == 1) "1 new message" else "$count new messages")
|
.setContentText(if (count == 1) "1 new message" else "$count new messages")
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
@@ -49,7 +51,10 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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 TAG = "BulwarkPush"
|
||||||
private const val START_WAKE_LOCK_MS = 10_000L
|
private const val START_WAKE_LOCK_MS = 10_000L
|
||||||
|
|
||||||
@@ -103,10 +108,39 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
|||||||
startWakeLock = null
|
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) {
|
fun ensureChannel(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (channelId(context) != CHANNEL_ID) return
|
||||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) 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(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Mail notifications",
|
"Mail notifications",
|
||||||
@@ -114,6 +148,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
|||||||
).apply {
|
).apply {
|
||||||
description = "Incoming email alerts"
|
description = "Incoming email alerts"
|
||||||
enableVibration(true)
|
enableVibration(true)
|
||||||
|
setSound(sound, attributes)
|
||||||
}
|
}
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- The calendar reminder channel names its sound by resource name from JS
|
<!-- The calendar reminder channel (src/lib/calendar-notifications.ts) and the
|
||||||
(src/lib/calendar-notifications.ts), which the release resource shrinker
|
mail channel (BulwarkMessagingService.kt) name their sound by resource
|
||||||
can't see. -->
|
name, which the release resource shrinker can't see. -->
|
||||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||||
tools:keep="@raw/involved_notification" />
|
tools:keep="@raw/involved_notification" />
|
||||||
|
|||||||
+7
-2
@@ -106,8 +106,13 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
|
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
|
||||||
// Bundles the Involved sound calendar reminders play on iOS
|
// TLS client certificates (#3): the iOS side of src/lib/client-cert.ts.
|
||||||
// (src/lib/calendar-notifications.ts). Android has it in res/raw.
|
'./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',
|
'expo-notifications',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Compiles sources that live next to a config plugin into the iOS app target:
|
||||||
|
// copied into ios/<project>/ 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 };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Exposes BulwarkClientCert.swift to React Native as NativeModules.BulwarkClientCert,
|
||||||
|
// with the methods of Android's BulwarkClientCertModule.kt.
|
||||||
|
|
||||||
|
#import <React/RCTBridgeModule.h>
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -17,6 +17,8 @@ struct JmapFetchResult {
|
|||||||
|
|
||||||
enum JmapError: Error {
|
enum JmapError: Error {
|
||||||
case noCredentials
|
case noCredentials
|
||||||
|
/// The access token needs a refresh the caller didn't allow.
|
||||||
|
case needsRefresh
|
||||||
case http(Int)
|
case http(Int)
|
||||||
case badResponse
|
case badResponse
|
||||||
}
|
}
|
||||||
@@ -47,11 +49,18 @@ enum Jmap {
|
|||||||
/// access token that is about to expire. A refreshed bundle is written back
|
/// access token that is about to expire. A refreshed bundle is written back
|
||||||
/// to the shared keychain item so the app picks it up (and doesn't refresh
|
/// to the shared keychain item so the app picks it up (and doesn't refresh
|
||||||
/// again with a refresh token the server has already replaced).
|
/// again with a refresh token the server has already replaced).
|
||||||
static func authorization(credentialsKey: String) async throws -> String {
|
///
|
||||||
|
/// `allowRefresh: false` throws instead of refreshing: the app's foreground
|
||||||
|
/// renewal leaves that to the app's own code, which runs then too and would
|
||||||
|
/// race it for the same refresh token.
|
||||||
|
static func authorization(credentialsKey: String, allowRefresh: Bool = true) async throws -> String {
|
||||||
guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials }
|
guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials }
|
||||||
var creds = StoredCredentials(json: json)
|
var creds = StoredCredentials(json: json)
|
||||||
if let token = creds.accessToken {
|
if let token = creds.accessToken {
|
||||||
let nowMs = Date().timeIntervalSince1970 * 1000
|
let nowMs = Date().timeIntervalSince1970 * 1000
|
||||||
|
if !allowRefresh, let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000 {
|
||||||
|
throw JmapError.needsRefresh
|
||||||
|
}
|
||||||
if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000,
|
if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000,
|
||||||
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
|
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
|
||||||
let url = URL(string: endpoint) {
|
let url = URL(string: endpoint) {
|
||||||
@@ -99,6 +108,67 @@ enum Jmap {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One JMAP request; the method responses as the server sent them.
|
||||||
|
static func call(apiUrl: URL, authorization: String, using: [String], methodCalls: [[Any]]) async throws -> [[Any]] {
|
||||||
|
var request = URLRequest(url: apiUrl)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue(authorization, forHTTPHeaderField: "Authorization")
|
||||||
|
let body: [String: Any] = ["using": using, "methodCalls": methodCalls]
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
|
||||||
|
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
|
||||||
|
guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||||
|
let responses = root["methodResponses"] as? [[Any]] else { throw JmapError.badResponse }
|
||||||
|
return responses
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ListedPushSubscription {
|
||||||
|
let id: String
|
||||||
|
let deviceClientId: String?
|
||||||
|
let expires: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
static let pushSubscriptionProperties = ["id", "deviceClientId", "expires"]
|
||||||
|
|
||||||
|
/// Subscription `id` as the PushSubscription/get answer `callId` lists it.
|
||||||
|
static func listedPushSubscription(_ responses: [[Any]], callId: String, id: String) -> ListedPushSubscription? {
|
||||||
|
for response in responses where response.count >= 3 {
|
||||||
|
guard response[0] as? String == "PushSubscription/get", response[2] as? String == callId,
|
||||||
|
let args = response[1] as? [String: Any],
|
||||||
|
let list = args["list"] as? [[String: Any]] else { continue }
|
||||||
|
for item in list where item["id"] as? String == id {
|
||||||
|
return ListedPushSubscription(
|
||||||
|
id: id,
|
||||||
|
deviceClientId: item["deviceClientId"] as? String,
|
||||||
|
expires: item["expires"] as? String
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pushSubscription(apiUrl: URL, id: String, authorization: String) async throws -> ListedPushSubscription? {
|
||||||
|
let methodCalls: [[Any]] = [
|
||||||
|
["PushSubscription/get", ["ids": [id], "properties": pushSubscriptionProperties], "0"],
|
||||||
|
]
|
||||||
|
let responses = try await call(apiUrl: apiUrl, authorization: authorization, using: ["urn:ietf:params:jmap:core"], methodCalls: methodCalls)
|
||||||
|
return listedPushSubscription(responses, callId: "0", id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks for a later expiry and reads back the one the server settled on:
|
||||||
|
/// servers clamp it, and the update's answer doesn't say.
|
||||||
|
static func extendPushSubscription(apiUrl: URL, id: String, expires: String, authorization: String) async throws -> ListedPushSubscription? {
|
||||||
|
let methodCalls: [[Any]] = [
|
||||||
|
["PushSubscription/set", ["update": [id: ["expires": expires]]], "0"],
|
||||||
|
["PushSubscription/get", ["ids": [id], "properties": pushSubscriptionProperties], "1"],
|
||||||
|
]
|
||||||
|
let responses = try await call(apiUrl: apiUrl, authorization: authorization, using: ["urn:ietf:params:jmap:core"], methodCalls: methodCalls)
|
||||||
|
return listedPushSubscription(responses, callId: "1", id: id)
|
||||||
|
}
|
||||||
|
|
||||||
static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult {
|
static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult {
|
||||||
var request = URLRequest(url: apiUrl)
|
var request = URLRequest(url: apiUrl)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import Foundation
|
|||||||
import os
|
import os
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
/// Ids and steps only, never credentials or message content.
|
|
||||||
let pushLog = Logger(subsystem: "org.bulwarkmail.mobile.NotificationService", category: "push")
|
|
||||||
|
|
||||||
/// Turns the relay's content-blind push ("New message" plus ids) into a real
|
/// Turns the relay's content-blind push ("New message" plus ids) into a real
|
||||||
/// notification: sender, subject and preview, fetched over JMAP on this
|
/// notification: sender, subject and preview, fetched over JMAP on this
|
||||||
/// device with the account's own credentials. Nothing about the message ever
|
/// device with the account's own credentials. Nothing about the message ever
|
||||||
/// passes through the relay or Apple.
|
/// passes through the relay or Apple.
|
||||||
///
|
///
|
||||||
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
|
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
|
||||||
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl, label}
|
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl, label,
|
||||||
|
/// subscriptionId, deviceClientId,
|
||||||
|
/// subscriptionExpires}
|
||||||
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
|
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
|
||||||
/// <credentialsKey> the account's stored credentials
|
/// <credentialsKey> the account's stored credentials
|
||||||
/// If anything is missing or the server can't be reached, the relay's
|
/// If anything is missing or the server can't be reached, the relay's
|
||||||
@@ -30,10 +29,23 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
contentHandler(request.content)
|
contentHandler(request.content)
|
||||||
return
|
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
|
placeholder = content
|
||||||
let push = request.content.userInfo["body"] as? [String: Any]
|
let push = request.content.userInfo["body"] as? [String: Any]
|
||||||
work = Task { [weak self] in
|
work = Task { [weak self] in
|
||||||
let enriched = await NotificationService.enrich(content, push: push)
|
let enriched = await NotificationService.enrich(content, push: push)
|
||||||
|
// A timeout from here on shows what was fetched.
|
||||||
|
self?.placeholder = enriched
|
||||||
|
// Keep the account's subscription alive (PushRenewal.swift). Does
|
||||||
|
// nothing until it is less than six days from expiring, so this costs
|
||||||
|
// a request or two about once a day. After the fetch, not beside it:
|
||||||
|
// both may refresh the same OAuth token.
|
||||||
|
if let accountId = push?["accountId"] as? String, !accountId.isEmpty {
|
||||||
|
await PushRenewal.renewIfDue(jmapAccountId: accountId)
|
||||||
|
}
|
||||||
self?.finish(enriched)
|
self?.finish(enriched)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +62,7 @@ final class NotificationService: UNNotificationServiceExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func sanitize(_ id: String) -> String {
|
static func sanitize(_ id: String) -> String {
|
||||||
String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" })
|
SharedKeychain.keyPart(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent {
|
static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Keeps push alive on iOS. Stalwart (0.16 and 1.0) drops a PushSubscription
|
||||||
|
/// seven days after its last refresh, and the app's JS refreshes it only when
|
||||||
|
/// the app starts. On Android a push handled in the background extends it
|
||||||
|
/// (src/lib/push-background-task.ts extendPushSubscription); on iOS pushes
|
||||||
|
/// reach the notification extension, not the app's JS, so someone who reads
|
||||||
|
/// mail from the notifications without opening the app lost push after a
|
||||||
|
/// week. The same steps run here instead: in the notification extension for
|
||||||
|
/// the pushed account, and in the app (PushRenewalTask.swift) on foreground
|
||||||
|
/// and from a background refresh task for every account.
|
||||||
|
///
|
||||||
|
/// The app shares each account's subscription in the account's entry
|
||||||
|
/// (src/lib/ios-push-shared.ts IosPushAccount): subscriptionId,
|
||||||
|
/// deviceClientId and subscriptionExpires. Only a subscription that is
|
||||||
|
/// recorded there and has less than six days left (or an unknown expiry) is
|
||||||
|
/// looked at; then the server's copy is read first, since the record can be
|
||||||
|
/// older than it, and only this device's own subscription is extended.
|
||||||
|
enum PushRenewal {
|
||||||
|
static let accountPrefix = "bulwark_push_account__"
|
||||||
|
/// BACKGROUND_EXTEND_BELOW_MS in src/lib/push-notifications.ts.
|
||||||
|
static let extendBelowSeconds: TimeInterval = 6 * 24 * 60 * 60
|
||||||
|
/// SUBSCRIPTION_EXPIRES_DAYS in src/lib/push-notifications.ts; servers clamp it.
|
||||||
|
static let wantedSeconds: TimeInterval = 90 * 24 * 60 * 60
|
||||||
|
|
||||||
|
static func parseDate(_ value: String?) -> Date? {
|
||||||
|
guard let value, !value.isEmpty else { return nil }
|
||||||
|
let fractional = ISO8601DateFormatter()
|
||||||
|
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A JMAP UTCDate, without fractional seconds.
|
||||||
|
static func formatDate(_ date: Date) -> String {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// pushSubscriptionNeedsExtension in src/lib/push-notifications.ts.
|
||||||
|
static func needsExtension(_ expires: String?, now: Date = Date()) -> Bool {
|
||||||
|
guard let at = parseDate(expires) else { return true }
|
||||||
|
return at.timeIntervalSince(now) < extendBelowSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server that reused the id may hand back another device's subscription.
|
||||||
|
static func isOwn(_ listed: Jmap.ListedPushSubscription, deviceClientId: String?) -> Bool {
|
||||||
|
guard let theirs = listed.deviceClientId, let ours = deviceClientId else { return true }
|
||||||
|
return theirs == ours
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pushed account's subscription, if it is due.
|
||||||
|
static func renewIfDue(jmapAccountId: String) async {
|
||||||
|
await renew(key: accountPrefix + SharedKeychain.keyPart(jmapAccountId), allowRefresh: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every shared account's subscription that is due, one after the other.
|
||||||
|
static func renewAll(allowRefresh: Bool) async {
|
||||||
|
for key in SharedKeychain.keys(withPrefix: accountPrefix) {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await renew(key: key, allowRefresh: allowRefresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the subscription was extended. Best effort: anything that
|
||||||
|
/// fails is tried again on the next push, foreground or refresh task.
|
||||||
|
@discardableResult
|
||||||
|
static func renew(key: String, allowRefresh: Bool) async -> Bool {
|
||||||
|
guard let account = SharedKeychain.readJSON(key),
|
||||||
|
let subscriptionId = account["subscriptionId"] as? String, !subscriptionId.isEmpty,
|
||||||
|
let credentialsKey = account["credentialsKey"] as? String,
|
||||||
|
let apiString = account["apiUrl"] as? String,
|
||||||
|
let apiUrl = URL(string: apiString) else { return false }
|
||||||
|
guard needsExtension(account["subscriptionExpires"] as? String) else { return false }
|
||||||
|
let deviceClientId = account["deviceClientId"] as? String
|
||||||
|
do {
|
||||||
|
let authorization = try await Jmap.authorization(credentialsKey: credentialsKey, allowRefresh: allowRefresh)
|
||||||
|
guard let current = try await Jmap.pushSubscription(apiUrl: apiUrl, id: subscriptionId, authorization: authorization),
|
||||||
|
isOwn(current, deviceClientId: deviceClientId) else {
|
||||||
|
// Lapsed or revoked: the app's resync tells the two apart.
|
||||||
|
pushLog.info("subscription \(subscriptionId, privacy: .public) is not on the server")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The recorded expiry was stale: the server's copy has time left.
|
||||||
|
if let expires = current.expires, !needsExtension(expires) {
|
||||||
|
record(key: key, account: account, expires: expires)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let wanted = formatDate(Date().addingTimeInterval(wantedSeconds))
|
||||||
|
let extended = try await Jmap.extendPushSubscription(
|
||||||
|
apiUrl: apiUrl, id: subscriptionId, expires: wanted, authorization: authorization)
|
||||||
|
if let expires = extended?.expires { record(key: key, account: account, expires: expires) }
|
||||||
|
pushLog.info("extended subscription \(subscriptionId, privacy: .public)")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
pushLog.error("renewal failed: \(String(describing: error), privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func record(key: String, account: [String: Any], expires: String) {
|
||||||
|
var json = account
|
||||||
|
json["subscriptionExpires"] = expires
|
||||||
|
SharedKeychain.update(key, json: json)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// The app's side of PushRenewal: every shared account's subscription is
|
||||||
|
/// renewed when the app comes back to the foreground (the JS refresh runs
|
||||||
|
/// only on a cold start), and from a background app refresh task that iOS
|
||||||
|
/// runs now and then while the app is not used at all.
|
||||||
|
///
|
||||||
|
/// The config plugin (plugins/ios-push/index.js) compiles this into the app,
|
||||||
|
/// calls register() from AppDelegate's didFinishLaunching (BGTaskScheduler
|
||||||
|
/// wants its handlers registered before launch ends), and lists the task id
|
||||||
|
/// in Info.plist (BGTaskSchedulerPermittedIdentifiers, UIBackgroundModes
|
||||||
|
/// fetch).
|
||||||
|
enum PushRenewalTask {
|
||||||
|
static let identifier = "org.bulwarkmail.mobile.push-renewal"
|
||||||
|
/// A subscription lasts seven days; asking twice a day leaves iOS room to
|
||||||
|
/// skip runs (it decides when, and whether, the task runs).
|
||||||
|
static let interval: TimeInterval = 12 * 60 * 60
|
||||||
|
private static var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
|
static func register() {
|
||||||
|
let registered = BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
|
||||||
|
guard let refresh = task as? BGAppRefreshTask else {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handle(refresh)
|
||||||
|
}
|
||||||
|
if !registered { pushLog.error("push renewal task not registered") }
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
observers = [
|
||||||
|
center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { _ in
|
||||||
|
// No token refresh here: the app's own code refreshes on foreground.
|
||||||
|
Task { await PushRenewal.renewAll(allowRefresh: false) }
|
||||||
|
},
|
||||||
|
center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in
|
||||||
|
schedule()
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func schedule() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: identifier)
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
// The simulator has no background refresh.
|
||||||
|
pushLog.error("push renewal task not scheduled: \(String(describing: error), privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handle(_ task: BGAppRefreshTask) {
|
||||||
|
schedule()
|
||||||
|
let work = Task {
|
||||||
|
await PushRenewal.renewAll(allowRefresh: true)
|
||||||
|
task.setTaskCompleted(success: !Task.isCancelled)
|
||||||
|
}
|
||||||
|
task.expirationHandler = { work.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
|
/// Ids and steps only, never credentials or message content. Shared by the
|
||||||
|
/// notification extension and the app's push renewal (PushRenewal.swift).
|
||||||
|
let pushLog = os.Logger(subsystem: Bundle.main.bundleIdentifier ?? "org.bulwarkmail.mobile", category: "push")
|
||||||
|
|
||||||
/// Items the app shares with this extension through a keychain access group
|
/// Items the app shares with this extension through a keychain access group
|
||||||
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
|
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
|
||||||
/// expo-secure-store: generic passwords whose account and generic attributes
|
/// expo-secure-store: generic passwords whose account and generic attributes
|
||||||
@@ -31,6 +36,38 @@ enum SharedKeychain {
|
|||||||
return item as? Data
|
return item as? Data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SecureStore keys: letters, digits, ".", "-", "_" only. The app builds
|
||||||
|
/// the same key part (src/lib/ios-push-shared.ts keyPart).
|
||||||
|
static func keyPart(_ id: String) -> String {
|
||||||
|
String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keys of the shared items whose key starts with `prefix`.
|
||||||
|
static func keys(withPrefix prefix: String) -> [String] {
|
||||||
|
guard let group else { return [] }
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccessGroup as String: group,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes as String: true,
|
||||||
|
]
|
||||||
|
var items: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &items)
|
||||||
|
guard status == errSecSuccess, let list = items as? [[String: Any]] else {
|
||||||
|
if status != errSecItemNotFound {
|
||||||
|
pushLog.error("keychain listing failed: \(status, privacy: .public)")
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return list.compactMap { item in
|
||||||
|
let account = item[kSecAttrAccount as String]
|
||||||
|
let key = (account as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? (account as? String)
|
||||||
|
guard let key, key.hasPrefix(prefix) else { return nil }
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func readJSON(_ key: String) -> [String: Any]? {
|
static func readJSON(_ key: String) -> [String: Any]? {
|
||||||
guard let data = read(key) else { return nil }
|
guard let data = read(key) else { return nil }
|
||||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
// only ids; the extension fetches sender and subject over JMAP on the device
|
// only ids; the extension fetches sender and subject over JMAP on the device
|
||||||
// before iOS shows the notification. Its sources live next to this file and
|
// before iOS shows the notification. Its sources live next to this file and
|
||||||
// are copied into ios/ at prebuild, like everything else in ios/.
|
// are copied into ios/ at prebuild, like everything else in ios/.
|
||||||
|
// - Push renewal (PushRenewal.swift): Stalwart drops a subscription seven days
|
||||||
|
// after its last refresh. The extension extends the pushed account's, and
|
||||||
|
// the app (PushRenewalTask.swift, compiled into the app target) every
|
||||||
|
// account's on foreground and from a background app refresh task, which
|
||||||
|
// AppDelegate registers at launch and Info.plist permits.
|
||||||
//
|
//
|
||||||
// Options (app.config.js):
|
// Options (app.config.js):
|
||||||
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
|
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
|
||||||
@@ -20,7 +25,10 @@
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const {
|
const {
|
||||||
|
IOSConfig,
|
||||||
|
withAppDelegate,
|
||||||
withEntitlementsPlist,
|
withEntitlementsPlist,
|
||||||
|
withInfoPlist,
|
||||||
withXcodeProject,
|
withXcodeProject,
|
||||||
withDangerousMod,
|
withDangerousMod,
|
||||||
createRunOncePlugin,
|
createRunOncePlugin,
|
||||||
@@ -28,7 +36,17 @@ const {
|
|||||||
|
|
||||||
const TARGET = 'NotificationService';
|
const TARGET = 'NotificationService';
|
||||||
const DEPLOYMENT_TARGET = '15.1';
|
const DEPLOYMENT_TARGET = '15.1';
|
||||||
const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift'];
|
const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift'];
|
||||||
|
// Compiled into the app as well: the renewal on foreground and in the
|
||||||
|
// background refresh task.
|
||||||
|
const APP_SOURCE_FILES = ['SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift', 'PushRenewalTask.swift'];
|
||||||
|
// PushRenewalTask.identifier
|
||||||
|
const RENEWAL_TASK_ID = 'org.bulwarkmail.mobile.push-renewal';
|
||||||
|
const RENEWAL_REGISTER_CALL = 'PushRenewalTask.register()';
|
||||||
|
// Where Expo's AppDelegate.swift hands launch to its superclass; the
|
||||||
|
// registration has to run before that returns (Expo's Maps plugin uses the
|
||||||
|
// same anchor).
|
||||||
|
const APP_DELEGATE_LAUNCH = /^([ \t]*)return super\.application\(\w+, didFinishLaunchingWithOptions: \w+\)/m;
|
||||||
|
|
||||||
function sharedGroupSuffix() {
|
function sharedGroupSuffix() {
|
||||||
return 'org.bulwarkmail.shared';
|
return 'org.bulwarkmail.shared';
|
||||||
@@ -111,11 +129,70 @@ function withExtensionFiles(config) {
|
|||||||
infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
|
infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist());
|
fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist());
|
||||||
|
const appDir = path.join(cfg.modRequest.platformProjectRoot, cfg.modRequest.projectName);
|
||||||
|
for (const file of APP_SOURCE_FILES) {
|
||||||
|
fs.copyFileSync(path.join(__dirname, file), path.join(appDir, file));
|
||||||
|
}
|
||||||
return cfg;
|
return cfg;
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compile the renewal sources into the app target. */
|
||||||
|
function addAppSources(cfg) {
|
||||||
|
const projectName = cfg.modRequest.projectName;
|
||||||
|
for (const file of APP_SOURCE_FILES) {
|
||||||
|
IOSConfig.XcodeUtils.addBuildSourceFileToGroup({
|
||||||
|
filepath: `${projectName}/${file}`,
|
||||||
|
groupName: projectName,
|
||||||
|
project: cfg.modResults,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's Info.plist entries for the renewal: the refresh task id, the
|
||||||
|
* background mode it needs, and the keychain group SharedKeychain reads
|
||||||
|
* (the extension's Info.plist names the same group).
|
||||||
|
*/
|
||||||
|
function setRenewalInfoPlist(plist, { teamId }) {
|
||||||
|
const ids = new Set(plist.BGTaskSchedulerPermittedIdentifiers ?? []);
|
||||||
|
ids.add(RENEWAL_TASK_ID);
|
||||||
|
plist.BGTaskSchedulerPermittedIdentifiers = [...ids];
|
||||||
|
const modes = new Set(plist.UIBackgroundModes ?? []);
|
||||||
|
modes.add('fetch');
|
||||||
|
plist.UIBackgroundModes = [...modes];
|
||||||
|
plist.BulwarkKeychainGroup = `${teamId || '$(DEVELOPMENT_TEAM)'}.${sharedGroupSuffix()}`;
|
||||||
|
return plist;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AppDelegate.swift with the renewal task registered during launch. */
|
||||||
|
function addRenewalToAppDelegate(contents) {
|
||||||
|
if (contents.includes(RENEWAL_REGISTER_CALL)) return contents;
|
||||||
|
if (!APP_DELEGATE_LAUNCH.test(contents)) {
|
||||||
|
throw new Error('plugins/ios-push: AppDelegate.swift has no didFinishLaunching return to register push renewal before');
|
||||||
|
}
|
||||||
|
return contents.replace(
|
||||||
|
APP_DELEGATE_LAUNCH,
|
||||||
|
(line, indent) => `${indent}// plugins/ios-push: BGTaskScheduler handlers must exist before launch ends.\n${indent}${RENEWAL_REGISTER_CALL}\n${line}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRenewal(config, { teamId }) {
|
||||||
|
config = withInfoPlist(config, (cfg) => {
|
||||||
|
setRenewalInfoPlist(cfg.modResults, { teamId });
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
config = withAppDelegate(config, (cfg) => {
|
||||||
|
if (cfg.modResults.language !== 'swift') {
|
||||||
|
throw new Error('plugins/ios-push: push renewal expects a Swift AppDelegate');
|
||||||
|
}
|
||||||
|
cfg.modResults.contents = addRenewalToAppDelegate(cfg.modResults.contents);
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
|
function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
|
||||||
if (!appProfile && !extensionProfile) return;
|
if (!appProfile && !extensionProfile) return;
|
||||||
const project = cfg.modResults;
|
const project = cfg.modResults;
|
||||||
@@ -137,6 +214,7 @@ function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
|
|||||||
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
|
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
|
||||||
return withXcodeProject(config, (cfg) => {
|
return withXcodeProject(config, (cfg) => {
|
||||||
addExtensionTarget(cfg, { teamId });
|
addExtensionTarget(cfg, { teamId });
|
||||||
|
addAppSources(cfg);
|
||||||
setReleaseProfiles(cfg, { appProfile, extensionProfile });
|
setReleaseProfiles(cfg, { appProfile, extensionProfile });
|
||||||
return cfg;
|
return cfg;
|
||||||
});
|
});
|
||||||
@@ -189,7 +267,10 @@ function withIosPush(config, options = {}) {
|
|||||||
config = withAppEntitlements(config, { apsEnvironment });
|
config = withAppEntitlements(config, { apsEnvironment });
|
||||||
config = withExtensionFiles(config);
|
config = withExtensionFiles(config);
|
||||||
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
|
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
|
||||||
|
config = withRenewal(config, { teamId });
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');
|
module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');
|
||||||
|
// For the tests (src/lib/__tests__/ios-push-renewal.test.ts).
|
||||||
|
module.exports.internals = { addRenewalToAppDelegate, setRenewalInfoPlist, SOURCE_FILES, APP_SOURCE_FILES, RENEWAL_TASK_ID };
|
||||||
|
|||||||
@@ -0,0 +1,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<InboxEntry>) -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Exposes BulwarkWidgetKit.swift to React Native as NativeModules.BulwarkWidgetKit.
|
||||||
|
|
||||||
|
#import <React/RCTBridgeModule.h>
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Bulwark Mail</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>${version}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>${buildNumber}</string>
|
||||||
|
<key>BulwarkKeychainGroup</key>
|
||||||
|
<string>$(DEVELOPMENT_TEAM).${SHARED_GROUP_SUFFIX}</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitlementsPlist() {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)${SHARED_GROUP_SUFFIX}</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 };
|
||||||
@@ -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'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
vi.mock('../ios-push-shared', () => ({
|
||||||
|
apnsEnvironment: () => 'production',
|
||||||
|
writeIosPushAccount: vi.fn(async () => undefined),
|
||||||
|
removeIosPushAccount: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { shareAccountWithExtension } from '../push-notifications';
|
||||||
|
import { writeIosPushAccount } from '../ios-push-shared';
|
||||||
|
|
||||||
|
// iOS push lapsed a week after the app was last opened: Stalwart drops a
|
||||||
|
// subscription seven days after its last refresh, and only the app's JS
|
||||||
|
// refreshed it. The native side now extends it (plugins/ios-push/
|
||||||
|
// PushRenewal.swift) from the notification extension, on foreground and from
|
||||||
|
// a background refresh task, with what the app shares about the subscription.
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '../../..');
|
||||||
|
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
||||||
|
|
||||||
|
describe('the subscription the app shares with the native renewal', () => {
|
||||||
|
const API = 'https://mail.example.org/jmap/';
|
||||||
|
const LOCAL = 'ada@mail.example.org';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await AsyncStorage.clear();
|
||||||
|
(Platform as { OS: string }).OS = 'ios';
|
||||||
|
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [LOCAL]: 'c' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(Platform as { OS: string }).OS = 'android';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the subscription id, this device\'s client id and the recorded expiry', async () => {
|
||||||
|
await AsyncStorage.setItem(`push:subscriptionId:v2:${LOCAL}`, 'b');
|
||||||
|
await AsyncStorage.setItem(`push:deviceClientId:v2:${LOCAL}`, 'dev-1');
|
||||||
|
await AsyncStorage.setItem(`push:subscriptionExpires:v1:${LOCAL}`, '2026-11-07T10:00:00Z');
|
||||||
|
await shareAccountWithExtension(LOCAL, 'c', API, 'ada');
|
||||||
|
expect(writeIosPushAccount).toHaveBeenCalledWith('c', {
|
||||||
|
credentialsKey: expect.any(String),
|
||||||
|
apiUrl: API,
|
||||||
|
label: 'ada',
|
||||||
|
subscriptionId: 'b',
|
||||||
|
deviceClientId: 'dev-1',
|
||||||
|
subscriptionExpires: '2026-11-07T10:00:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares no subscription for an account the user turned push off for', async () => {
|
||||||
|
await AsyncStorage.setItem(`push:subscriptionId:v2:${LOCAL}`, 'b');
|
||||||
|
await AsyncStorage.setItem(`push:optedOut:v1:${LOCAL}`, '1');
|
||||||
|
await shareAccountWithExtension(LOCAL, 'c', API);
|
||||||
|
expect(writeIosPushAccount).toHaveBeenCalledWith('c', { credentialsKey: expect.any(String), apiUrl: API });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the config plugin wires the renewal into the app', () => {
|
||||||
|
const plugin = createRequire(__filename)(path.join(ROOT, 'plugins/ios-push/index.js')) as {
|
||||||
|
internals: {
|
||||||
|
addRenewalToAppDelegate(contents: string): string;
|
||||||
|
setRenewalInfoPlist(plist: Record<string, unknown>, opts: { teamId: string }): Record<string, unknown>;
|
||||||
|
SOURCE_FILES: string[];
|
||||||
|
APP_SOURCE_FILES: string[];
|
||||||
|
RENEWAL_TASK_ID: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const { internals } = plugin;
|
||||||
|
|
||||||
|
// Expo SDK 54's AppDelegate.swift, shortened.
|
||||||
|
const APP_DELEGATE = [
|
||||||
|
'public class AppDelegate: ExpoAppDelegate {',
|
||||||
|
' public override func application(',
|
||||||
|
' _ application: UIApplication,',
|
||||||
|
' didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil',
|
||||||
|
' ) -> Bool {',
|
||||||
|
' bindReactNativeFactory(factory)',
|
||||||
|
'',
|
||||||
|
' return super.application(application, didFinishLaunchingWithOptions: launchOptions)',
|
||||||
|
' }',
|
||||||
|
'}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
it('registers the refresh task in didFinishLaunching, before it returns', () => {
|
||||||
|
const out = internals.addRenewalToAppDelegate(APP_DELEGATE);
|
||||||
|
const call = out.indexOf(' PushRenewalTask.register()\n');
|
||||||
|
expect(call).toBeGreaterThan(out.indexOf('bindReactNativeFactory'));
|
||||||
|
expect(call).toBeLessThan(out.indexOf('return super.application('));
|
||||||
|
// Once, however often prebuild runs.
|
||||||
|
expect(internals.addRenewalToAppDelegate(out)).toBe(out);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the prebuild rather than skipping the registration', () => {
|
||||||
|
expect(() => internals.addRenewalToAppDelegate('class AppDelegate {}')).toThrow(/didFinishLaunching/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permits the task, adds the fetch background mode and names the shared keychain group', () => {
|
||||||
|
const plist = internals.setRenewalInfoPlist({ UIBackgroundModes: ['remote-notification'] }, { teamId: 'TEAM' });
|
||||||
|
expect(plist.BGTaskSchedulerPermittedIdentifiers).toEqual(['org.bulwarkmail.mobile.push-renewal']);
|
||||||
|
expect(plist.UIBackgroundModes).toEqual(['remote-notification', 'fetch']);
|
||||||
|
expect(plist.BulwarkKeychainGroup).toBe('TEAM.org.bulwarkmail.shared');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compiles the renewal into the extension and the app', () => {
|
||||||
|
expect(internals.SOURCE_FILES).toContain('PushRenewal.swift');
|
||||||
|
expect(internals.APP_SOURCE_FILES).toEqual(
|
||||||
|
expect.arrayContaining(['SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift', 'PushRenewalTask.swift']),
|
||||||
|
);
|
||||||
|
expect(internals.APP_SOURCE_FILES).not.toContain('NotificationService.swift');
|
||||||
|
for (const file of [...internals.SOURCE_FILES, ...internals.APP_SOURCE_FILES]) {
|
||||||
|
expect(fs.existsSync(path.join(ROOT, 'plugins/ios-push', file))).toBe(true);
|
||||||
|
}
|
||||||
|
expect(read('plugins/ios-push/PushRenewalTask.swift')).toContain(`static let identifier = "${internals.RENEWAL_TASK_ID}"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the native renewal', () => {
|
||||||
|
const renewal = read('plugins/ios-push/PushRenewal.swift');
|
||||||
|
const task = read('plugins/ios-push/PushRenewalTask.swift');
|
||||||
|
const nse = read('plugins/ios-push/NotificationService.swift');
|
||||||
|
const js = read('src/lib/push-notifications.ts');
|
||||||
|
|
||||||
|
it('extends at the same point and asks for the same lifetime as the Android push task', () => {
|
||||||
|
expect(js).toMatch(/const BACKGROUND_EXTEND_BELOW_MS = 6 \* 24 \* 60 \* 60 \* 1000;/);
|
||||||
|
expect(renewal).toMatch(/static let extendBelowSeconds: TimeInterval = 6 \* 24 \* 60 \* 60\r?\n/);
|
||||||
|
expect(js).toMatch(/const SUBSCRIPTION_EXPIRES_DAYS = 90;/);
|
||||||
|
expect(renewal).toMatch(/static let wantedSeconds: TimeInterval = 90 \* 24 \* 60 \* 60\r?\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the server\'s copy first and extends only this device\'s own subscription', () => {
|
||||||
|
const body = renewal.slice(renewal.indexOf('static func renew(key:'));
|
||||||
|
const get = body.indexOf('Jmap.pushSubscription(');
|
||||||
|
const own = body.indexOf('isOwn(current, deviceClientId: deviceClientId)');
|
||||||
|
const set = body.indexOf('Jmap.extendPushSubscription(');
|
||||||
|
expect(get).toBeGreaterThan(-1);
|
||||||
|
expect(own).toBeGreaterThan(get);
|
||||||
|
expect(set).toBeGreaterThan(own);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs from the notification extension after the fetch for the pushed account', () => {
|
||||||
|
const renew = nse.indexOf('await PushRenewal.renewIfDue(jmapAccountId: accountId)');
|
||||||
|
expect(renew).toBeGreaterThan(nse.indexOf('await NotificationService.enrich('));
|
||||||
|
expect(renew).toBeLessThan(nse.indexOf('self?.finish(enriched)'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs from the app on foreground without refreshing tokens, and from a background refresh task', () => {
|
||||||
|
expect(task).toMatch(/willEnterForegroundNotification[\s\S]*?PushRenewal\.renewAll\(allowRefresh: false\)/);
|
||||||
|
expect(task).toContain('BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier');
|
||||||
|
expect(task).toMatch(/BGAppRefreshTaskRequest\(identifier: identifier\)/);
|
||||||
|
expect(task).toMatch(/task\.expirationHandler = \{ work\.cancel\(\) \}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-12
@@ -2,8 +2,11 @@
|
|||||||
// clear flow and exposes a `secureFetch` that mirrors the global `fetch` API
|
// clear flow and exposes a `secureFetch` that mirrors the global `fetch` API
|
||||||
// closely enough that callers can swap them out.
|
// closely enough that callers can swap them out.
|
||||||
//
|
//
|
||||||
// On non-Android platforms (or when the user has not picked a cert) we fall
|
// Android (BulwarkClientCertModule.kt) picks a certificate from the system's
|
||||||
// straight back to the global `fetch` so we don't pay any bridge overhead.
|
// 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 = {
|
type Native = {
|
||||||
getAlias(): Promise<string | null>;
|
getAlias(): Promise<string | null>;
|
||||||
@@ -29,17 +32,18 @@ type Native = {
|
|||||||
let nativeProbed = false;
|
let nativeProbed = false;
|
||||||
let nativeModule: Native | null = null;
|
let nativeModule: Native | null = null;
|
||||||
|
|
||||||
|
/** The client-certificate module react-native offers, on any platform that has one. */
|
||||||
|
export function clientCertModuleOf(rn: { NativeModules: Record<string, unknown> }): Native | null {
|
||||||
|
return (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function getNative(): Native | null {
|
function getNative(): Native | null {
|
||||||
if (nativeProbed) return nativeModule;
|
if (nativeProbed) return nativeModule;
|
||||||
nativeProbed = true;
|
nativeProbed = true;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const rn = require('react-native') as {
|
const rn = require('react-native') as { NativeModules: Record<string, unknown> };
|
||||||
Platform: { OS: string };
|
nativeModule = clientCertModuleOf(rn);
|
||||||
NativeModules: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
if (rn.Platform.OS !== 'android') return (nativeModule = null);
|
|
||||||
nativeModule = (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? null;
|
|
||||||
} catch {
|
} catch {
|
||||||
nativeModule = null;
|
nativeModule = null;
|
||||||
}
|
}
|
||||||
@@ -63,7 +67,7 @@ export async function getClientCertAlias(): Promise<string | null> {
|
|||||||
|
|
||||||
export async function pickClientCertAlias(host: string | null): Promise<string | null> {
|
export async function pickClientCertAlias(host: string | null): Promise<string | null> {
|
||||||
const native = getNative();
|
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);
|
const alias = await native.pickAlias(host);
|
||||||
cachedAlias = alias;
|
cachedAlias = alias;
|
||||||
return alias;
|
return alias;
|
||||||
@@ -144,9 +148,9 @@ function buildResponse(raw: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop-in replacement for `fetch` that routes through the native client when
|
* 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
|
* the user has selected a client certificate. When no cert is set (or there
|
||||||
* on iOS), it just calls the platform `fetch` and the caller pays no
|
* is no native module), it just calls the platform `fetch` and the caller
|
||||||
* bridge overhead.
|
* pays no bridge overhead.
|
||||||
*/
|
*/
|
||||||
export interface SecureFetchInit extends RequestInit {
|
export interface SecureFetchInit extends RequestInit {
|
||||||
/** Native-path deadline (ms); blob transfers pass a longer one. */
|
/** Native-path deadline (ms); blob transfers pass a longer one. */
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ export interface IosPushAccount {
|
|||||||
* group account that has this account's id.
|
* group account that has this account's id.
|
||||||
*/
|
*/
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/**
|
||||||
|
* This device's PushSubscription on the account's server, so the native
|
||||||
|
* side keeps it alive (plugins/ios-push/PushRenewal.swift): Stalwart drops
|
||||||
|
* a subscription seven days after its last refresh, and on iOS the app's JS
|
||||||
|
* runs only when the app is opened. The native side extends it only while
|
||||||
|
* the server still lists it with this device's client id, and writes the
|
||||||
|
* expiry it settled on back here.
|
||||||
|
*/
|
||||||
|
subscriptionId?: string;
|
||||||
|
deviceClientId?: string;
|
||||||
|
/** When the subscription expires, as last recorded (UTCDate). */
|
||||||
|
subscriptionExpires?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise<void> {
|
export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise<void> {
|
||||||
|
|||||||
@@ -1073,9 +1073,32 @@ export async function shareAccountWithExtension(
|
|||||||
credentialsKey: credentialsKey(accountId),
|
credentialsKey: credentialsKey(accountId),
|
||||||
apiUrl,
|
apiUrl,
|
||||||
...(username ? { label: username } : {}),
|
...(username ? { label: username } : {}),
|
||||||
|
...(await sharedSubscription(accountId)),
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account's subscription as the iOS native renewal needs it
|
||||||
|
* (IosPushAccount.subscriptionId). Nothing for an account the user turned
|
||||||
|
* push off for, or without a recorded subscription: nothing to keep alive.
|
||||||
|
*/
|
||||||
|
async function sharedSubscription(
|
||||||
|
accountId: string,
|
||||||
|
): Promise<{ subscriptionId?: string; deviceClientId?: string; subscriptionExpires?: string }> {
|
||||||
|
const [subscriptionId, deviceClientId, expires, optedOut] = await Promise.all([
|
||||||
|
AsyncStorage.getItem(subscriptionIdKey(accountId)),
|
||||||
|
AsyncStorage.getItem(deviceClientIdKey(accountId)),
|
||||||
|
AsyncStorage.getItem(subscriptionExpiresKey(accountId)),
|
||||||
|
AsyncStorage.getItem(optedOutKey(accountId)),
|
||||||
|
]);
|
||||||
|
if (!subscriptionId || optedOut) return {};
|
||||||
|
return {
|
||||||
|
subscriptionId,
|
||||||
|
...(deviceClientId ? { deviceClientId } : {}),
|
||||||
|
...(expires ? { subscriptionExpires: expires } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a local account (username@host) is the login a relay label names.
|
* Whether a local account (username@host) is the login a relay label names.
|
||||||
* The label is the username the account registered with the relay.
|
* The label is the username the account registered with the relay.
|
||||||
|
|||||||
@@ -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<string, unknown>,
|
||||||
|
}));
|
||||||
|
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('<string>com.apple.widgetkit-extension</string>');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void>; placedCount?: () => Promise<number> };
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { serial } from './serial';
|
|||||||
import { readView } from './state';
|
import { readView } from './state';
|
||||||
import type { WidgetSnapshot } from './snapshot';
|
import type { WidgetSnapshot } from './snapshot';
|
||||||
import { DARK, LIGHT, type WidgetPalette } from './theme';
|
import { DARK, LIGHT, type WidgetPalette } from './theme';
|
||||||
|
import { iosWidgetPlaced, writeIosWidget } from './ios';
|
||||||
import type { Layout, WidgetLocalState } from './layouts/types';
|
import type { Layout, WidgetLocalState } from './layouts/types';
|
||||||
import * as mail from './layouts/mail';
|
import * as mail from './layouts/mail';
|
||||||
import * as moreMail from './layouts/more-mail';
|
import * as moreMail from './layouts/more-mail';
|
||||||
@@ -161,11 +162,13 @@ let queued: Promise<void> | null = null;
|
|||||||
* one drawn is the newest; calls made while one is still waiting share it.
|
* one drawn is the newest; calls made while one is still waiting share it.
|
||||||
*/
|
*/
|
||||||
export function redrawAll(): Promise<void> {
|
export function redrawAll(): Promise<void> {
|
||||||
if (Platform.OS !== 'android') return Promise.resolve();
|
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return Promise.resolve();
|
||||||
if (queued) return queued;
|
if (queued) return queued;
|
||||||
const run = serial(async () => {
|
const run = serial(async () => {
|
||||||
queued = null;
|
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;
|
queued = run;
|
||||||
return 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. */
|
/** Whether any Bulwark widget is on a home screen; cached briefly. */
|
||||||
export async function hasPlacedWidgets(maxAgeMs = 60_000): Promise<boolean> {
|
export async function hasPlacedWidgets(maxAgeMs = 60_000): Promise<boolean> {
|
||||||
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;
|
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 = Platform.OS === 'ios'
|
||||||
const value = counts.some((c) => c > 0);
|
? 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 };
|
placedCache = { at: Date.now(), value };
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-3
@@ -48,6 +48,12 @@ export function handleAppState(next: string): void {
|
|||||||
if (next !== 'background') return;
|
if (next !== 'background') return;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
timer = null;
|
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<boolean> } | undefined;
|
const native = NativeModules.BulwarkWidgets as { refreshInBackground?: () => Promise<boolean> } | undefined;
|
||||||
void (native?.refreshInBackground?.() ?? Promise.resolve(false))
|
void (native?.refreshInBackground?.() ?? Promise.resolve(false))
|
||||||
.catch(() => 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 {
|
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 { useEmailStore } = require('../stores/email-store') as typeof import('../stores/email-store');
|
||||||
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-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');
|
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. */
|
/** Wipe what the widgets show after the last account signed out. */
|
||||||
export async function signOutWidgets(): Promise<void> {
|
export async function signOutWidgets(): Promise<void> {
|
||||||
if (Platform.OS !== 'android') return;
|
if (Platform.OS !== 'android' && Platform.OS !== 'ios') return;
|
||||||
// Signed out of every account, not merely offline or mid-switch.
|
// Signed out of every account, not merely offline or mid-switch.
|
||||||
if ((await readRegistry()).accounts.length > 0) return;
|
if ((await readRegistry()).accounts.length > 0) return;
|
||||||
await replaceAll({ ...emptySnapshot(), generatedAt: Date.now() });
|
await replaceAll({ ...emptySnapshot(), generatedAt: Date.now() });
|
||||||
|
|||||||
Reference in New Issue
Block a user