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
+4 -4
View File
@@ -906,7 +906,6 @@
94D9172C4B91FC6971C668CE /* LeaveSpaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AE4B7AD74E402BBC46A801A /* LeaveSpaceView.swift */; }; 94D9172C4B91FC6971C668CE /* LeaveSpaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AE4B7AD74E402BBC46A801A /* LeaveSpaceView.swift */; };
94E15D018D70563FA4AB4E5A /* ComposerToolbarModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3A7375AB22721C436EB056 /* ComposerToolbarModels.swift */; }; 94E15D018D70563FA4AB4E5A /* ComposerToolbarModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3A7375AB22721C436EB056 /* ComposerToolbarModels.swift */; };
94EB81606839C175CA6C2ADB /* RoomScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D97A4E73EA97CA08D2BB9806 /* RoomScreenTests.swift */; }; 94EB81606839C175CA6C2ADB /* RoomScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D97A4E73EA97CA08D2BB9806 /* RoomScreenTests.swift */; };
94F0B78928E952689ACDB271 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D67E616BCA82D8A1258D488 /* NetworkMonitor.swift */; };
95690DDD9D547D3D842ACBE3 /* AnalyticsSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */; }; 95690DDD9D547D3D842ACBE3 /* AnalyticsSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */; };
9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */; }; 9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */; };
95B690EA99A3385D4B8A593D /* NotificationToneManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B94A6A57FE7E71F7605070AA /* NotificationToneManager.swift */; }; 95B690EA99A3385D4B8A593D /* NotificationToneManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B94A6A57FE7E71F7605070AA /* NotificationToneManager.swift */; };
@@ -1448,7 +1447,6 @@
F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57916A1578D8043BB0795441 /* GeneratedMocks.swift */; }; F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57916A1578D8043BB0795441 /* GeneratedMocks.swift */; };
F1C68F64FC8A66B6B9510BF7 /* ManageRoomMemberSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B30CD19ED6243FEDFBA8400 /* ManageRoomMemberSheetView.swift */; }; F1C68F64FC8A66B6B9510BF7 /* ManageRoomMemberSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B30CD19ED6243FEDFBA8400 /* ManageRoomMemberSheetView.swift */; };
F252C0EA49088801F4CA6006 /* landscape_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 96CE9D6642DD487D8CC90C9C /* landscape_test_image.jpg */; }; F252C0EA49088801F4CA6006 /* landscape_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 96CE9D6642DD487D8CC90C9C /* landscape_test_image.jpg */; };
F253AAB4C8F06208173C9C4A /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; };
F255083E18CDBFDF7E640FB1 /* Avatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = C142248014E08E885E323E56 /* Avatars.swift */; }; F255083E18CDBFDF7E640FB1 /* Avatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = C142248014E08E885E323E56 /* Avatars.swift */; };
F2D5C0E1351DA7BD16867629 /* TimelineStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD4823EAB4B4E8BAB4F6B8C /* TimelineStyle.swift */; }; F2D5C0E1351DA7BD16867629 /* TimelineStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD4823EAB4B4E8BAB4F6B8C /* TimelineStyle.swift */; };
F2E580C0FBFBEFFE9D69893B /* RoomPreviewProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739077686814E4EA339B1C83 /* RoomPreviewProxyProtocol.swift */; }; F2E580C0FBFBEFFE9D69893B /* RoomPreviewProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739077686814E4EA339B1C83 /* RoomPreviewProxyProtocol.swift */; };
@@ -7796,7 +7794,6 @@
68C3AF257678F6E7BB238C3F /* AppAppearance.swift in Sources */, 68C3AF257678F6E7BB238C3F /* AppAppearance.swift in Sources */,
A2172B5A26976F9174228B8A /* AppHooks.swift in Sources */, A2172B5A26976F9174228B8A /* AppHooks.swift in Sources */,
E1DE8A5422643F759385290F /* AppSettings.swift in Sources */, E1DE8A5422643F759385290F /* AppSettings.swift in Sources */,
F253AAB4C8F06208173C9C4A /* Assets.swift in Sources */,
484202C5D50983442D24D061 /* AttributedString.swift in Sources */, 484202C5D50983442D24D061 /* AttributedString.swift in Sources */,
CDCA8A559E098503DDE29477 /* AttributedStringBuilder.swift in Sources */, CDCA8A559E098503DDE29477 /* AttributedStringBuilder.swift in Sources */,
BA43D782BE85C7F5F20C624A /* AttributedStringBuilderProtocol.swift in Sources */, BA43D782BE85C7F5F20C624A /* AttributedStringBuilderProtocol.swift in Sources */,
@@ -7835,7 +7832,6 @@
E2DB696117BAEABAD5718023 /* MediaSourceProxy.swift in Sources */, E2DB696117BAEABAD5718023 /* MediaSourceProxy.swift in Sources */,
4CB30D9BD72CA3FEEFAA0793 /* NSEUserSession.swift in Sources */, 4CB30D9BD72CA3FEEFAA0793 /* NSEUserSession.swift in Sources */,
1D5DC685CED904386C89B7DA /* NSRegularExpresion.swift in Sources */, 1D5DC685CED904386C89B7DA /* NSRegularExpresion.swift in Sources */,
94F0B78928E952689ACDB271 /* NetworkMonitor.swift in Sources */,
4DEEFB73181C3B023DB42686 /* NetworkMonitorProtocol.swift in Sources */, 4DEEFB73181C3B023DB42686 /* NetworkMonitorProtocol.swift in Sources */,
5C02841B2A86327B2C377682 /* NotificationConstants.swift in Sources */, 5C02841B2A86327B2C377682 /* NotificationConstants.swift in Sources */,
2689D22EF1D10D22B0A4DAEA /* NotificationContentBuilder.swift in Sources */, 2689D22EF1D10D22B0A4DAEA /* NotificationContentBuilder.swift in Sources */,
@@ -9545,7 +9541,9 @@
PRODUCT_DISPLAY_NAME = "$(APP_DISPLAY_NAME)"; PRODUCT_DISPLAY_NAME = "$(APP_DISPLAY_NAME)";
PRODUCT_NAME = NSE; PRODUCT_NAME = NSE;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_OBJC_INTERFACE_HEADER_NAME = "GeneratedInterface-Swift.h"; SWIFT_OBJC_INTERFACE_HEADER_NAME = "GeneratedInterface-Swift.h";
SWIFT_VERSION = 6.2;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Release; name = Release;
@@ -9926,7 +9924,9 @@
PRODUCT_DISPLAY_NAME = "$(APP_DISPLAY_NAME)"; PRODUCT_DISPLAY_NAME = "$(APP_DISPLAY_NAME)";
PRODUCT_NAME = NSE; PRODUCT_NAME = NSE;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_OBJC_INTERFACE_HEADER_NAME = "GeneratedInterface-Swift.h"; SWIFT_OBJC_INTERFACE_HEADER_NAME = "GeneratedInterface-Swift.h";
SWIFT_VERSION = 6.2;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Debug; name = Debug;
+63 -22
View File
@@ -7,66 +7,107 @@
// //
import Foundation import Foundation
import Synchronization
class AppHooks: AppHooksProtocol { final class AppHooks: AppHooksProtocol {
#if IS_MAIN_APP #if IS_MAIN_APP
func configure(with userSession: UserSessionProtocol?) async { func configure(with userSession: UserSessionProtocol?) async {
await roomScreenHook.configure(with: userSession) await roomScreenHook.configure(with: userSession)
} }
private(set) var appSettingsHook: AppSettingsHookProtocol = DefaultAppSettingsHook() private let _appSettingsHook: Mutex<AppSettingsHookProtocol> = Mutex(DefaultAppSettingsHook())
var appSettingsHook: AppSettingsHookProtocol {
_appSettingsHook.withLock { $0 }
}
func registerAppSettingsHook(_ hook: AppSettingsHookProtocol) { func registerAppSettingsHook(_ hook: AppSettingsHookProtocol) {
appSettingsHook = hook _appSettingsHook.withLock { $0 = hook }
}
private let _compoundHook: Mutex<CompoundHookProtocol> = Mutex(DefaultCompoundHook())
var compoundHook: CompoundHookProtocol {
_compoundHook.withLock { $0 }
} }
private(set) var compoundHook: CompoundHookProtocol = DefaultCompoundHook()
func registerCompoundHook(_ hook: CompoundHookProtocol) { func registerCompoundHook(_ hook: CompoundHookProtocol) {
compoundHook = hook _compoundHook.withLock { $0 = hook }
}
private let _bugReportHook: Mutex<BugReportHookProtocol> = Mutex(DefaultBugReportHook())
var bugReportHook: BugReportHookProtocol {
_bugReportHook.withLock { $0 }
} }
private(set) var bugReportHook: BugReportHookProtocol = DefaultBugReportHook()
func registerBugReportHook(_ hook: BugReportHookProtocol) { func registerBugReportHook(_ hook: BugReportHookProtocol) {
bugReportHook = hook _bugReportHook.withLock { $0 = hook }
}
private let _certificateValidatorHook: Mutex<CertificateValidatorHookProtocol> = Mutex(DefaultCertificateValidator())
var certificateValidatorHook: CertificateValidatorHookProtocol {
_certificateValidatorHook.withLock { $0 }
} }
private(set) var certificateValidatorHook: CertificateValidatorHookProtocol = DefaultCertificateValidator()
func registerCertificateValidatorHook(_ hook: CertificateValidatorHookProtocol) { func registerCertificateValidatorHook(_ hook: CertificateValidatorHookProtocol) {
certificateValidatorHook = hook _certificateValidatorHook.withLock { $0 = hook }
}
private let _oAuthPresenterHook: Mutex<OAuthPresenterHookProtocol> = Mutex(DefaultOAuthPresenterHook())
var oAuthPresenterHook: OAuthPresenterHookProtocol {
_oAuthPresenterHook.withLock { $0 }
} }
private(set) var oAuthPresenterHook: OAuthPresenterHookProtocol = DefaultOAuthPresenterHook()
func registerOAuthPresenterHook(_ hook: OAuthPresenterHookProtocol) { func registerOAuthPresenterHook(_ hook: OAuthPresenterHookProtocol) {
oAuthPresenterHook = hook _oAuthPresenterHook.withLock { $0 = hook }
}
private let _roomScreenHook: Mutex<RoomScreenHookProtocol> = Mutex(DefaultRoomScreenHook())
var roomScreenHook: RoomScreenHookProtocol {
_roomScreenHook.withLock { $0 }
} }
private(set) var roomScreenHook: RoomScreenHookProtocol = DefaultRoomScreenHook()
func registerRoomScreenHook(_ hook: RoomScreenHookProtocol) { func registerRoomScreenHook(_ hook: RoomScreenHookProtocol) {
roomScreenHook = hook _roomScreenHook.withLock { $0 = hook }
}
private let _developerOptionsScreenHook: Mutex<DeveloperOptionsScreenHookProtocol> = Mutex(DefaultDeveloperOptionsScreenHook())
var developerOptionsScreenHook: DeveloperOptionsScreenHookProtocol {
_developerOptionsScreenHook.withLock { $0 }
} }
private(set) var developerOptionsScreenHook: DeveloperOptionsScreenHookProtocol = DefaultDeveloperOptionsScreenHook()
func registerDeveloperOptionsScreenHook(_ hook: DeveloperOptionsScreenHookProtocol) { func registerDeveloperOptionsScreenHook(_ hook: DeveloperOptionsScreenHookProtocol) {
developerOptionsScreenHook = hook _developerOptionsScreenHook.withLock { $0 = hook }
} }
#endif #endif
private(set) var tracingHook: TracingHookProtocol = DefaultTracingHook() private let _tracingHook: Mutex<TracingHookProtocol> = Mutex(DefaultTracingHook())
var tracingHook: TracingHookProtocol {
_tracingHook.withLock { $0 }
}
func registerTracingHook(_ hook: TracingHookProtocol) { func registerTracingHook(_ hook: TracingHookProtocol) {
tracingHook = hook _tracingHook.withLock { $0 = hook }
}
private let _clientBuilderHook: Mutex<ClientBuilderHookProtocol> = Mutex(DefaultClientBuilderHook())
var clientBuilderHook: ClientBuilderHookProtocol {
_clientBuilderHook.withLock { $0 }
} }
private(set) var clientBuilderHook: ClientBuilderHookProtocol = DefaultClientBuilderHook()
func registerClientBuilderHook(_ hook: ClientBuilderHookProtocol) { func registerClientBuilderHook(_ hook: ClientBuilderHookProtocol) {
clientBuilderHook = hook _clientBuilderHook.withLock { $0 = hook }
}
private let _remoteSettingsHook: Mutex<RemoteSettingsHookProtocol> = Mutex(DefaultRemoteSettingsHook())
var remoteSettingsHook: RemoteSettingsHookProtocol {
_remoteSettingsHook.withLock { $0 }
} }
private(set) var remoteSettingsHook: RemoteSettingsHookProtocol = DefaultRemoteSettingsHook()
func registerRemoteSettingsHook(_ hook: RemoteSettingsHookProtocol) { func registerRemoteSettingsHook(_ hook: RemoteSettingsHookProtocol) {
remoteSettingsHook = hook _remoteSettingsHook.withLock { $0 = hook }
} }
} }
protocol AppHooksProtocol { protocol AppHooksProtocol: Sendable {
func setUp() func setUp()
} }
@@ -8,7 +8,7 @@
import MatrixRustSDK import MatrixRustSDK
protocol ClientBuilderHookProtocol { protocol ClientBuilderHookProtocol: Sendable {
func configure(_ builder: ClientBuilder) -> ClientBuilder func configure(_ builder: ClientBuilder) -> ClientBuilder
} }
@@ -13,7 +13,7 @@ enum RemoteSettingsError: Error {
case elementProRequired(serverName: String) case elementProRequired(serverName: String)
} }
protocol RemoteSettingsHookProtocol { protocol RemoteSettingsHookProtocol: Sendable {
#if IS_MAIN_APP #if IS_MAIN_APP
@MainActor func initializeCache(using client: ClientProtocol, applyingTo appSettings: CommonSettingsProtocol) async -> Result<Void, RemoteSettingsError> @MainActor func initializeCache(using client: ClientProtocol, applyingTo appSettings: CommonSettingsProtocol) async -> Result<Void, RemoteSettingsError>
func updateCache(using client: ClientProtocol) async func updateCache(using client: ClientProtocol) async
@@ -8,7 +8,7 @@
import MatrixRustSDK import MatrixRustSDK
protocol TracingHookProtocol { protocol TracingHookProtocol: Sendable {
func update(_ configuration: TracingConfiguration, with rageshakeURL: RemotePreference<RageshakeConfiguration>) func update(_ configuration: TracingConfiguration, with rageshakeURL: RemotePreference<RageshakeConfiguration>)
} }
@@ -14,7 +14,7 @@ import Foundation
import SwiftUI import SwiftUI
/// Common settings between app and NSE /// Common settings between app and NSE
protocol CommonSettingsProtocol: AnyObject { protocol CommonSettingsProtocol: AnyObject, Sendable {
var lastNotificationBootTime: TimeInterval? { get set } var lastNotificationBootTime: TimeInterval? { get set }
var selectedNotificationTone: NotificationTone? { get set } var selectedNotificationTone: NotificationTone? { get set }
@@ -34,7 +34,9 @@ enum AppBuildType {
} }
/// Store Element specific app settings. /// Store Element specific app settings.
final class AppSettings { ///
/// State is persisted in `UserDefaults`, which is thread-safe per Apple's documentation, hence `@unchecked`.
final class AppSettings: @unchecked Sendable {
private enum UserDefaultsKeys: String { private enum UserDefaultsKeys: String {
case lastVersionLaunched case lastVersionLaunched
case seenInvites case seenInvites
@@ -92,10 +94,10 @@ final class AppSettings {
case developerOptionsEnabled case developerOptionsEnabled
} }
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier private nonisolated(unsafe) static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
/// UserDefaults to be used on reads and writes. /// UserDefaults to be used on reads and writes. `UserDefaults` is thread-safe per Apple's documentation.
private static var store: UserDefaults! = UserDefaults(suiteName: suiteName) private nonisolated(unsafe) static var store: UserDefaults! = UserDefaults(suiteName: suiteName)
static var appBuildType: AppBuildType { static var appBuildType: AppBuildType {
#if DEBUG #if DEBUG
@@ -10,7 +10,9 @@ import Combine
/// A wrapper of CurrentValueSubject. /// A wrapper of CurrentValueSubject.
/// The purpose of this type is to remove the possibility to send new values on the underlying subject. /// The purpose of this type is to remove the possibility to send new values on the underlying subject.
struct CurrentValuePublisher<Output, Failure: Error>: Publisher { ///
/// `CurrentValueSubject` is documented as thread-safe but is not formally `Sendable`, hence `@unchecked`.
struct CurrentValuePublisher<Output, Failure: Error>: Publisher, @unchecked Sendable {
private let subject: CurrentValueSubject<Output, Failure> private let subject: CurrentValueSubject<Output, Failure>
init(_ subject: CurrentValueSubject<Output, Failure>) { init(_ subject: CurrentValueSubject<Output, Failure>) {
@@ -15,9 +15,9 @@ enum ExpiringTaskRunnerError: Error {
actor ExpiringTaskRunner<T: Sendable> { actor ExpiringTaskRunner<T: Sendable> {
private var continuation: CheckedContinuation<T, Error>? private var continuation: CheckedContinuation<T, Error>?
private var task: () async throws -> T private var task: @Sendable () async throws -> T
init(_ task: @escaping () async throws -> T) { init(_ task: @escaping @Sendable () async throws -> T) {
self.task = task self.task = task
} }
+7 -13
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import Synchronization
public extension Bundle { public extension Bundle {
/// The top-level bundle that contains the entire app. /// The top-level bundle that contains the entire app.
@@ -24,11 +25,11 @@ public extension Bundle {
// MARK: - Localisation // MARK: - Localisation
/// Overrides `Bundle.app.preferredLocalizations` for testing translations. /// Overrides `Bundle.app.preferredLocalizations` for testing translations since this is
static var overrideLocalizations: [String]? /// only for testing, and is changed at runtime only in tests, it's fine to keep as `nonisolated(unsafe)`
nonisolated(unsafe) static var overrideLocalizations: [String]?
private static let cacheDispatchQueue = DispatchQueue(label: "io.element.elementx.localization_bundle_cache") private static let cachedBundles = Mutex<[String: Bundle]>([:])
private static var cachedBundles = [String: Bundle]()
/// Get an lproj language bundle from the receiver bundle. /// Get an lproj language bundle from the receiver bundle.
/// - Parameter language: The language to try to load. /// - Parameter language: The language to try to load.
@@ -52,17 +53,10 @@ public extension Bundle {
// MARK: - Private // MARK: - Private
private static func cacheValue(_ value: Bundle?, forKey key: String) { private static func cacheValue(_ value: Bundle?, forKey key: String) {
cacheDispatchQueue.sync { cachedBundles.withLock { $0[key] = value }
cachedBundles[key] = value
}
} }
private static func cachedValue(forKey key: String) -> Bundle? { private static func cachedValue(forKey key: String) -> Bundle? {
var result: Bundle? cachedBundles.withLock { $0[key] }
cacheDispatchQueue.sync {
result = cachedBundles[key]
}
return result
} }
} }
@@ -10,6 +10,7 @@ import Compound
import LRUCache import LRUCache
import MatrixRustSDK import MatrixRustSDK
import SwiftSoup import SwiftSoup
import Synchronization
import UIKit import UIKit
protocol MentionBuilderProtocol { protocol MentionBuilderProtocol {
@@ -42,11 +43,10 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
private let mentionBuilder: MentionBuilderProtocol private let mentionBuilder: MentionBuilderProtocol
private static let attributeMSC4286 = "msc4286-external-payment-details" private static let attributeMSC4286 = "msc4286-external-payment-details"
private static let cacheDispatchQueue = DispatchQueue(label: "io.element.elementx.attributed_string_builder_v2_cache") private static let caches = Mutex<[String: LRUCache<String, AttributedString>]>([:])
private static var caches: [String: LRUCache<String, AttributedString>] = [:]
static func invalidateCaches() { static func invalidateCaches() {
caches.removeAll() caches.withLock { $0.removeAll() }
} }
init(cacheKey: String = defaultKey, mentionBuilder: MentionBuilderProtocol) { init(cacheKey: String = defaultKey, mentionBuilder: MentionBuilderProtocol) {
@@ -279,7 +279,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
} }
private static func cacheValue(_ value: AttributedString?, forKey key: String, cacheKey: String) { private static func cacheValue(_ value: AttributedString?, forKey key: String, cacheKey: String) {
cacheDispatchQueue.sync { caches.withLock { caches in
if caches[cacheKey] == nil { if caches[cacheKey] == nil {
caches[cacheKey] = LRUCache<String, AttributedString>(countLimit: 1000) caches[cacheKey] = LRUCache<String, AttributedString>(countLimit: 1000)
} }
@@ -289,12 +289,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
} }
private static func cachedValue(forKey key: String, cacheKey: String) -> AttributedString? { private static func cachedValue(forKey key: String, cacheKey: String) -> AttributedString? {
var result: AttributedString? caches.withLock { $0[cacheKey]?.value(forKey: key) }
cacheDispatchQueue.sync {
result = caches[cacheKey]?.value(forKey: key)
}
return result
} }
// swiftlint:disable:next cyclomatic_complexity // swiftlint:disable:next cyclomatic_complexity
+1 -1
View File
@@ -22,7 +22,7 @@ enum Tracing {
/// Set this to temporarily override the directory from which logs will be collected. /// Set this to temporarily override the directory from which logs will be collected.
/// This basically only affects ``logFiles``, and doesn't inform the SDK to write /// This basically only affects ``logFiles``, and doesn't inform the SDK to write
/// the logs to a different directory, which should be done before setting this. /// the logs to a different directory, which should be done before setting this.
static var logsDirectoryOverride: URL? nonisolated(unsafe) static var logsDirectoryOverride: URL?
static var legacyLogsDirectory: URL { static var legacyLogsDirectory: URL {
.appGroupContainerDirectory .appGroupContainerDirectory
} }
+3 -3
View File
@@ -13,12 +13,12 @@ import MatrixRustSDK
/// ///
/// To use this you'll need to add a conformance to the required listener /// To use this you'll need to add a conformance to the required listener
/// protocol with a specialisation for the type it listens for. /// protocol with a specialisation for the type it listens for.
final class SDKListener<T> { final class SDKListener<T>: Sendable {
private let onUpdateClosure: (T) -> Void private let onUpdateClosure: @Sendable (T) -> Void
/// Creates a new listener. /// Creates a new listener.
/// - Parameter onUpdateClosure: A closure that will be called whenever a new value is available. /// - Parameter onUpdateClosure: A closure that will be called whenever a new value is available.
init(_ onUpdateClosure: @escaping (T) -> Void) { init(_ onUpdateClosure: @escaping @Sendable (T) -> Void) {
self.onUpdateClosure = onUpdateClosure self.onUpdateClosure = onUpdateClosure
} }
} }
@@ -16,7 +16,7 @@ enum MediaLoaderError: Error {
} }
// sourcery: AutoMockable // sourcery: AutoMockable
protocol MediaLoaderProtocol { protocol MediaLoaderProtocol: Sendable {
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data
func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data
@@ -159,7 +159,7 @@ final class NotificationManager: NSObject, NotificationManagerProtocol {
} }
private func removeReceivedWhileOfflineNotification() { private func removeReceivedWhileOfflineNotification() {
notificationCenter.removeDeliveredNotifications(withIdentifiers: [NotificationServiceExtension.receivedWhileOfflineNotificationID]) notificationCenter.removeDeliveredNotifications(withIdentifiers: [NotificationServiceExtensionActor.receivedWhileOfflineNotificationID])
} }
private func setPusher(with deviceToken: Data, clientProxy: ClientProxyProtocol) async -> Bool { private func setPusher(with deviceToken: Data, clientProxy: ClientProxyProtocol) async -> Bool {
@@ -12,10 +12,14 @@ import MatrixRustSDK
class TimelineItemProvider: TimelineItemProviderProtocol { class TimelineItemProvider: TimelineItemProviderProtocol {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private let serialDispatchQueue: DispatchQueue
private var roomTimelineObservationToken: TaskHandle? private var roomTimelineObservationToken: TaskHandle?
/// Bridge from the SDK's synchronous callback into Swift Concurrency. Yielding is safe from any
/// thread; a single long-lived `for await` consumer (set up in `init`) applies the diffs on the
/// main actor in FIFO order, guaranteeing one in-flight update at a time.
private let diffContinuation: AsyncStream<[TimelineDiff]>.Continuation
private let paginationStateSubject = CurrentValueSubject<TimelinePaginationState, Never>(.initial) private let paginationStateSubject = CurrentValueSubject<TimelinePaginationState, Never>(.initial)
var paginationState: TimelinePaginationState { var paginationState: TimelinePaginationState {
paginationStateSubject.value paginationStateSubject.value
@@ -43,25 +47,34 @@ class TimelineItemProvider: TimelineItemProviderProtocol {
} }
deinit { deinit {
diffContinuation.finish()
roomTimelineObservationToken?.cancel() roomTimelineObservationToken?.cancel()
} }
init(timeline: Timeline, kind: TimelineKind, paginationStatePublisher: AnyPublisher<TimelinePaginationState, Never>) { init(timeline: Timeline, kind: TimelineKind, paginationStatePublisher: AnyPublisher<TimelinePaginationState, Never>) {
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.timeline_item_provider", qos: .utility)
itemProxiesSubject = CurrentValueSubject<[TimelineItemProxy], Never>([]) itemProxiesSubject = CurrentValueSubject<[TimelineItemProxy], Never>([])
self.kind = kind self.kind = kind
let (diffStream, diffContinuation) = AsyncStream<[TimelineDiff]>.makeStream()
self.diffContinuation = diffContinuation
paginationStatePublisher paginationStatePublisher
.sink { [weak self] in .sink { [weak self] in
self?.paginationStateSubject.send($0) self?.paginationStateSubject.send($0)
} }
.store(in: &cancellables) .store(in: &cancellables)
// `for await` guarantees FIFO ordering and that only one diff is being applied at a time.
// The stream also allows to handle the sendable items in the listener.
Task { [weak self] in
for await diffs in diffStream {
self?.updateItemsWithDiffs(diffs)
}
}
Task { Task {
roomTimelineObservationToken = await timeline.addListener(listener: SDKListener { [weak self] timelineDiffs in roomTimelineObservationToken = await timeline.addListener(listener: SDKListener { diffs in
self?.serialDispatchQueue.sync { diffContinuation.yield(diffs)
self?.updateItemsWithDiffs(timelineDiffs)
}
}) })
} }
} }
+3 -3
View File
@@ -250,8 +250,8 @@ struct NotificationContentBuilder {
if let fetchedImage { if let fetchedImage {
image = fetchedImage image = fetchedImage
} else if let data = await getPlaceholderAvatarImageData(name: icon.groupInfo?.avatarDisplayName ?? senderAvatarDisplayName, } else if let data = await Self.getPlaceholderAvatarImageData(name: icon.groupInfo?.avatarDisplayName ?? senderAvatarDisplayName,
id: icon.groupInfo?.id ?? senderID) { id: icon.groupInfo?.id ?? senderID) {
image = INImage(imageData: data) image = INImage(imageData: data)
} else { } else {
image = INImage(named: "") image = INImage(named: "")
@@ -309,7 +309,7 @@ struct NotificationContentBuilder {
} }
@MainActor @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 // The version value is used in case the design of the placeholder is updated to force a replacement
let prefix = "notification_placeholderV9" let prefix = "notification_placeholderV9"
+18 -13
View File
@@ -8,6 +8,7 @@
import CallKit import CallKit
import MatrixRustSDK import MatrixRustSDK
import Synchronization
import UserNotifications import UserNotifications
class NotificationHandler { class NotificationHandler {
@@ -19,9 +20,6 @@ class NotificationHandler {
private let notificationContentBuilder: NotificationContentBuilder private let notificationContentBuilder: NotificationContentBuilder
// periphery:ignore - required for instance retention in the rust codebase
private var roomInfoObservationToken: TaskHandle?
init(userSession: NSEUserSession, init(userSession: NSEUserSession,
settings: CommonSettingsProtocol, settings: CommonSettingsProtocol,
contentHandler: @escaping (UNNotificationContent) -> Void, contentHandler: @escaping (UNNotificationContent) -> Void,
@@ -184,20 +182,27 @@ class NotificationHandler {
// Check to see if a call is still ongoing // 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 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 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 { let expiringTask = ExpiringTaskRunner {
await withCheckedContinuation { [weak self] continuation in await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
self?.roomInfoObservationToken = room.subscribeToRoomInfoUpdates(listener: SDKListener { info in observationToken.withLock { state in
if info.hasRoomCall { state = room.subscribeToRoomInfoUpdates(listener: SDKListener { info in
MXLog.info("Received room info update and the room has an active call now.") if info.hasRoomCall {
continuation.resume() MXLog.info("Received room info update and the room has an active call now.")
} else { continuation.resume()
MXLog.info("Received a room info update but the room still doesn't have an ongoing call.") } 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 { guard room.hasActiveRoomCall() else {
MXLog.info("The room no longer has an ongoing call, handling as push notification") MXLog.info("The room no longer has an ongoing call, handling as push notification")
+55 -32
View File
@@ -8,7 +8,8 @@
import Combine import Combine
import MatrixRustSDK import MatrixRustSDK
import UserNotifications import Synchronization
@preconcurrency import UserNotifications
// The lifecycle of the NSE looks something like the following: // The lifecycle of the NSE looks something like the following:
// 1) App receives notification // 1) App receives notification
@@ -29,31 +30,55 @@ import UserNotifications
// called on the same instance of `NotificationService` as a previous // called on the same instance of `NotificationService` as a previous
// notification. // 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" 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 var hasHandledFirstNotificationSinceBoot = false
private static let firstNotificationThreshold: TimeInterval = 15 * 60 private static let firstNotificationThreshold: TimeInterval = 15 * 60
private let settings: CommonSettingsProtocol = AppSettings() private let settings: CommonSettingsProtocol = AppSettings()
private let appHooks: AppHooks 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, private let keychainController = KeychainController(service: .sessions,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) 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 { deinit {
ExtensionLogger.logMemory(with: tag) ExtensionLogger.logMemory(with: tag)
MXLog.info("\(tag) deinit") MXLog.info("\(tag) deinit")
} }
override init() { init() {
appHooks = AppHooks() appHooks = AppHooks()
appHooks.setUp() appHooks.setUp()
@@ -61,42 +86,40 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
// the target configuration will fail. We could call exit(0) here, however with the // the target configuration will fail. We could call exit(0) here, however with the
// notification filtering entitlement that results in the notification being discarded // 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. // so we need to wait for the delegate method to be called and bail out there instead.
if !BootDetectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory), // The `Mutex` guarantees `Target.nse.configure(...)` runs at most once per process even
Self.targetConfiguration == nil { // under concurrent NSE instance creation.
Self.targetConfiguration = Target.nse.configure(logLevel: settings.logLevel, if !BootDetectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory) {
traceLogPacks: settings.traceLogPacks, Self.targetConfiguration.withLock { state in
sentryURL: nil, guard state == nil else { return }
rageshakeURL: settings.bugReportRageshakeURL, state = Target.nse.configure(logLevel: settings.logLevel,
appHooks: appHooks) traceLogPacks: settings.traceLogPacks,
sentryURL: nil,
rageshakeURL: settings.bugReportRageshakeURL,
appHooks: appHooks)
}
} }
super.init()
} }
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { func handle(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) async {
Task { await handle(request, withContentHandler: contentHandler) }
}
private 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 // 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). // 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. // 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. // 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: // MXLog isn't configured:
// swiftlint:disable:next print_deprecation // swiftlint:disable:next print_deprecation
print("Device is locked after reboot.") print("Device is locked after reboot.")
if Self.hasHandledFirstNotificationSinceBoot { if await Self.hasHandledFirstNotificationSinceBoot {
return contentHandler(request.content) return contentHandler(request.content)
} else { } else {
Self.hasHandledFirstNotificationSinceBoot = true await MainActor.run { Self.hasHandledFirstNotificationSinceBoot = true }
deliverReceivedWhileOfflineNotification(for: request) deliverReceivedWhileOfflineNotification(for: request)
return contentHandler(.init()) return contentHandler(.init())
} }
} }
guard !shouldDeliverReceivedWhileOfflineNotification() else { guard await !shouldDeliverReceivedWhileOfflineNotification() else {
// Don't log until the app hooks have been run: // Don't log until the app hooks have been run:
// swiftlint:disable:next print_deprecation // swiftlint:disable:next print_deprecation
print("Device is unlocked but may have missed notifications while offline.") 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() 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, /// 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. /// remote-notifications (content-available) and background app refreshes to fetch and deliver our notifications as a more robust solution.
private func shouldDeliverReceivedWhileOfflineNotification() -> Bool { private func shouldDeliverReceivedWhileOfflineNotification() async -> Bool {
if Self.hasHandledFirstNotificationSinceBoot { if await Self.hasHandledFirstNotificationSinceBoot {
// If we've already handled the first notification in this process there's no need to continue. // If we've already handled the first notification in this process there's no need to continue.
return false return false
} }
Self.hasHandledFirstNotificationSinceBoot = true await MainActor.run { Self.hasHandledFirstNotificationSinceBoot = true }
guard let currentBootTime = BootDetectionManager.systemBootTime() else { 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. // There's not much we can do if the boot time is unknown, so don't show the offline notification.
@@ -239,7 +262,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
// MARK: - Logging // MARK: - Logging
private var tag: String { private nonisolated var tag: String {
"[NSE][\(Unmanaged.passUnretained(self).toOpaque())][\(Unmanaged.passUnretained(Thread.current).toOpaque())][\(ProcessInfo.processInfo.processIdentifier)]" "[NSE][\(Unmanaged.passUnretained(self).toOpaque())][\(Unmanaged.passUnretained(Thread.current).toOpaque())][\(ProcessInfo.processInfo.processIdentifier)]"
} }
} }
+3 -2
View File
@@ -74,6 +74,8 @@ targets:
CURRENT_PROJECT_VERSION: $(CURRENT_PROJECT_VERSION) CURRENT_PROJECT_VERSION: $(CURRENT_PROJECT_VERSION)
DEVELOPMENT_TEAM: $(DEVELOPMENT_TEAM) DEVELOPMENT_TEAM: $(DEVELOPMENT_TEAM)
SWIFT_OBJC_INTERFACE_HEADER_NAME: GeneratedInterface-Swift.h SWIFT_OBJC_INTERFACE_HEADER_NAME: GeneratedInterface-Swift.h
SWIFT_VERSION: 6.2
SWIFT_APPROACHABLE_CONCURRENCY: YES
OTHER_SWIFT_FLAGS: OTHER_SWIFT_FLAGS:
- "-DIS_NSE" - "-DIS_NSE"
@@ -87,7 +89,6 @@ targets:
- path: ../../Secrets/Secrets.swift - path: ../../Secrets/Secrets.swift
- path: ../../ElementX/Sources/Application/Settings - path: ../../ElementX/Sources/Application/Settings
- path: ../../ElementX/Sources/Application/TargetConfiguration.swift - path: ../../ElementX/Sources/Application/TargetConfiguration.swift
- path: ../../ElementX/Sources/Generated/Assets.swift
- path: ../../ElementX/Sources/Generated/Strings.swift - path: ../../ElementX/Sources/Generated/Strings.swift
- path: ../../ElementX/Sources/Other/Avatars.swift - path: ../../ElementX/Sources/Other/Avatars.swift
- path: ../../ElementX/Sources/Other/CurrentValuePublisher.swift - path: ../../ElementX/Sources/Other/CurrentValuePublisher.swift
@@ -111,7 +112,7 @@ targets:
- path: ../../ElementX/Sources/Other/InfoPlistReader.swift - path: ../../ElementX/Sources/Other/InfoPlistReader.swift
- path: ../../ElementX/Sources/Other/Logging - path: ../../ElementX/Sources/Other/Logging
- path: ../../ElementX/Sources/Other/MatrixEntityRegex.swift - path: ../../ElementX/Sources/Other/MatrixEntityRegex.swift
- path: ../../ElementX/Sources/Other/NetworkMonitor - path: ../../ElementX/Sources/Other/NetworkMonitor/NetworkMonitorProtocol.swift
- path: ../../ElementX/Sources/Other/Pills/PillUtilities.swift - path: ../../ElementX/Sources/Other/Pills/PillUtilities.swift
- path: ../../ElementX/Sources/Other/Pills/PlainMentionBuilder.swift - path: ../../ElementX/Sources/Other/Pills/PlainMentionBuilder.swift
- path: ../../ElementX/Sources/Other/SDKListener.swift - path: ../../ElementX/Sources/Other/SDKListener.swift