Merge pull request #879 from kiwix/single-background-context

Fix Crash with Single DB background context
This commit is contained in:
Kelson 2024-07-25 22:17:25 +02:00 committed by GitHub
commit 2b1f49d39f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 129 additions and 176 deletions

View File

@ -36,7 +36,7 @@ struct Kiwix: App {
WindowGroup {
RootView()
.ignoresSafeArea()
.environment(\.managedObjectContext, Database.viewContext)
.environment(\.managedObjectContext, Database.shared.viewContext)
.environmentObject(library)
.environmentObject(navigation)
.modifier(AlertHandler())
@ -45,7 +45,7 @@ struct Kiwix: App {
.modifier(SaveContentHandler())
.onChange(of: scenePhase) { newValue in
guard newValue == .inactive else { return }
try? Database.viewContext.save()
try? Database.shared.viewContext.save()
}
.onOpenURL { url in
if url.isFileURL {

View File

@ -39,7 +39,7 @@ struct Kiwix: App {
var body: some Scene {
WindowGroup {
RootView()
.environment(\.managedObjectContext, Database.shared.container.viewContext)
.environment(\.managedObjectContext, Database.shared.viewContext)
.environmentObject(libraryRefreshViewModel)
}.commands {
SidebarCommands()
@ -214,10 +214,8 @@ struct RootView: View {
DownloadService.shared.restartHeartbeatIfNeeded()
case let .custom(zimFileURL):
LibraryOperations.open(url: zimFileURL) {
Task {
await ZimMigration.forCustomApps()
navigation.currentItem = .reading
}
ZimMigration.forCustomApps()
navigation.currentItem = .reading
}
}
}

View File

@ -40,7 +40,7 @@ class SidebarViewController: UICollectionViewController, NSFetchedResultsControl
}()
private let fetchedResultController = NSFetchedResultsController(
fetchRequest: Tab.fetchRequest(sortDescriptors: [NSSortDescriptor(key: "created", ascending: false)]),
managedObjectContext: Database.viewContext,
managedObjectContext: Database.shared.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
@ -187,7 +187,8 @@ class SidebarViewController: UICollectionViewController, NSFetchedResultsControl
// MARK: - Collection View Configuration
private func configureCell(cell: UICollectionViewListCell, indexPath: IndexPath, item: NavigationItem) {
if case let .tab(objectID) = item, let tab = try? Database.viewContext.existingObject(with: objectID) as? Tab {
if case let .tab(objectID) = item,
let tab = try? Database.shared.viewContext.existingObject(with: objectID) as? Tab {
var config = cell.defaultContentConfiguration()
config.text = tab.title ?? "common.tab.menu.new_tab".localized
if let zimFile = tab.zimFile, let category = Category(rawValue: zimFile.category) {

View File

@ -111,7 +111,10 @@ final class SplitViewController: UISplitViewController {
let controller = UIHostingController(rootView: ZimFilesCategories(dismiss: nil))
setViewController(UINavigationController(rootViewController: controller), for: .secondary)
case .downloads:
let controller = UIHostingController(rootView: ZimFilesDownloads(dismiss: nil))
let controller = UIHostingController(
rootView: ZimFilesDownloads(dismiss: nil)
.environment(\.managedObjectContext, Database.shared.viewContext)
)
setViewController(UINavigationController(rootViewController: controller), for: .secondary)
case .new:
let controller = UIHostingController(rootView: ZimFilesNew(dismiss: nil))

View File

@ -49,7 +49,7 @@ private final class DownloadProgress {
final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegate, URLSessionDownloadDelegate {
static let shared = DownloadService()
private let queue = DispatchQueue(label: "downloads")
private let queue = DispatchQueue(label: "downloads", qos: .background)
private let progress = DownloadProgress()
@MainActor private var heartbeat: Timer?
var backgroundCompletionHandler: (() -> Void)?
@ -74,40 +74,38 @@ final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegat
let zimFileID = UUID(uuidString: taskDescription) else { return }
self.progress.updateFor(uuid: zimFileID, totalBytes: task.countOfBytesReceived)
}
self.startHeartbeat()
Task { @MainActor in
self.startHeartbeat()
}
}
}
/// Start heartbeat, which will update database every 0.25 second
private func startHeartbeat() {
DispatchQueue.main.async {
guard self.heartbeat == nil else { return }
self.heartbeat = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
Database.shared.container.performBackgroundTask { [weak self] context in
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
if let progressValues = self?.progress.values() {
for (zimFileID, downloadedBytes) in progressValues {
let predicate = NSPredicate(format: "fileID == %@", zimFileID as CVarArg)
let request = DownloadTask.fetchRequest(predicate: predicate)
guard let downloadTask = try? context.fetch(request).first else { return }
downloadTask.downloadedBytes = downloadedBytes
}
try? context.save()
@MainActor private func startHeartbeat() {
guard self.heartbeat == nil else { return }
self.heartbeat = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
Database.shared.performBackgroundTask { [weak self] context in
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
if let progressValues = self?.progress.values() {
for (zimFileID, downloadedBytes) in progressValues {
let predicate = NSPredicate(format: "fileID == %@", zimFileID as CVarArg)
let request = DownloadTask.fetchRequest(predicate: predicate)
guard let downloadTask = try? context.fetch(request).first else { return }
downloadTask.downloadedBytes = downloadedBytes
}
try? context.save()
}
}
os_log("Heartbeat started.", log: Log.DownloadService, type: .info)
}
os_log("Heartbeat started.", log: Log.DownloadService, type: .info)
}
/// Stop heartbeat, which stops periodical database update
private func stopHeartbeat() {
DispatchQueue.main.async {
guard self.heartbeat != nil else { return }
self.heartbeat?.invalidate()
self.heartbeat = nil
os_log("Heartbeat stopped.", log: Log.DownloadService, type: .info)
}
@MainActor private func stopHeartbeat() {
guard self.heartbeat != nil else { return }
self.heartbeat?.invalidate()
self.heartbeat = nil
os_log("Heartbeat stopped.", log: Log.DownloadService, type: .info)
}
// MARK: - Download Actions
@ -116,9 +114,9 @@ final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegat
/// - Parameters:
/// - zimFile: the zim file to download
/// - allowsCellularAccess: if using cellular data is allowed
func start(zimFileID: UUID, allowsCellularAccess: Bool) {
@MainActor func start(zimFileID: UUID, allowsCellularAccess: Bool) {
requestNotificationAuthorization()
Database.shared.container.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let fetchRequest = ZimFile.fetchRequest(fileID: zimFileID)
guard let zimFile = try? context.fetch(fetchRequest).first,
@ -160,7 +158,7 @@ final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegat
session.getTasksWithCompletionHandler { _, _, downloadTasks in
guard let task = downloadTasks.filter({ $0.taskDescription == zimFileID.uuidString }).first else { return }
task.cancel { resumeData in
Database.shared.container.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let request = DownloadTask.fetchRequest(fileID: zimFileID)
guard let downloadTask = try? context.fetch(request).first else { return }
@ -173,9 +171,9 @@ final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegat
/// Resume a zim file download task and start heartbeat
/// - Parameter zimFileID: identifier of the zim file
func resume(zimFileID: UUID) {
@MainActor func resume(zimFileID: UUID) {
requestNotificationAuthorization()
Database.shared.container.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let request = DownloadTask.fetchRequest(fileID: zimFileID)
@ -190,14 +188,16 @@ final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegat
downloadTask.resumeData = nil
try? context.save()
self.startHeartbeat()
Task { @MainActor in
self.startHeartbeat()
}
}
}
// MARK: - Database
private func deleteDownloadTask(zimFileID: UUID) {
Database.shared.container.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
do {
let request = DownloadTask.fetchRequest(fileID: zimFileID)
@ -217,7 +217,7 @@ final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegat
// MARK: - Notification
private func requestNotificationAuthorization() {
@MainActor private func requestNotificationAuthorization() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
}
@ -225,7 +225,7 @@ final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegat
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard settings.authorizationStatus != .denied else { return }
Database.shared.container.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
// configure notification content
let content = UNMutableNotificationContent()
content.title = "download_service.complete.title".localized
@ -248,7 +248,9 @@ final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegat
let zimFileID = UUID(uuidString: taskDescription) else { return }
progress.resetFor(uuid: zimFileID)
if progress.isEmpty() {
stopHeartbeat()
Task { @MainActor in
stopHeartbeat()
}
}
// download finished successfully if there's no error
@ -268,7 +270,7 @@ final class DownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegat
Note: The result data equality check is used as a trick to distinguish user pausing the download task vs
failure. When pausing, the same resume data would have already been saved when the delegate is called.
*/
Database.shared.container.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let request = DownloadTask.fetchRequest(fileID: zimFileID)
let resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data

View File

@ -18,47 +18,36 @@ import os
final class Database {
static let shared = Database()
private var notificationToken: NSObjectProtocol?
private var token: NSPersistentHistoryToken?
private var tokenURL = NSPersistentContainer.defaultDirectoryURL().appendingPathComponent("token.data")
private let container: NSPersistentContainer
private let backgroundContext: NSManagedObjectContext
private let backgroundQueue = DispatchQueue(label: "database.background.queue",
qos: .utility)
private init() {
// due to objc++ interop, only the older notification value is working for downloads:
// https://developer.apple.com/documentation/coredata/nspersistentstoreremotechangenotification?language=objc
let storeChange: NSNotification.Name = .init(rawValue: "NSPersistentStoreRemoteChangeNotification")
notificationToken = NotificationCenter.default.addObserver(
forName: storeChange, object: nil, queue: nil) { _ in
try? self.mergeChanges()
container = Self.createContainer()
backgroundContext = container.newBackgroundContext()
backgroundContext.persistentStoreCoordinator = container.persistentStoreCoordinator
backgroundContext.automaticallyMergesChangesFromParent = true
backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
backgroundContext.undoManager = nil
backgroundContext.shouldDeleteInaccessibleFaults = true
}
var viewContext: NSManagedObjectContext {
container.viewContext
}
func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
backgroundQueue.sync { [self] in
backgroundContext.perform { [self] in
block(backgroundContext)
}
}
token = {
guard let data = UserDefaults.standard.data(forKey: "PersistentHistoryToken") else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSPersistentHistoryToken.self, from: data)
}()
}
deinit {
if let token = notificationToken {
NotificationCenter.default.removeObserver(token)
}
}
static var viewContext: NSManagedObjectContext {
Database.shared.container.viewContext
}
static func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
Database.shared.container.performBackgroundTask(block)
}
static func performBackgroundTask<T>(_ block: @escaping (NSManagedObjectContext) throws -> T) async rethrows -> T {
try await Database.shared.container.performBackgroundTask(block)
}
/// A persistent container to set up the Core Data stack.
lazy var container: NSPersistentContainer = {
/// - Tag: persistentContainer
private static func createContainer() -> NSPersistentContainer {
let container = NSPersistentContainer(name: "DataModel")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Failed to retrieve a persistent store description.")
}
@ -75,27 +64,25 @@ final class Database {
}
}
// This sample refreshes UI by consuming store changes via persistent history tracking.
/// - Tag: viewContextMergeParentChanges
container.viewContext.automaticallyMergesChangesFromParent = false
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.name = "viewContext"
/// - Tag: viewContextMergePolicy
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.undoManager = nil
container.viewContext.shouldDeleteInaccessibleFaults = true
return container
}()
}
/// Save image data to zim files.
func saveImageData(url: URL, completion: @escaping (Data) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, _ in
URLSession.shared.dataTask(with: url) { [self] data, response, _ in
guard let response = response as? HTTPURLResponse,
response.statusCode == 200,
let mimeType = response.mimeType,
mimeType.contains("image"),
let data = data else { return }
let context = self.container.newBackgroundContext()
context.perform {
performBackgroundTask { [data] context in
let predicate = NSPredicate(format: "faviconURL == %@", url as CVarArg)
let request = ZimFile.fetchRequest(predicate: predicate)
guard let zimFile = try? context.fetch(request).first else { return }
@ -105,41 +92,4 @@ final class Database {
completion(data)
}.resume()
}
/// Merge changes performed on batch requests to view context.
private func mergeChanges() throws {
let context = container.newBackgroundContext()
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
context.undoManager = nil
context.perform {
// fetch and merge changes
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: self.token)
guard let result = try? context.execute(fetchRequest) as? NSPersistentHistoryResult else {
os_log("no persistent history found after token: \(self.token)")
self.token = nil
return
}
guard let transactions = result.result as? [NSPersistentHistoryTransaction] else {
os_log("no transactions in persistent history found after token: \(self.token)")
self.token = nil
return
}
self.container.viewContext.performAndWait {
transactions.forEach { transaction in
self.container.viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
self.token = transaction.token
}
}
// update token
guard let token = transactions.last?.token else { return }
let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
UserDefaults.standard.set(data, forKey: "PersistentHistoryToken")
// purge history
let sevenDaysAgo = Date(timeIntervalSinceNow: -3600 * 24 * 7)
let purgeRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: sevenDaysAgo)
_ = try? context.execute(purgeRequest)
}
}
}

View File

@ -43,7 +43,7 @@ struct LibraryOperations {
}
// upsert zim file in the database
Database.shared.container.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let predicate = NSPredicate(format: "fileID == %@", metadata.fileID as CVarArg)
let fetchRequest = ZimFile.fetchRequest(predicate: predicate)
@ -65,7 +65,7 @@ struct LibraryOperations {
/// Reopen zim files from url bookmark data.
static func reopen(onComplete: (() -> Void)?) {
var successCount = 0
let context = Database.shared.container.viewContext
let context = Database.shared.viewContext
let request = ZimFile.fetchRequest(predicate: ZimFile.Predicate.isDownloaded)
guard let zimFiles = try? context.fetch(request) else {
@ -149,7 +149,7 @@ struct LibraryOperations {
/// - Parameter zimFile: the zim file to unlink
static func unlink(zimFileID: UUID) {
ZimFileService.shared.close(fileID: zimFileID)
Database.shared.container.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
guard let zimFile = try? ZimFile.fetchRequest(fileID: zimFileID).execute().first else { return }
zimFile.bookmarks.forEach { context.delete($0) }

View File

@ -33,9 +33,9 @@ enum ZimMigration {
sortDescriptors: Self.sortDescriptors
)
static func forCustomApps() async {
static func forCustomApps() {
guard FeatureFlags.hasLibrary == false else { return }
await Database.shared.container.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
guard var zimFiles = try? requestLatestZimFile.execute(),
zimFiles.count > 1,
let latest = zimFiles.popLast() else {
@ -77,17 +77,16 @@ enum ZimMigration {
if context.hasChanges { try? context.save() }
}
@MainActor
private static func latestZimFileHost() async -> String {
if let newHost = await Self.newHost { return newHost }
if let newHost = Self.newHost { return newHost }
// if it wasn't set before, set and return by the last ZimFile in DB:
guard let zimFile = try? Database.viewContext.fetch(requestLatestZimFile).first else {
guard let zimFile = try? Database.shared.viewContext.fetch(requestLatestZimFile).first else {
fatalError("we should have at least 1 zim file for a custom app")
}
let newHost = zimFile.fileID.uuidString
// save the new host for later
await MainActor.run {
Self.newHost = newHost
}
Self.newHost = newHost
return newHost
}
}

View File

@ -27,7 +27,7 @@ final class CategoryFetchingTests: XCTestCase {
func testFilteredOutByLanguage() throws {
// insert a zimFile
let context = Database.viewContext
let context = Database.shared.viewContext
let zimFile = ZimFile(context: context)
let metadata = ZimFileMetaData.mock(languageCodes: "eng",
category: Category.other.rawValue)
@ -45,7 +45,7 @@ final class CategoryFetchingTests: XCTestCase {
func testCanBeFoundByLanguage() throws {
// insert a zimFile
let context = Database.viewContext
let context = Database.shared.viewContext
let zimFile = ZimFile(context: context)
let metadata = ZimFileMetaData.mock(languageCodes: "eng",
category: Category.other.rawValue)
@ -63,7 +63,7 @@ final class CategoryFetchingTests: XCTestCase {
func testCanBeFoundByMultipleUserLanguages() throws {
// insert a zimFile
let context = Database.viewContext
let context = Database.shared.viewContext
let zimFile = ZimFile(context: context)
let metadata = ZimFileMetaData.mock(languageCodes: "fra",
category: Category.other.rawValue)
@ -81,7 +81,7 @@ final class CategoryFetchingTests: XCTestCase {
func testCanBeFoundHavingMultiLanguagesWithASingleUserLanguage() throws {
// insert a zimFile
let context = Database.viewContext
let context = Database.shared.viewContext
let zimFile = ZimFile(context: context)
let metadata = ZimFileMetaData.mock(languageCodes: "eng,fra,deu,nld,spa,ita,por,pol,ara,vie,kor",
category: Category.other.rawValue)
@ -99,7 +99,7 @@ final class CategoryFetchingTests: XCTestCase {
func testCanBeFoundHavingMultiLanguageMatches() throws {
// insert a zimFile
let context = Database.viewContext
let context = Database.shared.viewContext
let zimFile = ZimFile(context: context)
let metadata = ZimFileMetaData.mock(languageCodes: "eng,fra,deu,nld,spa,ita,por,pol,ara,vie,kor",
category: Category.other.rawValue)
@ -117,7 +117,7 @@ final class CategoryFetchingTests: XCTestCase {
func testFilteredOutByMultiToMultiLanguageMissMatch() throws {
// insert a zimFile
let context = Database.viewContext
let context = Database.shared.viewContext
let zimFile = ZimFile(context: context)
let metadata = ZimFileMetaData.mock(languageCodes: "eng,fra,deu,nld,spa,ita",
category: Category.other.rawValue)
@ -134,7 +134,7 @@ final class CategoryFetchingTests: XCTestCase {
}
private func resetDB() throws {
_ = try Database.viewContext.execute(
_ = try Database.shared.viewContext.execute(
NSBatchDeleteRequest(
fetchRequest: NSFetchRequest(entityName: ZimFile.entity().name!)
)

View File

@ -171,7 +171,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
XCTAssertNil(viewModel.error)
// check one zim file is in the database
let context = Database.shared.container.viewContext
let context = Database.shared.viewContext
let zimFiles = try context.fetch(ZimFile.fetchRequest())
XCTAssertEqual(zimFiles.count, 1)
XCTAssertEqual(zimFiles[0].id, zimFileID)
@ -215,7 +215,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
// refresh library for the first time, which should create one zim file
let viewModel = LibraryViewModel(urlSession: urlSession)
await viewModel.start(isUserInitiated: true)
let context = Database.shared.container.viewContext
let context = Database.shared.viewContext
let zimFile1 = try XCTUnwrap(try context.fetch(ZimFile.fetchRequest()).first)
// refresh library for the second time, which should replace the old zim file with a new one

View File

@ -104,7 +104,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
// Bookmark fetching:
bookmarkFetchedResultsController = NSFetchedResultsController(
fetchRequest: Bookmark.fetchRequest(), // initially empty
managedObjectContext: Database.viewContext,
managedObjectContext: Database.shared.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
@ -183,7 +183,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
private func didUpdate(title: String, url: URL) {
let zimFile: ZimFile? = {
guard let zimFileID = UUID(uuidString: url.host ?? "") else { return nil }
return try? Database.viewContext.fetch(ZimFile.fetchRequest(fileID: zimFileID)).first
return try? Database.shared.viewContext.fetch(ZimFile.fetchRequest(fileID: zimFileID)).first
}()
metaData = ZimFileService.shared.getContentMetaData(url: url)
@ -204,14 +204,14 @@ final class BrowserViewModel: NSObject, ObservableObject,
tabID = currentTabID
// update tab data
if let tab = try? Database.viewContext.existingObject(with: currentTabID) as? Tab {
if let tab = try? Database.shared.viewContext.existingObject(with: currentTabID) as? Tab {
tab.title = articleTitle
tab.zimFile = zimFile
}
}
func updateLastOpened() {
guard let tabID, let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else { return }
guard let tabID, let tab = try? Database.shared.viewContext.existingObject(with: tabID) as? Tab else { return }
tab.lastOpened = Date()
}
@ -221,11 +221,11 @@ final class BrowserViewModel: NSObject, ObservableObject,
func persistState() {
guard let tabID,
let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab else {
let tab = try? Database.shared.viewContext.existingObject(with: tabID) as? Tab else {
return
}
tab.interactionState = webView.interactionState as? Data
try? Database.viewContext.save()
try? Database.shared.viewContext.save()
}
// MARK: - Content Loading
@ -251,7 +251,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
}
private func restoreBy(tabID: NSManagedObjectID) {
if let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab {
if let tab = try? Database.shared.viewContext.existingObject(with: tabID) as? Tab {
webView.interactionState = tab.interactionState
Task {
await MainActor.run {
@ -501,7 +501,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
// bookmark
let bookmarkAction: UIAction = {
let context = Database.viewContext
let context = Database.shared.viewContext
let predicate = NSPredicate(format: "articleURL == %@", url as CVarArg)
let request = Bookmark.fetchRequest(predicate: predicate)
@ -596,7 +596,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
private func createNewTabID() -> NSManagedObjectID {
if let tabID { return tabID }
let context = Database.viewContext
let context = Database.shared.viewContext
let tab = Tab(context: context)
tab.created = Date()
tab.lastOpened = Date()
@ -615,7 +615,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
func createBookmark(url: URL? = nil) {
guard let url = url ?? webView.url else { return }
let title = webView.title
Database.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
let bookmark = Bookmark(context: context)
bookmark.articleURL = url
bookmark.created = Date()
@ -631,7 +631,7 @@ final class BrowserViewModel: NSObject, ObservableObject,
func deleteBookmark(url: URL? = nil) {
guard let url = url ?? webView.url else { return }
Database.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
let request = Bookmark.fetchRequest(predicate: NSPredicate(format: "articleURL == %@", url as CVarArg))
guard let bookmark = try? context.fetch(request).first else { return }
context.delete(bookmark)

View File

@ -49,18 +49,12 @@ final class LibraryViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>()
private let urlSession: URLSession
private let context: NSManagedObjectContext
private var insertionCount = 0
private var deletionCount = 0
@MainActor
init(urlSession: URLSession? = nil) {
self.urlSession = urlSession ?? URLSession.shared
context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.persistentStoreCoordinator = Database.shared.container.persistentStoreCoordinator
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
context.undoManager = nil
process = LibraryProcess.shared
state = process.state
process.$state.sink { [weak self] newState in
@ -204,11 +198,15 @@ final class LibraryViewModel: ObservableObject {
}
private func process(parser: Parser) async throws {
try await withCheckedThrowingContinuation { continuation in
context.perform {
try await withCheckedThrowingContinuation { [weak self] continuation -> Void in
Database.shared.performBackgroundTask { [weak self] context in
guard let self else {
continuation.resume()
return
}
do {
// insert new zim files
let existing = try self.context.fetch(ZimFile.fetchRequest()).map { $0.fileID }
let existing = try context.fetch(ZimFile.fetchRequest()).map { $0.fileID }
var zimFileIDs = parser.zimFileIDs.subtracting(existing)
let insertRequest = NSBatchInsertRequest(
entity: ZimFile.entity(),
@ -224,7 +222,7 @@ final class LibraryViewModel: ObservableObject {
}
)
insertRequest.resultType = .count
if let result = try self.context.execute(insertRequest) as? NSBatchInsertResult {
if let result = try context.execute(insertRequest) as? NSBatchInsertResult {
self.insertionCount = result.result as? Int ?? 0
}
@ -236,10 +234,9 @@ final class LibraryViewModel: ObservableObject {
])
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
deleteRequest.resultType = .resultTypeCount
if let result = try self.context.execute(deleteRequest) as? NSBatchDeleteResult {
if let result = try context.execute(deleteRequest) as? NSBatchDeleteResult {
self.deletionCount = result.result as? Int ?? 0
}
continuation.resume()
} catch {
os_log("Error saving OPDS Data: %s", log: Log.OPDS, type: .error, error.localizedDescription)

View File

@ -36,7 +36,7 @@ final class NavigationViewModel: ObservableObject {
}
func navigateToMostRecentTab() {
let context = Database.viewContext
let context = Database.shared.viewContext
let fetchRequest = Tab.fetchRequest(sortDescriptors: [NSSortDescriptor(key: "lastOpened", ascending: false)])
fetchRequest.fetchLimit = 1
let tab = (try? context.fetch(fetchRequest).first) ?? self.makeTab(context: context)
@ -50,7 +50,7 @@ final class NavigationViewModel: ObservableObject {
@discardableResult
func createTab() -> NSManagedObjectID {
let context = Database.viewContext
let context = Database.shared.viewContext
let tab = self.makeTab(context: context)
#if !os(macOS)
currentItem = NavigationItem.tab(objectID: tab.objectID)
@ -61,10 +61,10 @@ final class NavigationViewModel: ObservableObject {
@MainActor
func tabIDFor(url: URL?) -> NSManagedObjectID {
guard let url,
let coordinator = Database.viewContext.persistentStoreCoordinator,
let coordinator = Database.shared.viewContext.persistentStoreCoordinator,
let tabID = coordinator.managedObjectID(forURIRepresentation: url),
// make sure it's not went missing
let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab,
let tab = try? Database.shared.viewContext.existingObject(with: tabID) as? Tab,
tab.zimFile != nil
else {
return createTab()
@ -75,7 +75,7 @@ final class NavigationViewModel: ObservableObject {
/// Delete a single tab, and select another tab
/// - Parameter tabID: ID of the tab to delete
func deleteTab(tabID: NSManagedObjectID) {
Database.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
let sortByCreation = [NSSortDescriptor(key: "created", ascending: false)]
guard let tabs: [Tab] = try? context.fetch(Tab.fetchRequest(predicate: nil,
sortDescriptors: sortByCreation)),
@ -107,7 +107,7 @@ final class NavigationViewModel: ObservableObject {
/// Delete all tabs, and open a new tab
func deleteAllTabs() {
Database.performBackgroundTask { context in
Database.shared.performBackgroundTask { context in
// delete all existing tabs
let tabs = try? context.fetch(Tab.fetchRequest())
tabs?.forEach { context.delete($0) }

View File

@ -34,7 +34,7 @@ class SearchViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDel
let predicate = NSPredicate(format: "includedInSearch == true AND fileURLBookmark != nil")
fetchedResultsController = NSFetchedResultsController(
fetchRequest: ZimFile.fetchRequest(predicate: predicate),
managedObjectContext: Database.shared.container.viewContext,
managedObjectContext: Database.shared.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)

View File

@ -57,6 +57,7 @@ struct Library: View {
.navigationTitle(NavigationItem.categories.name)
case .downloads:
ZimFilesDownloads(dismiss: dismiss)
.environment(\.managedObjectContext, Database.shared.viewContext)
case .new:
ZimFilesNew(dismiss: dismiss)
}
@ -79,7 +80,7 @@ struct Library_Previews: PreviewProvider {
NavigationStack {
Library(dismiss: nil)
.environmentObject(LibraryViewModel())
.environment(\.managedObjectContext, Database.viewContext)
.environment(\.managedObjectContext, Database.shared.viewContext)
}
}
}

View File

@ -179,7 +179,10 @@ struct ZimFileDetail: View {
}
}()),
primaryButton: .default(Text("zim_file.action.download.button.anyway".localized)) {
DownloadService.shared.start(zimFileID: zimFile.id, allowsCellularAccess: false)
DownloadService.shared.start(
zimFileID: zimFile.id,
allowsCellularAccess: false
)
},
secondaryButton: .cancel()
)

View File

@ -115,7 +115,7 @@ struct ZimFilesNew_Previews: PreviewProvider {
NavigationStack {
ZimFilesNew(dismiss: nil)
.environmentObject(LibraryViewModel())
.environment(\.managedObjectContext, Database.viewContext)
.environment(\.managedObjectContext, Database.shared.viewContext)
}
}
}

View File

@ -165,8 +165,7 @@ class Languages {
fetchRequest.resultType = .dictionaryResultType
let languages: [Language] = await withCheckedContinuation { continuation in
let context = Database.shared.container.newBackgroundContext()
context.perform {
Database.shared.performBackgroundTask { context in
guard let results = try? context.fetch(fetchRequest) else {
continuation.resume(returning: [])
return