diff --git a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift index b6dc7f3..3163676 100644 --- a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift +++ b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift @@ -1,269 +1,269 @@ import Foundation import WriteFreely extension WriteFreelyModel { func loginHandler(result: Result) { DispatchQueue.main.async { self.isLoggingIn = false } do { let user = try result.get() fetchUserCollections() fetchUserPosts() do { try saveTokenToKeychain(user.token, username: user.username, server: account.server) DispatchQueue.main.async { self.account.login(user) } } catch { self.currentError = KeychainError.couldNotStoreAccessToken } } catch WFError.notFound { self.currentError = AccountError.usernameNotFound } catch WFError.unauthorized { self.currentError = AccountError.invalidPassword } catch { if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == -1003 { self.currentError = AccountError.serverNotFound } else { self.currentError = error } } } func logoutHandler(result: Result) { do { _ = try result.get() do { try purgeTokenFromKeychain(username: account.user?.username, server: account.server) client = nil DispatchQueue.main.async { self.account.logout() - LocalStorageManager.standard.purgeUserCollections() do { + try LocalStorageManager.standard.purgeUserCollections() try self.posts.purgePublishedPosts() } catch { self.currentError = error } } } catch { self.currentError = KeychainError.couldNotPurgeAccessToken } } catch WFError.notFound { // The user token is invalid or doesn't exist, so it's been invalidated by the server. Proceed with // purging the token from the Keychain, destroying the client object, and setting the AccountModel to its // logged-out state. do { try purgeTokenFromKeychain(username: account.user?.username, server: account.server) client = nil DispatchQueue.main.async { self.account.logout() - LocalStorageManager.standard.purgeUserCollections() do { + try LocalStorageManager.standard.purgeUserCollections() try self.posts.purgePublishedPosts() } catch { self.currentError = error } } } catch { self.currentError = KeychainError.couldNotPurgeAccessToken } } catch { // We get a 'cannot parse response' (similar to what we were seeing in the Swift package) NSURLError here, // so we're using a hacky workaround — if we get the NSURLError, but the AccountModel still thinks we're // logged in, try calling the logout function again and see what we get. // Conditional cast from 'Error' to 'NSError' always succeeds but is the only way to check error properties. if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == NSURLErrorCannotParseResponse { if account.isLoggedIn { self.logout() } } } } func fetchUserCollectionsHandler(result: Result<[WFCollection], Error>) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } do { let fetchedCollections = try result.get() for fetchedCollection in fetchedCollections { DispatchQueue.main.async { let localCollection = WFACollection(context: LocalStorageManager.standard.container.viewContext) localCollection.alias = fetchedCollection.alias localCollection.blogDescription = fetchedCollection.description localCollection.email = fetchedCollection.email localCollection.isPublic = fetchedCollection.isPublic ?? false localCollection.styleSheet = fetchedCollection.styleSheet localCollection.title = fetchedCollection.title localCollection.url = fetchedCollection.url } } DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch WFError.unauthorized { self.currentError = AccountError.genericAuthError self.logout() } catch { self.currentError = AppError.genericError(error.localizedDescription) } } func fetchUserPostsHandler(result: Result<[WFPost], Error>) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } let request = WFAPost.createFetchRequest() do { let locallyCachedPosts = try LocalStorageManager.standard.container.viewContext.fetch(request) do { var postsToDelete = locallyCachedPosts.filter { $0.status != PostStatus.local.rawValue } let fetchedPosts = try result.get() for fetchedPost in fetchedPosts { if let managedPost = locallyCachedPosts.first(where: { $0.postId == fetchedPost.postId }) { DispatchQueue.main.async { managedPost.wasDeletedFromServer = false if let fetchedPostUpdatedDate = fetchedPost.updatedDate, let localPostUpdatedDate = managedPost.updatedDate { managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate } else { self.currentError = AppError.genericError( "Error updating post: could not determine which copy of post is newer." ) } postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) } } else { DispatchQueue.main.async { let managedPost = WFAPost(context: LocalStorageManager.standard.container.viewContext) self.importData(from: fetchedPost, into: managedPost) managedPost.collectionAlias = fetchedPost.collectionAlias managedPost.wasDeletedFromServer = false } } } DispatchQueue.main.async { for post in postsToDelete { post.wasDeletedFromServer = true } LocalStorageManager.standard.saveContext() } } catch { self.currentError = AppError.genericError(error.localizedDescription) } } catch WFError.unauthorized { self.currentError = AccountError.genericAuthError self.logout() } catch { self.currentError = LocalStoreError.couldNotFetchPosts("cached") } } func publishHandler(result: Result) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } // ⚠️ NOTE: // The API does not return a collection alias, so we take care not to overwrite the // cached post's collection alias with the 'nil' value from the fetched post. // See: https://github.com/writeas/writefreely-swift/issues/20 do { let fetchedPost = try result.get() // If this is an updated post, check it against postToUpdate. if let updatingPost = self.postToUpdate { importData(from: fetchedPost, into: updatingPost) DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } else { // Otherwise if it's a newly-published post, find it in the local store. let request = WFAPost.createFetchRequest() let matchBodyPredicate = NSPredicate(format: "body == %@", fetchedPost.body) if let fetchedPostTitle = fetchedPost.title { let matchTitlePredicate = NSPredicate(format: "title == %@", fetchedPostTitle) request.predicate = NSCompoundPredicate( andPredicateWithSubpredicates: [ matchTitlePredicate, matchBodyPredicate ] ) } else { request.predicate = matchBodyPredicate } do { let cachedPostsResults = try LocalStorageManager.standard.container.viewContext.fetch(request) guard let cachedPost = cachedPostsResults.first else { return } importData(from: fetchedPost, into: cachedPost) DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { self.currentError = LocalStoreError.couldNotFetchPosts("cached") } } } catch { self.currentError = AppError.genericError(error.localizedDescription) } } func updateFromServerHandler(result: Result) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } // ⚠️ NOTE: // The API does not return a collection alias, so we take care not to overwrite the // cached post's collection alias with the 'nil' value from the fetched post. // See: https://github.com/writeas/writefreely-swift/issues/20 do { let fetchedPost = try result.get() guard let cachedPost = self.selectedPost else { return } importData(from: fetchedPost, into: cachedPost) cachedPost.hasNewerRemoteCopy = false DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { self.currentError = AppError.genericError(error.localizedDescription) } } func movePostHandler(result: Result) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } do { let succeeded = try result.get() if succeeded { if let post = selectedPost { updateFromServer(post: post) } else { return } } } catch { DispatchQueue.main.async { LocalStorageManager.standard.container.viewContext.rollback() } self.currentError = AppError.genericError(error.localizedDescription) } } private func importData(from fetchedPost: WFPost, into cachedPost: WFAPost) { cachedPost.appearance = fetchedPost.appearance cachedPost.body = fetchedPost.body cachedPost.createdDate = fetchedPost.createdDate cachedPost.language = fetchedPost.language cachedPost.postId = fetchedPost.postId cachedPost.rtl = fetchedPost.rtl ?? false cachedPost.slug = fetchedPost.slug cachedPost.status = PostStatus.published.rawValue cachedPost.title = fetchedPost.title ?? "" cachedPost.updatedDate = fetchedPost.updatedDate } } diff --git a/Shared/LocalStorageManager.swift b/Shared/LocalStorageManager.swift index af62660..ae074b4 100644 --- a/Shared/LocalStorageManager.swift +++ b/Shared/LocalStorageManager.swift @@ -1,125 +1,125 @@ import CoreData #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif final class LocalStorageManager { public static var standard = LocalStorageManager() public let container: NSPersistentContainer private let containerName = "LocalStorageModel" private init() { container = NSPersistentContainer(name: containerName) setupStore(in: container) registerObservers() } func saveContext() { if container.viewContext.hasChanges { do { try container.viewContext.save() } catch { - print(LocalStoreError.couldNotSaveContext.localizedDescription) + fatalError(LocalStoreError.couldNotSaveContext.localizedDescription) } } } - func purgeUserCollections() { + func purgeUserCollections() throws { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFACollection") let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try container.viewContext.executeAndMergeChanges(using: deleteRequest) } catch { - print(LocalStoreError.couldNotPurgeCollections.localizedDescription) + throw LocalStoreError.couldNotPurgeCollections } } } private extension LocalStorageManager { var oldStoreURL: URL { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return appSupport.appendingPathComponent("LocalStorageModel.sqlite") } var sharedStoreURL: URL { let id = "group.com.abunchtell.writefreely" let groupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: id)! return groupContainer.appendingPathComponent("LocalStorageModel.sqlite") } func setupStore(in container: NSPersistentContainer) { if !FileManager.default.fileExists(atPath: oldStoreURL.path) { container.persistentStoreDescriptions.first!.url = sharedStoreURL } container.loadPersistentStores { _, error in if let error = error { fatalError(LocalStoreError.couldNotLoadStore(error.localizedDescription).localizedDescription) } } migrateStore(for: container) container.viewContext.automaticallyMergesChangesFromParent = true container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy } func migrateStore(for container: NSPersistentContainer) { // Check if the shared store exists before attempting a migration — for example, in case we've already attempted // and successfully completed a migration, but the deletion of the old store failed for some reason. guard !FileManager.default.fileExists(atPath: sharedStoreURL.path) else { return } let coordinator = container.persistentStoreCoordinator // Get a reference to the old store. guard let oldStore = coordinator.persistentStore(for: oldStoreURL) else { return } // Attempt to migrate the old store over to the shared store URL. do { try coordinator.migratePersistentStore(oldStore, to: sharedStoreURL, options: nil, withType: NSSQLiteStoreType) } catch { fatalError(LocalStoreError.couldNotMigrateStore(error.localizedDescription).localizedDescription) } // Attempt to delete the old store. do { try FileManager.default.removeItem(at: oldStoreURL) } catch { fatalError( LocalStoreError.couldNotDeleteStoreAfterMigration(error.localizedDescription).localizedDescription ) } } func registerObservers() { let center = NotificationCenter.default #if os(iOS) let notification = UIApplication.willResignActiveNotification #elseif os(macOS) let notification = NSApplication.willResignActiveNotification #endif // We don't need to worry about removing this observer because we're targeting iOS 9+ / macOS 10.11+; the // system will clean this up the next time it would be posted to. // See: https://developer.apple.com/documentation/foundation/notificationcenter/1413994-removeobserver // And: https://developer.apple.com/documentation/foundation/notificationcenter/1407263-removeobserver // swiftlint:disable:next discarded_notification_center_observer center.addObserver(forName: notification, object: nil, queue: nil, using: self.saveContextOnResignActive) } func saveContextOnResignActive(_ notification: Notification) { saveContext() } } diff --git a/Shared/PostCollection/CollectionListModel.swift b/Shared/PostCollection/CollectionListModel.swift index 86e4088..b2ac884 100644 --- a/Shared/PostCollection/CollectionListModel.swift +++ b/Shared/PostCollection/CollectionListModel.swift @@ -1,41 +1,41 @@ import SwiftUI import CoreData class CollectionListModel: NSObject, ObservableObject { @Published var list: [WFACollection] = [] private let collectionsController: NSFetchedResultsController init(managedObjectContext: NSManagedObjectContext) { collectionsController = NSFetchedResultsController(fetchRequest: WFACollection.collectionsFetchRequest, managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) super.init() collectionsController.delegate = self do { try collectionsController.performFetch() list = collectionsController.fetchedObjects ?? [] } catch { // FIXME: Errors cannot be thrown out of the CollectionListView property initializer - print("Failed to fetch collections!") + fatalError(LocalStoreError.couldNotFetchCollections.localizedDescription) } } } extension CollectionListModel: NSFetchedResultsControllerDelegate { func controllerDidChangeContent(_ controller: NSFetchedResultsController) { guard let collections = controller.fetchedObjects as? [WFACollection] else { return } self.list = collections } } extension WFACollection { static var collectionsFetchRequest: NSFetchRequest { let request: NSFetchRequest = WFACollection.createFetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)] return request } } diff --git a/Shared/PostCollection/CollectionListView.swift b/Shared/PostCollection/CollectionListView.swift index 29e84b1..0975fff 100644 --- a/Shared/PostCollection/CollectionListView.swift +++ b/Shared/PostCollection/CollectionListView.swift @@ -1,55 +1,66 @@ import SwiftUI struct CollectionListView: View { @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling @ObservedObject var collections = CollectionListModel( managedObjectContext: LocalStorageManager.standard.container.viewContext ) @State var selectedCollection: WFACollection? var body: some View { List(selection: $selectedCollection) { if model.account.isLoggedIn { NavigationLink("All Posts", destination: PostListView(selectedCollection: nil, showAllPosts: true)) NavigationLink("Drafts", destination: PostListView(selectedCollection: nil, showAllPosts: false)) Section(header: Text("Your Blogs")) { ForEach(collections.list, id: \.self) { collection in NavigationLink(destination: PostListView(selectedCollection: collection, showAllPosts: false), tag: collection, selection: $selectedCollection, label: { Text("\(collection.title)") }) } } } else { NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: false)) { Text("Drafts") } } } .navigationTitle( model.account.isLoggedIn ? "\(URL(string: model.account.server)?.host ?? "WriteFreely")" : "WriteFreely" ) .listStyle(SidebarListStyle()) .onChange(of: model.selectedCollection) { collection in if collection != model.editor.fetchSelectedCollectionFromAppStorage() { self.model.editor.selectedCollectionURL = collection?.objectID.uriRepresentation() } } .onChange(of: model.showAllPosts) { value in if value != model.editor.showAllPostsFlag { self.model.editor.showAllPostsFlag = model.showAllPosts } } + .onChange(of: model.hasError) { value in + if value { + if let error = model.currentError { + self.errorHandling.handle(error: error) + } else { + self.errorHandling.handle(error: AppError.genericError()) + } + model.hasError = false + } + } } } struct CollectionListView_LoggedOutPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() return CollectionListView() .environment(\.managedObjectContext, context) .environmentObject(model) } }