ReadKeep/readeck/Data/CoreData/CoreDataManager.swift
Ilyas Hallak ffb41347af refactor: Implement state machine architecture for offline sync
- Replace multiple boolean properties with single OfflineBookmarkSyncState enum
- Add Use Case pattern for OfflineSyncManager with dependency injection
- Simplify LocalBookmarksSyncView using state-driven UI with external bindings
- Convert OfflineBookmarksViewModel to use @Observable instead of ObservableObject
- Move credentials from Core Data to Keychain for better persistence
- Implement comprehensive database migration for App Group containers
- Add structured logging throughout sync operations and API calls

Architecture improvements follow MVVM principles with clean separation of concerns.
2025-08-18 22:58:42 +02:00

180 lines
7.3 KiB
Swift

import CoreData
import Foundation
class CoreDataManager {
static let shared = CoreDataManager()
private var isInMemoryStore = false
private let logger = Logger.data
private init() {}
lazy var persistentContainer: NSPersistentContainer = {
// Try to find the model in the main bundle first, then in extension bundle
guard let modelURL = Bundle.main.url(forResource: "readeck", withExtension: "momd") ??
Bundle(for: CoreDataManager.self).url(forResource: "readeck", withExtension: "momd") else {
logger.error("Could not find Core Data model file")
fatalError("Core Data model 'readeck.xcdatamodeld' not found in bundle")
}
guard let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else {
logger.error("Could not load Core Data model from URL: \(modelURL)")
fatalError("Failed to load Core Data model")
}
let container = NSPersistentContainer(name: "readeck", managedObjectModel: managedObjectModel)
// Use App Group container for shared access with extensions
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite")
if let storeURL = storeURL {
// Migrate existing database from app container to app group if needed
migrateStoreToAppGroupIfNeeded(targetURL: storeURL)
let storeDescription = NSPersistentStoreDescription(url: storeURL)
container.persistentStoreDescriptions = [storeDescription]
}
container.loadPersistentStores { [weak self] _, error in
if let error = error {
self?.logger.error("Core Data failed to load persistent store: \(error)", file: #file, function: #function, line: #line)
self?.setupInMemoryStore(container: container)
} else {
self?.logger.info("Core Data persistent store loaded successfully")
}
}
return container
}()
var context: NSManagedObjectContext {
return persistentContainer.viewContext
}
func save() {
if context.hasChanges {
do {
try context.save()
logger.debug("Core Data context saved successfully")
} catch {
logger.error("Failed to save Core Data context: \(error.localizedDescription)")
}
}
}
private func setupInMemoryStore(container: NSPersistentContainer) {
logger.warning("Setting up in-memory Core Data store as fallback")
isInMemoryStore = true
let inMemoryDescription = NSPersistentStoreDescription()
inMemoryDescription.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [inMemoryDescription]
container.loadPersistentStores { [weak self] _, error in
if let error = error {
self?.logger.error("Failed to setup in-memory store: \(error.localizedDescription)")
// Continue with empty container - app will work with reduced functionality
} else {
self?.logger.info("In-memory Core Data store setup successfully")
}
}
}
private func migrateStoreToAppGroupIfNeeded(targetURL: URL) {
let fileManager = FileManager.default
// Check if store already exists in app group
if fileManager.fileExists(atPath: targetURL.path) {
logger.info("Database already exists in app group container")
return
}
// Try multiple possible old locations for database
var searchPaths: [URL] = []
// 1. App's documents directory (most common old location)
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
searchPaths.append(documentsURL)
}
// 2. App's library directory
if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first {
searchPaths.append(libraryURL)
}
// 3. App's support directory
if let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
searchPaths.append(supportURL)
}
var foundOldStore = false
for searchPath in searchPaths {
let oldStoreURL = searchPath.appendingPathComponent("readeck.sqlite")
if fileManager.fileExists(atPath: oldStoreURL.path) {
logger.info("Found existing database at: \(oldStoreURL.path)")
foundOldStore = true
if migrateFromPath(oldStoreURL: oldStoreURL, targetURL: targetURL) {
break // Successfully migrated, stop searching
}
}
}
if !foundOldStore {
logger.info("No existing database found in any search location - starting fresh")
}
}
private func migrateFromPath(oldStoreURL: URL, targetURL: URL) -> Bool {
let fileManager = FileManager.default
let oldStoreWAL = oldStoreURL.appendingPathExtension("wal")
let oldStoreSHM = oldStoreURL.appendingPathExtension("shm")
logger.info("Migrating existing database from: \(oldStoreURL.path)")
logger.info("Migrating existing database to: \(targetURL.path)")
do {
// Create app group directory if it doesn't exist
let appGroupDirectory = targetURL.deletingLastPathComponent()
try fileManager.createDirectory(at: appGroupDirectory, withIntermediateDirectories: true)
// Copy main database file
try fileManager.copyItem(at: oldStoreURL, to: targetURL)
logger.info("Main database file migrated successfully")
// Copy WAL file if it exists
if fileManager.fileExists(atPath: oldStoreWAL.path) {
let targetWAL = targetURL.appendingPathExtension("wal")
try fileManager.copyItem(at: oldStoreWAL, to: targetWAL)
logger.info("WAL file migrated successfully")
}
// Copy SHM file if it exists
if fileManager.fileExists(atPath: oldStoreSHM.path) {
let targetSHM = targetURL.appendingPathExtension("shm")
try fileManager.copyItem(at: oldStoreSHM, to: targetSHM)
logger.info("SHM file migrated successfully")
}
// Remove old files after successful migration
try fileManager.removeItem(at: oldStoreURL)
if fileManager.fileExists(atPath: oldStoreWAL.path) {
try fileManager.removeItem(at: oldStoreWAL)
}
if fileManager.fileExists(atPath: oldStoreSHM.path) {
try fileManager.removeItem(at: oldStoreSHM)
}
logger.info("Database migration completed successfully")
return true
} catch {
logger.error("Failed to migrate database from \(oldStoreURL.path): \(error.localizedDescription)")
return false
}
}
}