diff --git a/Shared/Extensions/UserDefaults+Extensions.swift b/Shared/Extensions/UserDefaults+Extensions.swift index b010fdc..dcf0267 100644 --- a/Shared/Extensions/UserDefaults+Extensions.swift +++ b/Shared/Extensions/UserDefaults+Extensions.swift @@ -1,57 +1,59 @@ import Foundation enum WFDefaults { static let isLoggedIn = "isLoggedIn" static let showAllPostsFlag = "showAllPostsFlag" static let selectedCollectionURL = "selectedCollectionURL" static let lastDraftURL = "lastDraftURL" static let colorSchemeIntegerKey = "colorSchemeIntegerKey" static let defaultFontIntegerKey = "defaultFontIntegerKey" static let usernameStringKey = "usernameStringKey" static let serverStringKey = "serverStringKey" #if os(macOS) static let automaticallyChecksForUpdates = "automaticallyChecksForUpdates" static let subscribeToBetaUpdates = "subscribeToBetaUpdates" #endif + static let didHaveFatalError = "didHaveFatalError" + static let fatalErrorDescription = "fatalErrorDescription" } extension UserDefaults { private static let appGroupName: String = "group.com.abunchtell.writefreely" private static let didMigrateDefaultsToAppGroup: String = "didMigrateDefaultsToAppGroup" private static let didRemoveStandardDefaults: String = "didRemoveStandardDefaults" static var shared: UserDefaults { if let groupDefaults = UserDefaults(suiteName: UserDefaults.appGroupName), groupDefaults.bool(forKey: UserDefaults.didMigrateDefaultsToAppGroup) { return groupDefaults } else { do { let groupDefaults = try UserDefaults.standard.migrateDefaultsToAppGroup() return groupDefaults } catch { return UserDefaults.standard } } } private func migrateDefaultsToAppGroup() throws -> UserDefaults { let userDefaults = UserDefaults.standard let groupDefaults = UserDefaults(suiteName: UserDefaults.appGroupName) if let groupDefaults = groupDefaults { if groupDefaults.bool(forKey: UserDefaults.didMigrateDefaultsToAppGroup) { return groupDefaults } for (key, value) in userDefaults.dictionaryRepresentation() { groupDefaults.set(value, forKey: key) } groupDefaults.set(true, forKey: UserDefaults.didMigrateDefaultsToAppGroup) return groupDefaults } else { throw UserDefaultsError.couldNotMigrateStandardDefaults } } } diff --git a/Shared/LocalStorageManager.swift b/Shared/LocalStorageManager.swift index ae074b4..759bbf0 100644 --- a/Shared/LocalStorageManager.swift +++ b/Shared/LocalStorageManager.swift @@ -1,125 +1,152 @@ import CoreData +import os #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 { + Self.logger.info("Saving context to local store started...") try container.viewContext.save() + Self.logger.notice("Context saved to local store.") } catch { - fatalError(LocalStoreError.couldNotSaveContext.localizedDescription) + logCrashAndSetFlag(error: LocalStoreError.couldNotSaveContext) } } } func purgeUserCollections() throws { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFACollection") let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { + Self.logger.info("Purging user collections from local store...") try container.viewContext.executeAndMergeChanges(using: deleteRequest) + Self.logger.notice("User collections purged from local store.") } catch { + Self.logger.error("\(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 + Self.logger.info("Loading local store...") if let error = error { - fatalError(LocalStoreError.couldNotLoadStore(error.localizedDescription).localizedDescription) + self.logCrashAndSetFlag(error: LocalStoreError.couldNotLoadStore(error.localizedDescription)) } + Self.logger.notice("Loaded local store.") } 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 { + Self.logger.info("Migrating local store to shared store...") try coordinator.migratePersistentStore(oldStore, to: sharedStoreURL, options: nil, withType: NSSQLiteStoreType) + Self.logger.notice("Migrated local store to shared store.") } catch { - fatalError(LocalStoreError.couldNotMigrateStore(error.localizedDescription).localizedDescription) + logCrashAndSetFlag(error: LocalStoreError.couldNotMigrateStore(error.localizedDescription)) } // Attempt to delete the old store. do { + Self.logger.info("Deleting migrated local store...") try FileManager.default.removeItem(at: oldStoreURL) + Self.logger.notice("Deleted migrated local store.") } catch { - fatalError( - LocalStoreError.couldNotDeleteStoreAfterMigration(error.localizedDescription).localizedDescription - ) + logCrashAndSetFlag(error: LocalStoreError.couldNotDeleteStoreAfterMigration(error.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() } } + +private extension LocalStorageManager { + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: LocalStorageManager.self) + ) + + private func logCrashAndSetFlag(error: Error) { + let errorDescription = error.localizedDescription + UserDefaults.shared.set(true, forKey: WFDefaults.didHaveFatalError) + UserDefaults.shared.set(errorDescription, forKey: WFDefaults.fatalErrorDescription) + Self.logger.critical("\(errorDescription)") + fatalError(errorDescription) + } + +} diff --git a/Shared/PostCollection/CollectionListModel.swift b/Shared/PostCollection/CollectionListModel.swift index b2ac884..d1cd2bb 100644 --- a/Shared/PostCollection/CollectionListModel.swift +++ b/Shared/PostCollection/CollectionListModel.swift @@ -1,41 +1,58 @@ import SwiftUI import CoreData +import os 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 { + Self.logger.info("Fetching collections from local store...") try collectionsController.performFetch() list = collectionsController.fetchedObjects ?? [] + Self.logger.notice("Fetched collections from local store.") } catch { - // FIXME: Errors cannot be thrown out of the CollectionListView property initializer - fatalError(LocalStoreError.couldNotFetchCollections.localizedDescription) + logCrashAndSetFlag(error: LocalStoreError.couldNotFetchCollections) } } } extension CollectionListModel: NSFetchedResultsControllerDelegate { func controllerDidChangeContent(_ controller: NSFetchedResultsController) { guard let collections = controller.fetchedObjects as? [WFACollection] else { return } self.list = collections } } +extension CollectionListModel { + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: CollectionListModel.self) + ) + + private func logCrashAndSetFlag(error: Error) { + let errorDescription = error.localizedDescription + UserDefaults.shared.set(true, forKey: WFDefaults.didHaveFatalError) + UserDefaults.shared.set(errorDescription, forKey: WFDefaults.fatalErrorDescription) + Self.logger.critical("\(errorDescription)") + fatalError(errorDescription) + } +} + 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/WriteFreely_MultiPlatformApp.swift b/Shared/WriteFreely_MultiPlatformApp.swift index d17c965..feedb83 100644 --- a/Shared/WriteFreely_MultiPlatformApp.swift +++ b/Shared/WriteFreely_MultiPlatformApp.swift @@ -1,148 +1,166 @@ import SwiftUI #if os(macOS) import Sparkle #endif @main struct CheckForDebugModifier { static func main() { #if os(macOS) if NSEvent.modifierFlags.contains(.shift) { // Clear the launch-to-last-draft values to load a new draft. UserDefaults.shared.setValue(false, forKey: WFDefaults.showAllPostsFlag) UserDefaults.shared.setValue(nil, forKey: WFDefaults.selectedCollectionURL) UserDefaults.shared.setValue(nil, forKey: WFDefaults.lastDraftURL) } else { // No-op } #endif WriteFreely_MultiPlatformApp.main() } } struct WriteFreely_MultiPlatformApp: App { @StateObject private var model = WriteFreelyModel.shared #if os(macOS) @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject var updaterViewModel = MacUpdatesViewModel() @State private var selectedTab = 0 #endif + @State private var didCrash = UserDefaults.shared.bool(forKey: WFDefaults.didHaveFatalError) + var body: some Scene { WindowGroup { ContentView() .onAppear(perform: { if model.editor.showAllPostsFlag { DispatchQueue.main.async { self.model.selectedCollection = nil self.model.showAllPosts = true showLastDraftOrCreateNewLocalPost() } } else { DispatchQueue.main.async { self.model.selectedCollection = model.editor.fetchSelectedCollectionFromAppStorage() self.model.showAllPosts = false showLastDraftOrCreateNewLocalPost() } } }) + .alert(isPresented: $didCrash) { + // TODO: - Confirm copy for this alert + Alert( + title: Text("Crash Detected"), + message: Text( + UserDefaults.shared.object(forKey: WFDefaults.fatalErrorDescription) as? String ?? + "Something went horribly wrong!" + ), + dismissButton: .default( + Text("Dismiss"), action: { + UserDefaults.shared.set(false, forKey: WFDefaults.didHaveFatalError) + UserDefaults.shared.removeObject(forKey: WFDefaults.fatalErrorDescription) + } + ) + ) + } .withErrorHandling() .environmentObject(model) .environment(\.managedObjectContext, LocalStorageManager.standard.container.viewContext) // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } .commands { #if os(macOS) CommandGroup(after: .appInfo) { CheckForUpdatesView(updaterViewModel: updaterViewModel) } #endif CommandGroup(replacing: .newItem, addition: { Button("New Post") { createNewLocalPost() } .keyboardShortcut("n", modifiers: [.command]) }) CommandGroup(after: .newItem) { Button("Refresh Posts") { DispatchQueue.main.async { model.fetchUserCollections() model.fetchUserPosts() } } .disabled(!model.account.isLoggedIn) .keyboardShortcut("r", modifiers: [.command]) } SidebarCommands() #if os(macOS) PostCommands(model: model) #endif CommandGroup(after: .help) { Button("Visit Support Forum") { #if os(macOS) NSWorkspace().open(model.helpURL) #else UIApplication.shared.open(model.helpURL) #endif } } ToolbarCommands() TextEditingCommands() } #if os(macOS) Settings { TabView(selection: $selectedTab) { MacAccountView() .environmentObject(model) .tabItem { Image(systemName: "person.crop.circle") Text("Account") } .tag(0) MacPreferencesView(preferences: model.preferences) .tabItem { Image(systemName: "gear") Text("Preferences") } .tag(1) MacUpdatesView(updaterViewModel: updaterViewModel) .tabItem { Image(systemName: "arrow.down.circle") Text("Updates") } .tag(2) } .withErrorHandling() .frame(minWidth: 500, maxWidth: 500, minHeight: 200) .padding() // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } #endif } private func showLastDraftOrCreateNewLocalPost() { if model.editor.lastDraftURL != nil { self.model.selectedPost = model.editor.fetchLastDraftFromAppStorage() } else { createNewLocalPost() } } private func createNewLocalPost() { withAnimation { // Un-set the currently selected post self.model.selectedPost = nil } // Create the new-post managed object let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { // Set it as the selectedPost DispatchQueue.main.asyncAfter(deadline: .now()) { self.model.selectedPost = managedPost } } } }