mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
Run CloudKit record mapping on the main actor to fix SwiftData race
CloudKitRecordMapper was a standalone actor, so 'await recordMapper.toCKRecord' from the @MainActor sync engine hopped off the main actor and read live SwiftData @Model properties (e.g. LocalPlaylistItem.authorName) from the wrong executor. SwiftData models are bound to the main ModelContext and are not thread-safe, so this raced the backing store and crashed with EXC_BAD_ACCESS. Make the mapper @MainActor (it must touch main-isolated models regardless) and drop the now-redundant awaits. Fixes the crash for all synced model types. Fixes an EXC_BAD_ACCESS seen in TestFlight build 261.
This commit is contained in:
@@ -9,7 +9,15 @@ import CloudKit
|
||||
import Foundation
|
||||
|
||||
/// Maps SwiftData models to CloudKit records and vice versa.
|
||||
actor CloudKitRecordMapper {
|
||||
///
|
||||
/// This MUST be `@MainActor`: the `toCKRecord(...)` methods read live SwiftData
|
||||
/// `@Model` properties (e.g. `LocalPlaylistItem.authorName`) and the `to<Model>`
|
||||
/// methods create/insert models. SwiftData models are bound to the main
|
||||
/// `ModelContext` and are not thread-safe — when this was a standalone `actor`,
|
||||
/// `await recordMapper.toCKRecord(...)` hopped off the main actor and read the
|
||||
/// backing store from the wrong executor, causing EXC_BAD_ACCESS crashes.
|
||||
@MainActor
|
||||
final class CloudKitRecordMapper {
|
||||
private let zone: CKRecordZone
|
||||
|
||||
/// Current schema version for CloudKit records.
|
||||
|
||||
@@ -548,7 +548,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
guard canSyncSubscriptions else { return }
|
||||
|
||||
Task {
|
||||
let record = await recordMapper.toCKRecord(subscription: subscription)
|
||||
let record = recordMapper.toCKRecord(subscription: subscription)
|
||||
|
||||
// Remove from deletes if it was queued for deletion
|
||||
pendingDeletes.removeAll { $0.recordName == record.recordID.recordName }
|
||||
@@ -612,7 +612,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
|
||||
// Convert all to CKRecords and add to pending queue
|
||||
for subscription in subscriptions {
|
||||
let record = await recordMapper.toCKRecord(subscription: subscription)
|
||||
let record = recordMapper.toCKRecord(subscription: subscription)
|
||||
|
||||
// Check if already queued
|
||||
if !pendingSaves.contains(where: { $0.recordID.recordName == record.recordID.recordName }) {
|
||||
@@ -648,7 +648,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
}
|
||||
|
||||
Task {
|
||||
let record = await recordMapper.toCKRecord(watchEntry: watchEntry)
|
||||
let record = recordMapper.toCKRecord(watchEntry: watchEntry)
|
||||
|
||||
// Remove from deletes if it was queued for deletion
|
||||
pendingDeletes.removeAll { $0.recordName == record.recordID.recordName }
|
||||
@@ -713,7 +713,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
|
||||
// Convert all to CKRecords and add to pending queue
|
||||
for entry in watchHistory {
|
||||
let record = await recordMapper.toCKRecord(watchEntry: entry)
|
||||
let record = recordMapper.toCKRecord(watchEntry: entry)
|
||||
|
||||
// Check if already queued
|
||||
if !pendingSaves.contains(where: { $0.recordID.recordName == record.recordID.recordName }) {
|
||||
@@ -734,7 +734,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
guard canSyncBookmarks else { return }
|
||||
|
||||
Task {
|
||||
let record = await recordMapper.toCKRecord(bookmark: bookmark)
|
||||
let record = recordMapper.toCKRecord(bookmark: bookmark)
|
||||
|
||||
// Remove from deletes if it was queued for deletion
|
||||
pendingDeletes.removeAll { $0.recordName == record.recordID.recordName }
|
||||
@@ -798,7 +798,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
|
||||
// Convert all to CKRecords and add to pending queue
|
||||
for bookmark in bookmarks {
|
||||
let record = await recordMapper.toCKRecord(bookmark: bookmark)
|
||||
let record = recordMapper.toCKRecord(bookmark: bookmark)
|
||||
|
||||
// Check if already queued
|
||||
if !pendingSaves.contains(where: { $0.recordID.recordName == record.recordID.recordName }) {
|
||||
@@ -819,13 +819,13 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
guard canSyncPlaylists else { return }
|
||||
|
||||
Task {
|
||||
let record = await recordMapper.toCKRecord(playlist: playlist)
|
||||
let record = recordMapper.toCKRecord(playlist: playlist)
|
||||
pendingSaves.removeAll { $0.recordID.recordName == record.recordID.recordName }
|
||||
pendingSaves.append(record)
|
||||
|
||||
// Also queue all items
|
||||
for item in playlist.items ?? [] {
|
||||
let itemRecord = await recordMapper.toCKRecord(playlistItem: item)
|
||||
let itemRecord = recordMapper.toCKRecord(playlistItem: item)
|
||||
pendingSaves.removeAll { $0.recordID.recordName == itemRecord.recordID.recordName }
|
||||
pendingSaves.append(itemRecord)
|
||||
}
|
||||
@@ -892,14 +892,14 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
|
||||
// Convert all playlists and their items to CKRecords
|
||||
for playlist in playlists {
|
||||
let record = await recordMapper.toCKRecord(playlist: playlist)
|
||||
let record = recordMapper.toCKRecord(playlist: playlist)
|
||||
if !pendingSaves.contains(where: { $0.recordID.recordName == record.recordID.recordName }) {
|
||||
pendingSaves.append(record)
|
||||
}
|
||||
|
||||
// Add all items
|
||||
for item in playlist.items ?? [] {
|
||||
let itemRecord = await recordMapper.toCKRecord(playlistItem: item)
|
||||
let itemRecord = recordMapper.toCKRecord(playlistItem: item)
|
||||
if !pendingSaves.contains(where: { $0.recordID.recordName == itemRecord.recordID.recordName }) {
|
||||
pendingSaves.append(itemRecord)
|
||||
}
|
||||
@@ -921,7 +921,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
guard canSyncSearchHistory else { return }
|
||||
|
||||
Task {
|
||||
let record = await recordMapper.toCKRecord(searchHistory: searchHistory)
|
||||
let record = recordMapper.toCKRecord(searchHistory: searchHistory)
|
||||
|
||||
pendingDeletes.removeAll { $0.recordName == record.recordID.recordName }
|
||||
pendingSaves.removeAll { $0.recordID.recordName == record.recordID.recordName }
|
||||
@@ -972,7 +972,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
uploadProgress?.totalOperations += searchHistory.count
|
||||
|
||||
for entry in searchHistory {
|
||||
let record = await recordMapper.toCKRecord(searchHistory: entry)
|
||||
let record = recordMapper.toCKRecord(searchHistory: entry)
|
||||
if !pendingSaves.contains(where: { $0.recordID.recordName == record.recordID.recordName }) {
|
||||
pendingSaves.append(record)
|
||||
}
|
||||
@@ -989,7 +989,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
guard canSyncSearchHistory else { return }
|
||||
|
||||
Task {
|
||||
let record = await recordMapper.toCKRecord(recentChannel: recentChannel)
|
||||
let record = recordMapper.toCKRecord(recentChannel: recentChannel)
|
||||
|
||||
pendingDeletes.removeAll { $0.recordName == record.recordID.recordName }
|
||||
pendingSaves.removeAll { $0.recordID.recordName == record.recordID.recordName }
|
||||
@@ -1040,7 +1040,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
uploadProgress?.totalOperations += recentChannels.count
|
||||
|
||||
for channel in recentChannels {
|
||||
let record = await recordMapper.toCKRecord(recentChannel: channel)
|
||||
let record = recordMapper.toCKRecord(recentChannel: channel)
|
||||
if !pendingSaves.contains(where: { $0.recordID.recordName == record.recordID.recordName }) {
|
||||
pendingSaves.append(record)
|
||||
}
|
||||
@@ -1057,7 +1057,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
guard canSyncSearchHistory else { return }
|
||||
|
||||
Task {
|
||||
let record = await recordMapper.toCKRecord(recentPlaylist: recentPlaylist)
|
||||
let record = recordMapper.toCKRecord(recentPlaylist: recentPlaylist)
|
||||
|
||||
pendingDeletes.removeAll { $0.recordName == record.recordID.recordName }
|
||||
pendingSaves.removeAll { $0.recordID.recordName == record.recordID.recordName }
|
||||
@@ -1108,7 +1108,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
uploadProgress?.totalOperations += recentPlaylists.count
|
||||
|
||||
for playlist in recentPlaylists {
|
||||
let record = await recordMapper.toCKRecord(recentPlaylist: playlist)
|
||||
let record = recordMapper.toCKRecord(recentPlaylist: playlist)
|
||||
if !pendingSaves.contains(where: { $0.recordID.recordName == record.recordID.recordName }) {
|
||||
pendingSaves.append(record)
|
||||
}
|
||||
@@ -1125,7 +1125,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
guard canSyncSubscriptions else { return }
|
||||
|
||||
Task {
|
||||
let record = await recordMapper.toCKRecord(channelNotificationSettings: settings)
|
||||
let record = recordMapper.toCKRecord(channelNotificationSettings: settings)
|
||||
|
||||
// Remove from deletes if it was queued for deletion
|
||||
pendingDeletes.removeAll { $0.recordName == record.recordID.recordName }
|
||||
@@ -1185,7 +1185,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
uploadProgress?.totalOperations += allSettings.count
|
||||
|
||||
for settings in allSettings {
|
||||
let record = await recordMapper.toCKRecord(channelNotificationSettings: settings)
|
||||
let record = recordMapper.toCKRecord(channelNotificationSettings: settings)
|
||||
if !pendingSaves.contains(where: { $0.recordID.recordName == record.recordID.recordName }) {
|
||||
pendingSaves.append(record)
|
||||
}
|
||||
@@ -1222,7 +1222,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
|
||||
for preset in presets {
|
||||
do {
|
||||
let record = try await recordMapper.toCKRecord(preset: preset)
|
||||
let record = try recordMapper.toCKRecord(preset: preset)
|
||||
if !pendingSaves.contains(where: { $0.recordID.recordName == record.recordID.recordName }) {
|
||||
pendingSaves.append(record)
|
||||
}
|
||||
@@ -1244,7 +1244,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
|
||||
Task {
|
||||
do {
|
||||
let record = try await recordMapper.toCKRecord(preset: preset)
|
||||
let record = try recordMapper.toCKRecord(preset: preset)
|
||||
|
||||
pendingDeletes.removeAll { $0.recordName == record.recordID.recordName }
|
||||
pendingSaves.removeAll { $0.recordID.recordName == record.recordID.recordName }
|
||||
@@ -1509,7 +1509,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
/// Attaches a playlist item record to an existing playlist.
|
||||
private func attachItemToPlaylist(record: CKRecord, playlist: LocalPlaylist, dataManager: DataManager) async {
|
||||
do {
|
||||
let (item, _) = try await recordMapper.toLocalPlaylistItem(from: record)
|
||||
let (item, _) = try recordMapper.toLocalPlaylistItem(from: record)
|
||||
item.playlist = playlist
|
||||
dataManager.insertPlaylistItem(item)
|
||||
LoggingService.shared.logCloudKit("Attached item \(item.id) to playlist \(playlist.id)")
|
||||
@@ -2072,14 +2072,14 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
switch record.recordType {
|
||||
case RecordType.subscription:
|
||||
guard canSyncSubscriptions else { return .success }
|
||||
let subscription = try await recordMapper.toSubscription(from: record)
|
||||
let subscription = try recordMapper.toSubscription(from: record)
|
||||
|
||||
// Check if exists locally
|
||||
if let existing = dataManager.subscription(for: subscription.channelID) {
|
||||
// Conflict - resolve it
|
||||
let localRecord = await recordMapper.toCKRecord(subscription: existing)
|
||||
let localRecord = recordMapper.toCKRecord(subscription: existing)
|
||||
let resolved = await conflictResolver.resolveSubscriptionConflict(local: localRecord, server: record)
|
||||
let resolvedSubscription = try await recordMapper.toSubscription(from: resolved)
|
||||
let resolvedSubscription = try recordMapper.toSubscription(from: resolved)
|
||||
|
||||
// Update existing subscription with resolved data
|
||||
existing.name = resolvedSubscription.name
|
||||
@@ -2114,7 +2114,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
case RecordType.watchEntry:
|
||||
guard canSyncPlaybackHistory else { return .success }
|
||||
let watchEntry = try await recordMapper.toWatchEntry(from: record)
|
||||
let watchEntry = try recordMapper.toWatchEntry(from: record)
|
||||
|
||||
guard shouldSyncWatchEntry(watchEntry) else {
|
||||
LoggingService.shared.logCloudKit("Ignoring incoming local-folder watch entry: \(watchEntry.videoID)")
|
||||
@@ -2124,9 +2124,9 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
// Check if exists locally
|
||||
if let existing = dataManager.watchEntry(for: watchEntry.videoID) {
|
||||
// Conflict - resolve it
|
||||
let localRecord = await recordMapper.toCKRecord(watchEntry: existing)
|
||||
let localRecord = recordMapper.toCKRecord(watchEntry: existing)
|
||||
let resolved = await conflictResolver.resolveWatchEntryConflict(local: localRecord, server: record)
|
||||
let resolvedEntry = try await recordMapper.toWatchEntry(from: resolved)
|
||||
let resolvedEntry = try recordMapper.toWatchEntry(from: resolved)
|
||||
|
||||
// Update existing watch entry with resolved data
|
||||
existing.watchedSeconds = resolvedEntry.watchedSeconds
|
||||
@@ -2159,14 +2159,14 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
case RecordType.bookmark:
|
||||
guard canSyncBookmarks else { return .success }
|
||||
let bookmark = try await recordMapper.toBookmark(from: record)
|
||||
let bookmark = try recordMapper.toBookmark(from: record)
|
||||
|
||||
// Check if exists locally
|
||||
if let existing = dataManager.bookmark(for: bookmark.videoID) {
|
||||
// Conflict - resolve it
|
||||
let localRecord = await recordMapper.toCKRecord(bookmark: existing)
|
||||
let localRecord = recordMapper.toCKRecord(bookmark: existing)
|
||||
let resolved = await conflictResolver.resolveBookmarkConflict(local: localRecord, server: record)
|
||||
let resolvedBookmark = try await recordMapper.toBookmark(from: resolved)
|
||||
let resolvedBookmark = try recordMapper.toBookmark(from: resolved)
|
||||
|
||||
// Update existing bookmark with resolved data
|
||||
existing.title = resolvedBookmark.title
|
||||
@@ -2203,7 +2203,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
case RecordType.localPlaylist:
|
||||
guard canSyncPlaylists else { return .success }
|
||||
let playlist = try await recordMapper.toLocalPlaylist(from: record)
|
||||
let playlist = try recordMapper.toLocalPlaylist(from: record)
|
||||
|
||||
// Check if exists locally
|
||||
if let existing = dataManager.playlist(forID: playlist.id) {
|
||||
@@ -2226,9 +2226,9 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
LoggingService.shared.logCloudKit("Upgraded placeholder to real playlist: \(playlist.title)")
|
||||
} else {
|
||||
// Conflict - resolve it
|
||||
let localRecord = await recordMapper.toCKRecord(playlist: existing)
|
||||
let localRecord = recordMapper.toCKRecord(playlist: existing)
|
||||
let resolved = await conflictResolver.resolveLocalPlaylistConflict(local: localRecord, server: record)
|
||||
let resolvedPlaylist = try await recordMapper.toLocalPlaylist(from: resolved)
|
||||
let resolvedPlaylist = try recordMapper.toLocalPlaylist(from: resolved)
|
||||
|
||||
// Update existing playlist with resolved data
|
||||
existing.title = resolvedPlaylist.title
|
||||
@@ -2254,14 +2254,14 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
case RecordType.localPlaylistItem:
|
||||
guard canSyncPlaylists else { return .success }
|
||||
let (item, playlistID) = try await recordMapper.toLocalPlaylistItem(from: record)
|
||||
let (item, playlistID) = try recordMapper.toLocalPlaylistItem(from: record)
|
||||
|
||||
// Check if exists locally
|
||||
if let existing = dataManager.playlistItem(forID: item.id) {
|
||||
// Conflict - resolve it
|
||||
let localRecord = await recordMapper.toCKRecord(playlistItem: existing)
|
||||
let localRecord = recordMapper.toCKRecord(playlistItem: existing)
|
||||
let resolved = await conflictResolver.resolveLocalPlaylistItemConflict(local: localRecord, server: record)
|
||||
let (resolvedItem, _) = try await recordMapper.toLocalPlaylistItem(from: resolved)
|
||||
let (resolvedItem, _) = try recordMapper.toLocalPlaylistItem(from: resolved)
|
||||
|
||||
// Update existing item with resolved data
|
||||
existing.title = resolvedItem.title
|
||||
@@ -2299,14 +2299,14 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
case RecordType.searchHistory:
|
||||
guard canSyncSearchHistory else { return .success }
|
||||
let searchHistory = try await recordMapper.toSearchHistory(from: record)
|
||||
let searchHistory = try recordMapper.toSearchHistory(from: record)
|
||||
|
||||
// Check if exists locally
|
||||
if let existing = dataManager.searchHistoryEntry(forID: searchHistory.id) {
|
||||
// Conflict - resolve it
|
||||
let localRecord = await recordMapper.toCKRecord(searchHistory: existing)
|
||||
let localRecord = recordMapper.toCKRecord(searchHistory: existing)
|
||||
let resolved = await conflictResolver.resolveSearchHistoryConflict(local: localRecord, server: record)
|
||||
let resolvedHistory = try await recordMapper.toSearchHistory(from: resolved)
|
||||
let resolvedHistory = try recordMapper.toSearchHistory(from: resolved)
|
||||
|
||||
// Update existing search history with resolved data
|
||||
existing.query = resolvedHistory.query
|
||||
@@ -2330,14 +2330,14 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
case RecordType.recentChannel:
|
||||
guard canSyncSearchHistory else { return .success }
|
||||
let recentChannel = try await recordMapper.toRecentChannel(from: record)
|
||||
let recentChannel = try recordMapper.toRecentChannel(from: record)
|
||||
|
||||
// Check if exists locally
|
||||
if let existing = dataManager.recentChannelEntry(forChannelID: recentChannel.channelID) {
|
||||
// Conflict - resolve it
|
||||
let localRecord = await recordMapper.toCKRecord(recentChannel: existing)
|
||||
let localRecord = recordMapper.toCKRecord(recentChannel: existing)
|
||||
let resolved = await conflictResolver.resolveRecentChannelConflict(local: localRecord, server: record)
|
||||
let resolvedChannel = try await recordMapper.toRecentChannel(from: resolved)
|
||||
let resolvedChannel = try recordMapper.toRecentChannel(from: resolved)
|
||||
|
||||
// Update existing recent channel with resolved data
|
||||
existing.name = resolvedChannel.name
|
||||
@@ -2366,14 +2366,14 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
case RecordType.recentPlaylist:
|
||||
guard canSyncSearchHistory else { return .success }
|
||||
let recentPlaylist = try await recordMapper.toRecentPlaylist(from: record)
|
||||
let recentPlaylist = try recordMapper.toRecentPlaylist(from: record)
|
||||
|
||||
// Check if exists locally
|
||||
if let existing = dataManager.recentPlaylistEntry(forPlaylistID: recentPlaylist.playlistID) {
|
||||
// Conflict - resolve it
|
||||
let localRecord = await recordMapper.toCKRecord(recentPlaylist: existing)
|
||||
let localRecord = recordMapper.toCKRecord(recentPlaylist: existing)
|
||||
let resolved = await conflictResolver.resolveRecentPlaylistConflict(local: localRecord, server: record)
|
||||
let resolvedPlaylist = try await recordMapper.toRecentPlaylist(from: resolved)
|
||||
let resolvedPlaylist = try recordMapper.toRecentPlaylist(from: resolved)
|
||||
|
||||
// Update existing recent playlist with resolved data
|
||||
existing.title = resolvedPlaylist.title
|
||||
@@ -2402,7 +2402,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
case RecordType.channelNotificationSettings:
|
||||
guard canSyncSubscriptions else { return .success }
|
||||
let settings = try await recordMapper.toChannelNotificationSettings(from: record)
|
||||
let settings = try recordMapper.toChannelNotificationSettings(from: record)
|
||||
|
||||
// Use upsert which handles conflict resolution based on updatedAt
|
||||
dataManager.upsertChannelNotificationSettings(settings)
|
||||
@@ -2411,7 +2411,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
|
||||
case RecordType.controlsPreset:
|
||||
guard canSyncControlsPresets else { return .success }
|
||||
let preset = try await recordMapper.toLayoutPreset(from: record)
|
||||
let preset = try recordMapper.toLayoutPreset(from: record)
|
||||
|
||||
// Only import if device class matches current device
|
||||
guard preset.deviceClass == .current else {
|
||||
|
||||
Reference in New Issue
Block a user