Swift 6 Migration (#5733)

* Enable Swift 6.2 with default main actor isolation for the app target

Turn on SWIFT_VERSION 6.2, SWIFT_APPROACHABLE_CONCURRENCY and SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor for the ElementX target, matching the NSE and Share Extension (which already use 6.2 without the main actor default).

# Conflicts:
#	ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

* Update the code generation templates for Swift 6

Emit nonisolated/@unchecked Sendable mocks and nonisolated string enums, and mark async mock methods @concurrent so they keep suspending like the real implementations (otherwise intermediate states are unobservable in tests). Includes the regenerated mocks and strings.

# Conflicts:
#	ElementX/Sources/Mocks/Generated/GeneratedMocks.swift

* Make the data layer and services nonisolated and Sendable

Annotate the value-type models, Rust SDK proxy wrappers, timeline item graph, settings, logging and NSE-shared types as nonisolated, and conform the service protocols to Sendable so their existentials can cross isolation boundaries. These types run off the main actor and no longer inherit the target's default main actor isolation.

* Adopt the new isolation across call sites, mocks and signatures

Update call sites, closure signatures and mock configuration helpers to match the nonisolated/Sendable data layer, including the main actor isolated mock inits and the @Sendable filter on recentlyVisitedRooms.

^ Conflicts:
^	ElementX/Sources/Services/SecureBackup/SecureBackupController.swift

* Remove redundant @MainActor annotations

With the app target defaulting to main actor isolation, the explicit @MainActor on view models, coordinators, views and their protocols is no longer required.

* Fix concurrency hotspots surfaced by the migration

Resolve the room summary main-thread deadlock by moving diff processing onto AsyncStreams, add the SDKListener.onMainActor FIFO bridge for SDK callbacks, hop KVO/timer/UIKit callbacks to the main actor via assumeIsolated, add isolated deinits, and apply the few justified nonisolated(unsafe) escapes for queue-confined Apple APIs.

* Fix the unit tests for Swift 6 concurrency

Hop the waitForConfirmation body onto the caller's actor (Swift Testing runs confirmation closures off-actor), and adjust the affected suites and async signatures.

* offload diffs on concurrent threads instead of main actor

* make room info protocols sendable

* use a task group for fetching data so that everything runs concurrently in the TextRoomTimelineView when getting the preview.

* use the generic onMainActor implementation, which already makes a stream and send the continuation on the main actor.

* small pr comments issues being addressed

* update enterprise

* Update StaticCode to 1.1.0 and specify the required Swift isolation.

* fix ElementCallServiceTests

* update enterprise

* re-generate mocks

* update enterprise

* enterprise new code conformed to swift 6

---------

Co-authored-by: Doug <douglase@element.io>
This commit is contained in:
Mauro
2026-06-18 17:55:31 +02:00
committed by GitHub
parent 7edca8b5a1
commit a520d94760
444 changed files with 3677 additions and 3361 deletions
@@ -9,7 +9,7 @@
import Foundation
import MatrixRustSDK
struct RoomEventStringBuilder {
nonisolated struct RoomEventStringBuilder {
let stateEventStringBuilder: RoomStateEventStringBuilder
let messageEventStringBuilder: RoomMessageEventStringBuilder
let shouldPrefixSenderName: Bool
@@ -9,7 +9,7 @@
import Foundation
import MatrixRustSDK
struct RoomMessageEventStringBuilder {
nonisolated struct RoomMessageEventStringBuilder {
enum Style {
/// Plain: no prefix, no special text treatment
/// Shown in push notifications and thread lists
@@ -10,7 +10,7 @@ import Foundation
import MatrixRustSDK
/// A quick summary of a Room, useful to describe and give quick informations for the room list
struct RoomSummary {
nonisolated struct RoomSummary {
enum JoinRequestType {
case invite(inviter: RoomMemberProxyProtocol?)
case knock
@@ -20,8 +20,6 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
private let roomListPageSize: UInt32
private let serialDispatchQueue: DispatchQueue
private let visibleItemRangePublisher = CurrentValueSubject<Range<Int>, Never>(0..<0)
// periphery:ignore - retaining purpose
@@ -35,7 +33,13 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
private let roomListSubject = CurrentValueSubject<[RoomSummary], Never>([])
private let stateSubject = CurrentValueSubject<RoomSummaryProviderState, Never>(.notLoaded)
private let diffsPublisher = PassthroughSubject<[RoomListEntriesUpdate], Never>()
/// 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 diffsContinuation: AsyncStream<[RoomListEntriesUpdate]>.Continuation
/// Same bridging pattern as `diffsContinuation`, for the room list loading state.
private let loadingStateContinuation: AsyncStream<RoomListLoadingState>.Continuation
var roomListPublisher: CurrentValuePublisher<[RoomSummary], Never> {
roomListSubject.asCurrentValuePublisher()
@@ -64,7 +68,6 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
notificationSettings: NotificationSettingsProxyProtocol,
appSettings: AppSettings) {
self.roomListService = roomListService
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.room_summary_provider", qos: .default)
self.eventStringBuilder = eventStringBuilder
self.name = name
self.shouldUpdateVisibleRange = shouldUpdateVisibleRange
@@ -72,16 +75,35 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
self.appSettings = appSettings
self.roomListPageSize = roomListPageSize
diffsPublisher
.receive(on: serialDispatchQueue)
.sink { [weak self] in self?.updateRoomsWithDiffs($0) }
.store(in: &cancellables)
let (diffsStream, diffsContinuation) = AsyncStream<[RoomListEntriesUpdate]>.makeStream()
self.diffsContinuation = diffsContinuation
let (loadingStateStream, loadingStateContinuation) = AsyncStream<RoomListLoadingState>.makeStream()
self.loadingStateContinuation = loadingStateContinuation
Task { [weak self] in
for await diffs in diffsStream {
await self?.updateRoomsWithDiffs(diffs)
}
}
Task { [weak self, name] in
for await state in loadingStateStream {
MXLog.info("\(name): Received state update: \(state)")
self?.stateSubject.send(RoomSummaryProviderState(roomListState: state))
}
}
setupVisibleRangeObservers()
setupNotificationSettingsSubscription()
}
deinit {
diffsContinuation.finish()
loadingStateContinuation.finish()
}
func setRoomList(_ roomList: RoomList) {
guard stateUpdatesTaskHandle == nil else {
return
@@ -91,18 +113,15 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
do {
listUpdatesSubscriptionResult = roomList.entriesWithDynamicAdapters(pageSize: UInt32(roomListPageSize),
listener: SDKListener { [weak self] updates in
guard let self else { return }
diffsPublisher.send(updates)
listener: SDKListener { [diffsContinuation] updates in
diffsContinuation.yield(updates)
})
// Forces the listener above to be called with the current state
setFilter(.all(filters: []))
let stateUpdatesSubscriptionResult = try roomList.loadingState(listener: SDKListener { [weak self] state in
guard let self else { return }
MXLog.info("\(name): Received state update: \(state)")
stateSubject.send(RoomSummaryProviderState(roomListState: state))
let stateUpdatesSubscriptionResult = try roomList.loadingState(listener: SDKListener { [loadingStateContinuation] state in
loadingStateContinuation.yield(state)
})
stateUpdatesTaskHandle = stateUpdatesSubscriptionResult.stateStream
@@ -208,20 +227,33 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
.store(in: &cancellables)
}
fileprivate func updateRoomsWithDiffs(_ diffs: [RoomListEntriesUpdate]) {
fileprivate func updateRoomsWithDiffs(_ diffs: [RoomListEntriesUpdate]) async {
// Building the room summaries (SDK fetches + string building) and applying the
// CollectionDifference can be expensive, so compute off the main actor and only
// hop back to publish the result.
rooms = await Self.updatedRooms(from: diffs, on: rooms, eventStringBuilder: eventStringBuilder, name: name)
}
@concurrent
private static func updatedRooms(from diffs: [RoomListEntriesUpdate],
on currentRooms: [RoomSummary],
eventStringBuilder: RoomEventStringBuilder,
name: String) async -> [RoomSummary] {
let span = MXLog.createSpan("\(name).process_room_list_diffs")
span.enter()
defer {
span.exit()
}
rooms = diffs.reduce(rooms) { currentItems, diff in
processDiff(diff, on: currentItems)
var updatedRooms = currentRooms
for diff in diffs {
updatedRooms = await processDiff(diff, on: updatedRooms, eventStringBuilder: eventStringBuilder, name: name)
}
return updatedRooms
}
private func processDiff(_ diff: RoomListEntriesUpdate, on currentItems: [RoomSummary]) -> [RoomSummary] {
guard let collectionDiff = buildDiff(from: diff, on: currentItems) else {
private nonisolated static func processDiff(_ diff: RoomListEntriesUpdate, on currentItems: [RoomSummary], eventStringBuilder: RoomEventStringBuilder, name: String) async -> [RoomSummary] {
guard let collectionDiff = await buildDiff(from: diff, on: currentItems, eventStringBuilder: eventStringBuilder) else {
MXLog.error("\(name): Failed building CollectionDifference from \(diff)")
return currentItems
}
@@ -234,30 +266,19 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
return updatedItems
}
private func fetchRoomDetails(from room: Room) -> (roomInfo: RoomInfo?, latestEvent: LatestEventValue?) {
class FetchResult {
var roomInfo: RoomInfo?
var latestEvent: LatestEventValue?
private nonisolated static func fetchRoomDetails(from room: Room) async -> (roomInfo: RoomInfo?, latestEvent: LatestEventValue?) {
do {
let latestEvent = await room.latestEvent()
let roomInfo = try await room.roomInfo()
return (roomInfo, latestEvent)
} catch {
MXLog.error("Failed fetching room info with error: \(error)")
return (nil, nil)
}
let semaphore = DispatchSemaphore(value: 0)
let result = FetchResult()
Task {
do {
result.latestEvent = await room.latestEvent()
result.roomInfo = try await room.roomInfo()
} catch {
MXLog.error("Failed fetching room info with error: \(error)")
}
semaphore.signal()
}
semaphore.wait()
return (result.roomInfo, result.latestEvent)
}
private func buildRoomSummary(from room: Room) -> RoomSummary {
let roomDetails = fetchRoomDetails(from: room)
private nonisolated static func buildRoomSummary(from room: Room, eventStringBuilder: RoomEventStringBuilder) async -> RoomSummary {
let roomDetails = await fetchRoomDetails(from: room)
guard let roomInfo = roomDetails.roomInfo else {
fatalError("Missing room info for \(room.id())")
@@ -347,13 +368,13 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
isTombstoned: roomInfo.successorRoom != nil)
}
private func buildDiff(from diff: RoomListEntriesUpdate, on rooms: [RoomSummary]) -> CollectionDifference<RoomSummary>? {
private nonisolated static func buildDiff(from diff: RoomListEntriesUpdate, on rooms: [RoomSummary], eventStringBuilder: RoomEventStringBuilder) async -> CollectionDifference<RoomSummary>? {
var changes = [CollectionDifference<RoomSummary>.Change]()
switch diff {
case .append(let values):
for (index, value) in values.enumerated() {
let summary = buildRoomSummary(from: value)
let summary = await buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder)
changes.append(.insert(offset: rooms.count + index, element: summary, associatedWith: nil))
}
case .clear:
@@ -361,7 +382,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
changes.append(.remove(offset: index, element: value, associatedWith: nil))
}
case .insert(let index, let value):
let summary = buildRoomSummary(from: value)
let summary = await buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder)
changes.append(.insert(offset: Int(index), element: summary, associatedWith: nil))
case .popBack:
guard let value = rooms.last else {
@@ -373,10 +394,10 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
let summary = rooms[0]
changes.append(.remove(offset: 0, element: summary, associatedWith: nil))
case .pushBack(let value):
let summary = buildRoomSummary(from: value)
let summary = await buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder)
changes.append(.insert(offset: rooms.count, element: summary, associatedWith: nil))
case .pushFront(let value):
let summary = buildRoomSummary(from: value)
let summary = await buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder)
changes.append(.insert(offset: 0, element: summary, associatedWith: nil))
case .remove(let index):
let summary = rooms[Int(index)]
@@ -387,10 +408,10 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
}
for (index, value) in values.enumerated() {
changes.append(.insert(offset: index, element: buildRoomSummary(from: value), associatedWith: nil))
await changes.append(.insert(offset: index, element: buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder), associatedWith: nil))
}
case .set(let index, let value):
let summary = buildRoomSummary(from: value)
let summary = await buildRoomSummary(from: value, eventStringBuilder: eventStringBuilder)
changes.append(.remove(offset: Int(index), element: summary, associatedWith: nil))
changes.append(.insert(offset: Int(index), element: summary, associatedWith: nil))
case .truncate(let length):
@@ -408,32 +429,40 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
private func setupNotificationSettingsSubscription() {
notificationSettings.callbacks
.receive(on: serialDispatchQueue)
.receive(on: DispatchQueue.main)
.dropFirst() // drop the first one to avoid rebuilding the summaries during the first synchronization
.sink { [weak self] callback in
guard let self else { return }
switch callback {
case .settingsDidChange:
self.rebuildRoomSummaries()
Task { await self.rebuildRoomSummaries() }
}
}
.store(in: &cancellables)
}
private func rebuildRoomSummaries() {
private func rebuildRoomSummaries() async {
MXLog.info("\(name): Rebuilding room summaries for \(rooms.count) rooms")
rooms = await Self.rebuiltRoomSummaries(from: rooms, eventStringBuilder: eventStringBuilder, name: name)
MXLog.info("\(name): Finished rebuilding room summaries (\(rooms.count) rooms)")
}
@concurrent
private static func rebuiltRoomSummaries(from rooms: [RoomSummary], eventStringBuilder: RoomEventStringBuilder, name: String) async -> [RoomSummary] {
let span = MXLog.createSpan("\(name).rebuild_room_summaries")
span.enter()
defer {
span.exit()
}
MXLog.info("\(name): Rebuilding room summaries for \(rooms.count) rooms")
rooms = rooms.map {
self.buildRoomSummary(from: $0.room)
var rebuiltRooms = [RoomSummary]()
rebuiltRooms.reserveCapacity(rooms.count)
for room in rooms {
await rebuiltRooms.append(buildRoomSummary(from: room.room, eventStringBuilder: eventStringBuilder))
}
MXLog.info("\(name): Finished rebuilding room summaries (\(rooms.count) rooms)")
return rebuiltRooms
}
}