crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 85509e32c9d7703346dd45e607972f5f7de3d33c
parent 4cf942d22fdf51a3dff8ceca0059e37389bad64e
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 02:24:12 +0900

Clear Swift concurrency warnings

This commit removes the Swift 6 concurrency warnings surfaced by a fresh
Xcode build. The Notification Service Extension now treats the
UserNotifications import as a pre-concurrency boundary, carries its
content handler as Sendable, and keeps the delivered-notification
coalescing helper static so the framework callback does not capture the
mutable extension instance.

The Core Data background closures in GameStore now return their
diagnostics and deferred log messages as plain values. Logging happens
after the perform boundary, which avoids calling the main-actor event
log from the context queue and avoids mutating a captured diagnostics
array from concurrently executing code.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Persistence/GameStore.swift | 50++++++++++++++++++++++++++++++--------------------
MNotificationService/NotificationService.swift | 12++++++------
2 files changed, 36 insertions(+), 26 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -582,7 +582,7 @@ final class GameStore { @discardableResult func reconcileBlockedFriendHiddenGames(forAuthorIDs authorIDs: Set<String>) async -> Int { let ctx = persistence.container.newBackgroundContext() - return await ctx.perform { + let result: (changed: Int, errorMessage: String?) = await ctx.perform { let changed = GameEntity.reconcileBlockedFriendHiddenGames( forAuthorIDs: authorIDs, in: ctx @@ -591,15 +591,18 @@ final class GameStore { do { try ctx.save() } catch { - self.eventLog?.note( - "GameStore: reconcileBlockedFriendHiddenGames save failed — \(error)", - level: "error" + return ( + changed: 0, + errorMessage: "GameStore: reconcileBlockedFriendHiddenGames save failed — \(error)" ) - return 0 } } - return changed + return (changed: changed, errorMessage: nil) + } + if let errorMessage = result.errorMessage { + eventLog?.note(errorMessage, level: "error") } + return result.changed } /// Re-applies the block-derived visibility rule to games that just changed @@ -608,7 +611,7 @@ final class GameStore { @discardableResult func reconcileBlockedFriendHiddenGames(forGameIDs gameIDs: Set<UUID>) async -> Int { let ctx = persistence.container.newBackgroundContext() - return await ctx.perform { + let result: (changed: Int, errorMessage: String?) = await ctx.perform { let changed = GameEntity.reconcileBlockedFriendHiddenGames( forGameIDs: gameIDs, in: ctx @@ -617,15 +620,18 @@ final class GameStore { do { try ctx.save() } catch { - self.eventLog?.note( - "GameStore: reconcileBlockedFriendHiddenGames save failed — \(error)", - level: "error" + return ( + changed: 0, + errorMessage: "GameStore: reconcileBlockedFriendHiddenGames save failed — \(error)" ) - return 0 } } - return changed + return (changed: changed, errorMessage: nil) } + if let errorMessage = result.errorMessage { + eventLog?.note(errorMessage, level: "error") + } + return result.changed } private func saveContext(_ label: String) { @@ -744,8 +750,9 @@ final class GameStore { ) let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - var diagnostics: [String] = [] - await ctx.perform { + let result: (diagnostics: [String], errorMessage: String?) = await ctx.perform { + var diagnostics: [String] = [] + var errorMessage: String? for gameID in gameIDs { let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) @@ -806,20 +813,23 @@ final class GameStore { row.game = game } } - guard ctx.hasChanges else { return } + guard ctx.hasChanges else { + return (diagnostics: diagnostics, errorMessage: errorMessage) + } do { try ctx.save() } catch { - let message = "GameStore: peer change ledger save failed — \(error)" - Task { @MainActor [weak self] in - self?.eventLog?.note(message, level: "error") - } + errorMessage = "GameStore: peer change ledger save failed — \(error)" } + return (diagnostics: diagnostics, errorMessage: errorMessage) + } + if let errorMessage = result.errorMessage { + eventLog?.note(errorMessage, level: "error") } let elapsed = Date().timeIntervalSince(startedAt) eventLog?.note( "peer ledger build #\(serial) end: elapsed=\(String(format: "%.3f", elapsed))s " - + diagnostics.joined(separator: " | ") + + result.diagnostics.joined(separator: " | ") ) } diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -1,4 +1,4 @@ -import UserNotifications +@preconcurrency import UserNotifications /// Notification Service Extension. Runs in its own process when an APNs alert /// arrives with `mutable-content: 1`, with ~30s to mutate the content before @@ -24,12 +24,12 @@ import UserNotifications /// forwarding it) we fall back to the coarse top-level `kind`. final class NotificationService: UNNotificationServiceExtension { - private var contentHandler: ((UNNotificationContent) -> Void)? + private var contentHandler: (@Sendable (UNNotificationContent) -> Void)? private var bestAttemptContent: UNMutableNotificationContent? override func didReceive( _ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + withContentHandler contentHandler: @escaping @Sendable (UNNotificationContent) -> Void ) { self.contentHandler = contentHandler self.bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent @@ -174,7 +174,7 @@ final class NotificationService: UNNotificationServiceExtension { } let center = UNUserNotificationCenter.current() center.getDeliveredNotifications { delivered in - self.finalize( + Self.finalize( content: bestAttemptContent, gameID: gameID, fromAuthorID: fromAuthorID, @@ -200,7 +200,7 @@ final class NotificationService: UNNotificationServiceExtension { /// tile and merely seeds the tally. A non-pause push (or an older /// payload-less sender) carries no counts and is delivered unchanged; its /// shared collapse id still replaces any tile in place. - private func finalize( + private static func finalize( content: UNMutableNotificationContent, gameID: UUID?, fromAuthorID: String?, @@ -209,7 +209,7 @@ final class NotificationService: UNNotificationServiceExtension { pauseCounts: (fills: Int, clears: Int, checks: Int, reveals: Int)?, delivered: [UNNotification], center: UNUserNotificationCenter, - contentHandler: @escaping (UNNotificationContent) -> Void + contentHandler: @escaping @Sendable (UNNotificationContent) -> Void ) { guard let gameID, let pauseCounts else { contentHandler(content)