diff --git a/App/App_iOS.swift b/App/App_iOS.swift index de2ed2a6..45c51bec 100644 --- a/App/App_iOS.swift +++ b/App/App_iOS.swift @@ -57,7 +57,10 @@ struct Kiwix: App { DownloadService.shared.restartHeartbeatIfNeeded() case let .custom(zimFileURL): LibraryOperations.open(url: zimFileURL) { - navigation.navigateToMostRecentTab() + Task { + await ZimMigration.forCustomApps() + navigation.navigateToMostRecentTab() + } } } } diff --git a/App/App_macOS.swift b/App/App_macOS.swift index 35cc2478..a9b8c1f9 100644 --- a/App/App_macOS.swift +++ b/App/App_macOS.swift @@ -166,7 +166,10 @@ struct RootView: View { DownloadService.shared.restartHeartbeatIfNeeded() case let .custom(zimFileURL): LibraryOperations.open(url: zimFileURL) { - navigation.currentItem = .reading + Task { + await ZimMigration.forCustomApps() + navigation.currentItem = .reading + } } } } diff --git a/App/SidebarViewController.swift b/App/SidebarViewController.swift index f001efd2..af48b16a 100644 --- a/App/SidebarViewController.swift +++ b/App/SidebarViewController.swift @@ -137,22 +137,32 @@ class SidebarViewController: UICollectionViewController, NSFetchedResultsControl // MARK: - Delegations - func controller(_ controller: NSFetchedResultsController, - didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + nonisolated func controller( + _ controller: NSFetchedResultsController, + didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference + ) { let tabs = snapshot.itemIdentifiers .compactMap { $0 as? NSManagedObjectID } .map { NavigationItem.tab(objectID: $0) } var snapshot = NSDiffableDataSourceSectionSnapshot() snapshot.append(tabs) - dataSource.apply(snapshot, to: .tabs, animatingDifferences: dataSource.snapshot(for: .tabs).items.count > 0) { - // [iOS 15] when a tab is selected, reload it to refresh title and icon - guard #unavailable(iOS 16), - let indexPath = self.collectionView.indexPathsForSelectedItems?.first, - let item = self.dataSource.itemIdentifier(for: indexPath), - case .tab = item else { return } - var snapshot = self.dataSource.snapshot() - snapshot.reconfigureItems([item]) - self.dataSource.apply(snapshot, animatingDifferences: true) + Task { [snapshot] in + await MainActor.run { [snapshot] in + dataSource.apply( + snapshot, + to: .tabs, + animatingDifferences: dataSource.snapshot(for: .tabs).items.count > 0 + ) { + // [iOS 15] when a tab is selected, reload it to refresh title and icon + guard #unavailable(iOS 16), + let indexPath = self.collectionView.indexPathsForSelectedItems?.first, + let item = self.dataSource.itemIdentifier(for: indexPath), + case .tab = item else { return } + var snapshot = self.dataSource.snapshot() + snapshot.reconfigureItems([item]) + self.dataSource.apply(snapshot, animatingDifferences: true) + } + } } } diff --git a/SwiftUI/Model/LibraryOperations.swift b/SwiftUI/Model/LibraryOperations.swift index df9cb976..0c3cc345 100644 --- a/SwiftUI/Model/LibraryOperations.swift +++ b/SwiftUI/Model/LibraryOperations.swift @@ -45,8 +45,10 @@ struct LibraryOperations { zimFile.fileURLBookmark = fileURLBookmark zimFile.isMissing = false if context.hasChanges { try? context.save() } - DispatchQueue.main.async { - onComplete?() + Task { + await MainActor.run { + onComplete?() + } } } diff --git a/SwiftUI/Model/ZimMigration.swift b/SwiftUI/Model/ZimMigration.swift new file mode 100644 index 00000000..3df6c918 --- /dev/null +++ b/SwiftUI/Model/ZimMigration.swift @@ -0,0 +1,95 @@ +// +// ZimMigration.swift +// Kiwix + +import Foundation +import CoreData + +enum ZimMigration { + + /// Holds the new zimfile host: + /// Set during migration, + /// and read back when updating URLS mapped from WebView interaction state, + /// witch is saved as Data for each opened Tab + @MainActor private static var newHost: String? + private static let sortDescriptors = [NSSortDescriptor(keyPath: \ZimFile.created, ascending: true)] + private static let requestLatestZimFile = ZimFile.fetchRequest( + predicate: ZimFile.Predicate.isDownloaded, + sortDescriptors: Self.sortDescriptors + ) + + static func forCustomApps() async { + guard FeatureFlags.hasLibrary == false else { return } + await Database.shared.container.performBackgroundTask { context in + guard var zimFiles = try? requestLatestZimFile.execute(), + zimFiles.count > 1, + let latest = zimFiles.popLast() else { + return + } + + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + for zimFile in zimFiles { + migrateFrom(zimFile: zimFile, toZimFile: latest, using: context) + } + } + } + + static func customApp(url: URL) async -> URL { + let newHost = await latestZimFileHost() + guard let newURL = url.updateHost(to: newHost) else { + assertionFailure("url cannot be updated") + return url + } + return newURL + } + + /// Migrates the bookmars from an old to new zim file, + /// also updates the bookmark urls accordingly (based on the new zim id as the host of those URLs) + /// deletes the old zim file in the DB + private static func migrateFrom( + zimFile fromZim: ZimFile, + toZimFile toZim: ZimFile, + using context: NSManagedObjectContext + ) { + let newHost = toZim.fileID.uuidString + Task { + await MainActor.run { + Self.newHost = newHost + } + } + fromZim.bookmarks.forEach { (bookmark: Bookmark) in + bookmark.zimFile = toZim + if let newArticleURL = bookmark.articleURL.updateHost(to: newHost) { + bookmark.articleURL = newArticleURL + } + bookmark.thumbImageURL = bookmark.thumbImageURL?.updateHost(to: newHost) + } + fromZim.tabs.forEach { (tab: Tab) in + tab.zimFile = toZim + } + context.delete(fromZim) + if context.hasChanges { try? context.save() } + } + + private static func latestZimFileHost() async -> String { + if let newHost = await Self.newHost { return newHost } + // if it wasn't set before, set and return by the last ZimFile in DB: + guard let zimFile = try? requestLatestZimFile.execute().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 + } + return newHost + } +} + +extension URL { + func updateHost(to newHost: String) -> URL? { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return nil } + components.host = newHost + return components.url + } +} diff --git a/Tests/BookmarkMigrationTests.swift b/Tests/BookmarkMigrationTests.swift new file mode 100644 index 00000000..bd4a2bc9 --- /dev/null +++ b/Tests/BookmarkMigrationTests.swift @@ -0,0 +1,16 @@ +// +// BookmarkMigrationTests.swift +// UnitTests + +import XCTest +@testable import Kiwix + +final class BookmarkMigrationTests: XCTestCase { + + func testURLHostChange() throws { + let url = URL(string: "kiwix://64C3EA1A-5161-2B94-1F50-606DA5EC0035/wb/Saftladen")! + let newHost: String = UUID(uuidString: "A992BF76-CA94-6B60-A762-9B5BC89B5BBF")!.uuidString + let expectedURL = URL(string: "kiwix://A992BF76-CA94-6B60-A762-9B5BC89B5BBF/wb/Saftladen")! + XCTAssertEqual(url.updateHost(to: newHost), expectedURL) + } +} diff --git a/ViewModel/BrowserViewModel.swift b/ViewModel/BrowserViewModel.swift index cc72d14b..dbdf7db1 100644 --- a/ViewModel/BrowserViewModel.swift +++ b/ViewModel/BrowserViewModel.swift @@ -22,6 +22,7 @@ final class BrowserViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate { private static var cache = OrderedDictionary() + @MainActor static func getCached(tabID: NSManagedObjectID) -> BrowserViewModel { let viewModel = cache[tabID] ?? BrowserViewModel(tabID: tabID) cache.removeValue(forKey: tabID) @@ -47,7 +48,7 @@ final class BrowserViewModel: NSObject, ObservableObject, @Published private(set) var articleBookmarked = false @Published private(set) var outlineItems = [OutlineItem]() @Published private(set) var outlineItemTree = [OutlineItem]() - @Published private(set) var url: URL? { + @MainActor @Published private(set) var url: URL? { didSet { if !FeatureFlags.hasLibrary, url == nil { loadMainArticle() @@ -84,6 +85,7 @@ final class BrowserViewModel: NSObject, ObservableObject, // MARK: - Lifecycle + @MainActor init(tabID: NSManagedObjectID? = nil) { self.tabID = tabID webView = WKWebView(frame: .zero, configuration: WebViewConfiguration()) @@ -121,10 +123,18 @@ final class BrowserViewModel: NSObject, ObservableObject, // setup web view property observers canGoBackObserver = webView.observe(\.canGoBack, options: .initial) { [weak self] webView, _ in - self?.canGoBack = webView.canGoBack + Task { [weak self] in + await MainActor.run { [weak self] in + self?.canGoBack = webView.canGoBack + } + } } canGoForwardObserver = webView.observe(\.canGoForward, options: .initial) { [weak self] webView, _ in - self?.canGoForward = webView.canGoForward + Task { [weak self] in + await MainActor.run { [weak self] in + self?.canGoForward = webView.canGoForward + } + } } titleURLObserver = Publishers.CombineLatest( webView.publisher(for: \.title, options: .initial), @@ -146,7 +156,11 @@ final class BrowserViewModel: NSObject, ObservableObject, // update view model articleTitle = title zimFileName = zimFile?.name ?? "" - self.url = url + Task { + await MainActor.run { + self.url = url + } + } let currentTabID: NSManagedObjectID = tabID ?? createNewTabID() tabID = currentTabID @@ -173,19 +187,21 @@ final class BrowserViewModel: NSObject, ObservableObject, } // MARK: - Content Loading - + @MainActor func load(url: URL) { guard webView.url != url else { return } webView.load(URLRequest(url: url)) self.url = url } + @MainActor func loadRandomArticle(zimFileID: UUID? = nil) { let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") guard let url = ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) else { return } load(url: url) } + @MainActor func loadMainArticle(zimFileID: UUID? = nil) { let zimFileID = zimFileID ?? UUID(uuidString: webView.url?.host ?? "") guard let url = ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) else { return } @@ -195,7 +211,26 @@ final class BrowserViewModel: NSObject, ObservableObject, private func restoreBy(tabID: NSManagedObjectID) { if let tab = try? Database.viewContext.existingObject(with: tabID) as? Tab { webView.interactionState = tab.interactionState - url = webView.url + if AppType.isCustom { + Task { + guard let webURL = await webView.url else { + await MainActor.run { + url = nil + } + return + } + let newURL = await ZimMigration.customApp(url: webURL) + await MainActor.run { + url = newURL + } + } + } else { + Task { + await MainActor.run { + url = webView.url + } + } + } } } diff --git a/ViewModel/NavigationViewModel.swift b/ViewModel/NavigationViewModel.swift index 1ab6a302..04e1ec92 100644 --- a/ViewModel/NavigationViewModel.swift +++ b/ViewModel/NavigationViewModel.swift @@ -30,7 +30,12 @@ class NavigationViewModel: ObservableObject { let tab = (try? context.fetch(fetchRequest).first) ?? self.makeTab(context: context) try? context.obtainPermanentIDs(for: [tab]) try? context.save() - currentItem = NavigationItem.tab(objectID: tab.objectID) + Task { + await MainActor.run { + currentItem = NavigationItem.tab(objectID: tab.objectID) + } + } + } @discardableResult