Swift 6 migration for NSE (#5608)

* swift 6 support for NSE and concurrency safe implementations for it

* enforcing required Sendable conformance for swift 6 migration in NSE

* use Mutex to access the string builder cache

* use an async stream to update timeline items diffs

* fix for Sendable appHooks
This commit is contained in:
Mauro
2026-05-21 20:21:57 +02:00
committed by GitHub
parent 91ccff36fc
commit 2462ddea85
20 changed files with 226 additions and 150 deletions
+3 -3
View File
@@ -250,8 +250,8 @@ struct NotificationContentBuilder {
if let fetchedImage {
image = fetchedImage
} else if let data = await getPlaceholderAvatarImageData(name: icon.groupInfo?.avatarDisplayName ?? senderAvatarDisplayName,
id: icon.groupInfo?.id ?? senderID) {
} else if let data = await Self.getPlaceholderAvatarImageData(name: icon.groupInfo?.avatarDisplayName ?? senderAvatarDisplayName,
id: icon.groupInfo?.id ?? senderID) {
image = INImage(imageData: data)
} else {
image = INImage(named: "")
@@ -309,7 +309,7 @@ struct NotificationContentBuilder {
}
@MainActor
func getPlaceholderAvatarImageData(name: String, id: String) async -> Data? {
static func getPlaceholderAvatarImageData(name: String, id: String) async -> Data? {
// The version value is used in case the design of the placeholder is updated to force a replacement
let prefix = "notification_placeholderV9"
+21 -16
View File
@@ -8,6 +8,7 @@
import CallKit
import MatrixRustSDK
import Synchronization
import UserNotifications
class NotificationHandler {
@@ -18,10 +19,7 @@ class NotificationHandler {
private let tag: String
private let notificationContentBuilder: NotificationContentBuilder
// periphery:ignore - required for instance retention in the rust codebase
private var roomInfoObservationToken: TaskHandle?
init(userSession: NSEUserSession,
settings: CommonSettingsProtocol,
contentHandler: @escaping (UNNotificationContent) -> Void,
@@ -184,21 +182,28 @@ class NotificationHandler {
// Check to see if a call is still ongoing
if let room = userSession.roomForIdentifier(roomID) { // Try to get call details from the room info
if !room.hasActiveRoomCall() { // If I don't have an active call wait a bit and make sure
// Local `Mutex` holder for the SDK subscription token — keeps it alive across the
// await without needing `[weak self]`, which would force this class to be `Sendable`.
let observationToken = Mutex<TaskHandle?>(nil)
let expiringTask = ExpiringTaskRunner {
await withCheckedContinuation { [weak self] continuation in
self?.roomInfoObservationToken = room.subscribeToRoomInfoUpdates(listener: SDKListener { info in
if info.hasRoomCall {
MXLog.info("Received room info update and the room has an active call now.")
continuation.resume()
} else {
MXLog.info("Received a room info update but the room still doesn't have an ongoing call.")
}
})
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
observationToken.withLock { state in
state = room.subscribeToRoomInfoUpdates(listener: SDKListener { info in
if info.hasRoomCall {
MXLog.info("Received room info update and the room has an active call now.")
continuation.resume()
} else {
MXLog.info("Received a room info update but the room still doesn't have an ongoing call.")
}
})
}
}
}
try? await expiringTask.run(timeout: .seconds(5)) // Wait 5 seconds or just use whatever is available
try? await expiringTask.run(timeout: Duration.seconds(5)) // Wait 5 seconds or just use whatever is available
// Release the SDK subscription explicitly once we no longer need updates.
observationToken.withLock { $0 = nil }
guard room.hasActiveRoomCall() else {
MXLog.info("The room no longer has an ongoing call, handling as push notification")
return .shouldDisplay
+60 -37
View File
@@ -8,7 +8,8 @@
import Combine
import MatrixRustSDK
import UserNotifications
import Synchronization
@preconcurrency import UserNotifications
// The lifecycle of the NSE looks something like the following:
// 1) App receives notification
@@ -29,74 +30,96 @@ import UserNotifications
// called on the same instance of `NotificationService` as a previous
// notification.
class NotificationServiceExtension: UNNotificationServiceExtension {
final class NotificationServiceExtension: UNNotificationServiceExtension {
let actor: NotificationServiceExtensionActor
override init() {
actor = NotificationServiceExtensionActor()
super.init()
}
/// This wouldn't be allowed normally because we added `@Sendable` to the contentHandler which is not by default, however there's nothing we can do about it
/// since the `UNNotificationServiceExtension` does not support swift 6 concurency yet, so we need to use the `@preconcurrency import` to suppress
/// this error, and then we handle all the concurrency logic inside the actor that the NSE delegates all the logic to.
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping @Sendable (UNNotificationContent) -> Void) {
let actor = actor
Task { await actor.handle(request, withContentHandler: contentHandler) }
}
override func serviceExtensionTimeWillExpire() {
actor.serviceExtensionTimeWillExpire()
}
}
actor NotificationServiceExtensionActor {
static let receivedWhileOfflineNotificationID = "io.element.elementx.receivedWhileOfflineNotification"
private static var targetConfiguration: Target.ConfigurationResult?
/// Process-wide target configuration, `Mutex`-protected so it can be read/written from any
/// thread (in particular, from the actor's synchronous `init` without spawning a Task).
private static let targetConfiguration = Mutex<Target.ConfigurationResult?>(nil)
@MainActor
private static var hasHandledFirstNotificationSinceBoot = false
private static let firstNotificationThreshold: TimeInterval = 15 * 60
private let settings: CommonSettingsProtocol = AppSettings()
private let appHooks: AppHooks
private var notificationHandler: NotificationHandler?
/// This is nonisolated just because the expire function needs to be served ASAP so we should not call it from a Task, however this is
/// always written and read in the actor isolated context, and only read nonisolated in `serviceExtensionTimeWillExpire` function
/// which is an acceptable compromise since it's a failure state, and the race condition will not make it worse and present a partial notification regardless.
private nonisolated(unsafe) var notificationHandler: NotificationHandler?
private let keychainController = KeychainController(service: .sessions,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
private var cancellables: Set<AnyCancellable> = []
/// We can make the whole NSE a MainActor after https://github.com/swiftlang/swift-evolution/blob/main/proposals/0371-isolated-synchronous-deinit.md
/// otherwise we wouldn't be able to log the tag in the deinit.
deinit {
ExtensionLogger.logMemory(with: tag)
MXLog.info("\(tag) deinit")
}
override init() {
init() {
appHooks = AppHooks()
appHooks.setUp()
// If the device is still locked then we can't write to the app group container and
// the target configuration will fail. We could call exit(0) here, however with the
// notification filtering entitlement that results in the notification being discarded
// so we need to wait for the delegate method to be called and bail out there instead.
if !BootDetectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory),
Self.targetConfiguration == nil {
Self.targetConfiguration = Target.nse.configure(logLevel: settings.logLevel,
traceLogPacks: settings.traceLogPacks,
sentryURL: nil,
rageshakeURL: settings.bugReportRageshakeURL,
appHooks: appHooks)
// The `Mutex` guarantees `Target.nse.configure(...)` runs at most once per process even
// under concurrent NSE instance creation.
if !BootDetectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory) {
Self.targetConfiguration.withLock { state in
guard state == nil else { return }
state = Target.nse.configure(logLevel: settings.logLevel,
traceLogPacks: settings.traceLogPacks,
sentryURL: nil,
rageshakeURL: settings.bugReportRageshakeURL,
appHooks: appHooks)
}
}
super.init()
}
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
Task { await handle(request, withContentHandler: contentHandler) }
}
private func handle(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) async {
func handle(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) async {
// If we skipped configuring the target it means we can't write to the app group, so we're unlikely to
// be able to create a session (and even if we could, we would be missing the lightweightTokioRuntime).
// Additionally, APNs servers only store the most recent notification when the device is powered off.
// So lets a) skip processing the notification and b) deliver a special "offline" notification as a workaround.
guard Self.targetConfiguration != nil else {
guard Self.targetConfiguration.withLock({ $0 != nil }) else {
// MXLog isn't configured:
// swiftlint:disable:next print_deprecation
print("Device is locked after reboot.")
if Self.hasHandledFirstNotificationSinceBoot {
if await Self.hasHandledFirstNotificationSinceBoot {
return contentHandler(request.content)
} else {
Self.hasHandledFirstNotificationSinceBoot = true
await MainActor.run { Self.hasHandledFirstNotificationSinceBoot = true }
deliverReceivedWhileOfflineNotification(for: request)
return contentHandler(.init())
}
}
guard !shouldDeliverReceivedWhileOfflineNotification() else {
guard await !shouldDeliverReceivedWhileOfflineNotification() else {
// Don't log until the app hooks have been run:
// swiftlint:disable:next print_deprecation
print("Device is unlocked but may have missed notifications while offline.")
@@ -167,7 +190,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
}
}
override func serviceExtensionTimeWillExpire() {
nonisolated func serviceExtensionTimeWillExpire() {
notificationHandler?.handleTimeExpiration()
}
@@ -180,13 +203,13 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
///
/// Note that this only handles the first-boot case. When the SDK is able to compute the unread count, we should start to use the NSE,
/// remote-notifications (content-available) and background app refreshes to fetch and deliver our notifications as a more robust solution.
private func shouldDeliverReceivedWhileOfflineNotification() -> Bool {
if Self.hasHandledFirstNotificationSinceBoot {
private func shouldDeliverReceivedWhileOfflineNotification() async -> Bool {
if await Self.hasHandledFirstNotificationSinceBoot {
// If we've already handled the first notification in this process there's no need to continue.
return false
}
Self.hasHandledFirstNotificationSinceBoot = true
await MainActor.run { Self.hasHandledFirstNotificationSinceBoot = true }
guard let currentBootTime = BootDetectionManager.systemBootTime() else {
// There's not much we can do if the boot time is unknown, so don't show the offline notification.
@@ -238,8 +261,8 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
}
// MARK: - Logging
private var tag: String {
private nonisolated var tag: String {
"[NSE][\(Unmanaged.passUnretained(self).toOpaque())][\(Unmanaged.passUnretained(Thread.current).toOpaque())][\(ProcessInfo.processInfo.processIdentifier)]"
}
}