diff --git a/ActionExtension-iOS/ContentView.swift b/ActionExtension-iOS/ContentView.swift index 0d38361..3e7b0d6 100644 --- a/ActionExtension-iOS/ContentView.swift +++ b/ActionExtension-iOS/ContentView.swift @@ -1,199 +1,198 @@ import SwiftUI import MobileCoreServices import UniformTypeIdentifiers import WriteFreely enum WFActionExtensionError: Error { case userCancelledRequest case couldNotParseInputItems } struct ContentView: View { @Environment(\.extensionContext) private var extensionContext: NSExtensionContext! @Environment(\.managedObjectContext) private var managedObjectContext @AppStorage(WFDefaults.defaultFontIntegerKey, store: UserDefaults.shared) var fontIndex: Int = 0 @FetchRequest( entity: WFACollection.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)] ) var collections: FetchedResults @State private var draftTitle: String = "" @State private var draftText: String = "" @State private var isShowingAlert: Bool = false @State private var selectedBlog: WFACollection? private var draftsCollectionName: String { guard UserDefaults.shared.string(forKey: WFDefaults.serverStringKey) == "https://write.as" else { return "Drafts" } return "Anonymous" } private var controls: some View { HStack { Group { Button( action: { extensionContext.cancelRequest(withError: WFActionExtensionError.userCancelledRequest) }, label: { Image(systemName: "xmark.circle").imageScale(.large) } ) .accessibilityLabel(Text("Cancel")) Spacer() Button( action: { savePostToCollection(collection: selectedBlog, title: draftTitle, body: draftText) extensionContext.completeRequest(returningItems: nil, completionHandler: nil) }, label: { Image(systemName: "square.and.arrow.down").imageScale(.large) } ) .accessibilityLabel(Text("Create new draft")) } .padding() } } var body: some View { VStack { controls Form { Section(header: Text("Title")) { switch fontIndex { case 1: TextField("Draft Title", text: $draftTitle).font(.custom("OpenSans-Regular", size: 26)) case 2: TextField("Draft Title", text: $draftTitle).font(.custom("Hack-Regular", size: 26)) default: TextField("Draft Title", text: $draftTitle).font(.custom("Lora", size: 26)) } } Section(header: Text("Content")) { switch fontIndex { case 1: TextEditor(text: $draftText).font(.custom("OpenSans-Regular", size: 17)) case 2: TextEditor(text: $draftText).font(.custom("Hack-Regular", size: 17)) default: TextEditor(text: $draftText).font(.custom("Lora", size: 17)) } } Section(header: Text("Save To")) { Button(action: { self.selectedBlog = nil }, label: { HStack { Text(draftsCollectionName) .foregroundColor(selectedBlog == nil ? .primary : .secondary) Spacer() if selectedBlog == nil { Image(systemName: "checkmark") } } }) ForEach(collections, id: \.self) { collection in Button(action: { self.selectedBlog = collection }, label: { HStack { Text(collection.title) .foregroundColor(selectedBlog == collection ? .primary : .secondary) Spacer() if selectedBlog == collection { Image(systemName: "checkmark") } } }) } } } .padding(.bottom, 24) } .alert(isPresented: $isShowingAlert, content: { Alert( title: Text("Something Went Wrong"), message: Text("WriteFreely can't create a draft with the data received."), dismissButton: .default(Text("OK"), action: { extensionContext.cancelRequest(withError: WFActionExtensionError.couldNotParseInputItems) })) }) .onAppear { do { try getPageDataFromExtensionContext() } catch { self.isShowingAlert = true } } } private func savePostToCollection(collection: WFACollection?, title: String, body: String) { let post = WFAPost(context: managedObjectContext) post.createdDate = Date() post.title = title post.body = body post.status = PostStatus.local.rawValue post.collectionAlias = collection?.alias switch fontIndex { case 1: post.appearance = "sans" case 2: post.appearance = "wrap" default: post.appearance = "serif" } if let languageCode = Locale.current.languageCode { post.language = languageCode post.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft } LocalStorageManager.standard.saveContext() } private func getPageDataFromExtensionContext() throws { if let inputItem = extensionContext.inputItems.first as? NSExtensionItem { if let itemProvider = inputItem.attachments?.first { let typeIdentifier: String if #available(iOS 15, *) { typeIdentifier = UTType.propertyList.identifier } else { typeIdentifier = kUTTypePropertyList as String } itemProvider.loadItem(forTypeIdentifier: typeIdentifier) { (dict, error) in - if let error = error { - print("⚠️", error) + if error != nil { self.isShowingAlert = true } guard let itemDict = dict as? NSDictionary else { return } guard let jsValues = itemDict[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary else { return } let pageTitle = jsValues["title"] as? String ?? "" let pageURL = jsValues["URL"] as? String ?? "" let pageSelectedText = jsValues["selection"] as? String ?? "" if pageSelectedText.isEmpty { // If there's no selected text, create a Markdown link to the webpage. self.draftText = "[\(pageTitle)](\(pageURL))" } else { // If there is selected text, create a Markdown blockquote with the selection // and add a Markdown link to the webpage. self.draftText = """ > \(pageSelectedText) Via: [\(pageTitle)](\(pageURL)) """ } } } else { throw WFActionExtensionError.couldNotParseInputItems } } else { throw WFActionExtensionError.couldNotParseInputItems } } } diff --git a/Shared/Account/AccountLoginView.swift b/Shared/Account/AccountLoginView.swift index 8284b95..64e7971 100644 --- a/Shared/Account/AccountLoginView.swift +++ b/Shared/Account/AccountLoginView.swift @@ -1,106 +1,99 @@ import SwiftUI struct AccountLoginView: View { @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling @State private var alertMessage: String = "" @State private var username: String = "" @State private var password: String = "" @State private var server: String = "" var body: some View { VStack { Text("Log in to publish and share your posts.") .font(.caption) .foregroundColor(.secondary) HStack { Image(systemName: "person.circle") .foregroundColor(.gray) #if os(iOS) TextField("Username", text: $username) .autocapitalization(.none) .disableAutocorrection(true) .textFieldStyle(RoundedBorderTextFieldStyle()) #else TextField("Username", text: $username) #endif } HStack { Image(systemName: "lock.circle") .foregroundColor(.gray) #if os(iOS) SecureField("Password", text: $password) .autocapitalization(.none) .disableAutocorrection(true) .textFieldStyle(RoundedBorderTextFieldStyle()) #else SecureField("Password", text: $password) #endif } HStack { Image(systemName: "link.circle") .foregroundColor(.gray) #if os(iOS) TextField("Server URL", text: $server) .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) .textFieldStyle(RoundedBorderTextFieldStyle()) #else TextField("Server URL", text: $server) #endif } Spacer() if model.isLoggingIn { ProgressView("Logging in...") .padding() } else { Button(action: { #if os(iOS) hideKeyboard() #endif // If the server string is not prefixed with a scheme, prepend "https://" to it. if !(server.hasPrefix("https://") || server.hasPrefix("http://")) { server = "https://\(server)" } // We only need the protocol and host from the URL, so drop anything else. let url = URLComponents(string: server) if let validURL = url { let scheme = validURL.scheme let host = validURL.host var hostURL = URLComponents() hostURL.scheme = scheme hostURL.host = host server = hostURL.string ?? server model.login( to: URL(string: server)!, as: username, password: password ) } else { - model.loginErrorMessage = AccountError.invalidServerURL.localizedDescription - model.isPresentingLoginErrorAlert = true + self.errorHandling.handle(error: AccountError.invalidServerURL) } }, label: { Text("Log In") }) .disabled( model.account.isLoggedIn || (username.isEmpty || password.isEmpty || server.isEmpty) ) .padding() } } - .alert(isPresented: $model.isPresentingLoginErrorAlert) { - Alert( - title: Text("Error Logging In"), - message: Text(model.loginErrorMessage ?? "An unknown error occurred while trying to login."), - dismissButton: .default(Text("OK")) - ) - } } } struct AccountLoginView_Previews: PreviewProvider { static var previews: some View { AccountLoginView() .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/Account/AccountLogoutView.swift b/Shared/Account/AccountLogoutView.swift index 22df2da..d972f70 100644 --- a/Shared/Account/AccountLogoutView.swift +++ b/Shared/Account/AccountLogoutView.swift @@ -1,80 +1,81 @@ import SwiftUI struct AccountLogoutView: View { @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling @State private var isPresentingLogoutConfirmation: Bool = false @State private var editedPostsWarningString: String = "" var body: some View { #if os(iOS) VStack { Spacer() VStack { Text("Logged in as \(model.account.username)") Text("on \(model.account.server)") } Spacer() Button(action: logoutHandler, label: { Text("Log Out") }) } .actionSheet(isPresented: $isPresentingLogoutConfirmation, content: { ActionSheet( title: Text("Log Out?"), message: Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?"), buttons: [ .destructive(Text("Log Out"), action: { model.logout() }), .cancel() ] ) }) #else VStack { Spacer() VStack { Text("Logged in as \(model.account.username)") Text("on \(model.account.server)") } Spacer() Button(action: logoutHandler, label: { Text("Log Out") }) } .alert(isPresented: $isPresentingLogoutConfirmation) { Alert( title: Text("Log Out?"), message: Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?"), primaryButton: .cancel(Text("Cancel"), action: { self.isPresentingLogoutConfirmation = false }), secondaryButton: .destructive(Text("Log Out"), action: model.logout ) ) } #endif } func logoutHandler() { let request = WFAPost.createFetchRequest() request.predicate = NSPredicate(format: "status == %i", 1) do { let editedPosts = try LocalStorageManager.standard.container.viewContext.fetch(request) if editedPosts.count == 1 { editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited post. " } if editedPosts.count > 1 { editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited posts. " } } catch { - print("Error: failed to fetch cached posts") + self.errorHandling.handle(error: LocalStoreError.couldNotFetchPosts("cached")) } self.isPresentingLogoutConfirmation = true } } struct AccountLogoutView_Previews: PreviewProvider { static var previews: some View { AccountLogoutView() .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/Account/AccountModel.swift b/Shared/Account/AccountModel.swift index 94171ec..25103ba 100644 --- a/Shared/Account/AccountModel.swift +++ b/Shared/Account/AccountModel.swift @@ -1,84 +1,32 @@ import SwiftUI import WriteFreely -enum AccountError: Error { - case invalidPassword - case usernameNotFound - case serverNotFound - case invalidServerURL - case couldNotSaveTokenToKeychain - case couldNotFetchTokenFromKeychain - case couldNotDeleteTokenFromKeychain -} - -extension AccountError: LocalizedError { - public var errorDescription: String? { - switch self { - case .serverNotFound: - return NSLocalizedString( - "The server could not be found. Please check the information you've entered and try again.", - comment: "" - ) - case .invalidPassword: - return NSLocalizedString( - "Invalid password. Please check that you've entered your password correctly and try logging in again.", - comment: "" - ) - case .usernameNotFound: - return NSLocalizedString( - "Username not found. Did you use your email address by mistake?", - comment: "" - ) - case .invalidServerURL: - return NSLocalizedString( - "Please enter a valid instance domain name. It should look like \"https://example.com\" or \"write.as\".", // swiftlint:disable:this line_length - comment: "" - ) - case .couldNotSaveTokenToKeychain: - return NSLocalizedString( - "There was a problem trying to save your access token to the device, please try logging in again.", - comment: "" - ) - case .couldNotFetchTokenFromKeychain: - return NSLocalizedString( - "There was a problem trying to fetch your access token from the device, please try logging in again.", - comment: "" - ) - case .couldNotDeleteTokenFromKeychain: - return NSLocalizedString( - "There was a problem trying to delete your access token from the device, please try logging out again.", - comment: "" - ) - } - } -} - struct AccountModel { @AppStorage(WFDefaults.isLoggedIn, store: UserDefaults.shared) var isLoggedIn: Bool = false private let defaults = UserDefaults.shared var server: String = "" var username: String = "" private(set) var user: WFUser? mutating func login(_ user: WFUser) { self.user = user self.username = user.username ?? "" self.isLoggedIn = true defaults.set(user.username, forKey: WFDefaults.usernameStringKey) defaults.set(server, forKey: WFDefaults.serverStringKey) } mutating func logout() { self.user = nil self.isLoggedIn = false defaults.removeObject(forKey: WFDefaults.usernameStringKey) defaults.removeObject(forKey: WFDefaults.serverStringKey) } mutating func restoreState() { server = defaults.string(forKey: WFDefaults.serverStringKey) ?? "" username = defaults.string(forKey: WFDefaults.usernameStringKey) ?? "" } } diff --git a/Shared/Account/AccountView.swift b/Shared/Account/AccountView.swift index 4ff4527..0b54f0e 100644 --- a/Shared/Account/AccountView.swift +++ b/Shared/Account/AccountView.swift @@ -1,26 +1,40 @@ import SwiftUI struct AccountView: View { @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling var body: some View { if model.account.isLoggedIn { HStack { Spacer() AccountLogoutView() + .withErrorHandling() Spacer() } .padding() } else { AccountLoginView() + .withErrorHandling() .padding(.top) } + EmptyView() + .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 AccountLogin_Previews: PreviewProvider { static var previews: some View { AccountView() .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/ErrorHandling/ErrorConstants.swift b/Shared/ErrorHandling/ErrorConstants.swift new file mode 100644 index 0000000..88ae92b --- /dev/null +++ b/Shared/ErrorHandling/ErrorConstants.swift @@ -0,0 +1,173 @@ +import Foundation + +// MARK: - Network Errors + +enum NetworkError: Error { + case noConnectionError +} + +extension NetworkError: LocalizedError { + public var errorDescription: String? { + switch self { + case .noConnectionError: + return NSLocalizedString( + "There is no internet connection at the moment. Please reconnect or try again later.", + comment: "" + ) + } + } +} + +// MARK: - Keychain Errors + +enum KeychainError: Error { + case couldNotStoreAccessToken + case couldNotPurgeAccessToken + case couldNotFetchAccessToken +} + +extension KeychainError: LocalizedError { + public var errorDescription: String? { + switch self { + case .couldNotStoreAccessToken: + return NSLocalizedString("There was a problem storing your access token in the Keychain.", comment: "") + case .couldNotPurgeAccessToken: + return NSLocalizedString("Something went wrong purging the token from the Keychain.", comment: "") + case .couldNotFetchAccessToken: + return NSLocalizedString("Something went wrong fetching the token from the Keychain.", comment: "") + } + } +} + +// MARK: - Account Errors + +enum AccountError: Error { + case invalidPassword + case usernameNotFound + case serverNotFound + case invalidServerURL + case unknownLoginError + case genericAuthError +} + +extension AccountError: LocalizedError { + public var errorDescription: String? { + switch self { + case .serverNotFound: + return NSLocalizedString( + "The server could not be found. Please check the information you've entered and try again.", + comment: "" + ) + case .invalidPassword: + return NSLocalizedString( + "Invalid password. Please check that you've entered your password correctly and try logging in again.", + comment: "" + ) + case .usernameNotFound: + return NSLocalizedString( + "Username not found. Did you use your email address by mistake?", + comment: "" + ) + case .invalidServerURL: + return NSLocalizedString( + "Please enter a valid instance domain name. It should look like \"https://example.com\" or \"write.as\".", // swiftlint:disable:this line_length + comment: "" + ) + case .genericAuthError: + return NSLocalizedString("Something went wrong, please try logging in again.", comment: "") + case .unknownLoginError: + return NSLocalizedString("An unknown error occurred while trying to login.", comment: "") + } + } +} + +// MARK: - User Defaults Errors + +enum UserDefaultsError: Error { + case couldNotMigrateStandardDefaults +} + +extension UserDefaultsError: LocalizedError { + public var errorDescription: String? { + switch self { + case .couldNotMigrateStandardDefaults: + return NSLocalizedString("Could not migrate user defaults to group container", comment: "") + } + } +} + +// MARK: - Local Store Errors + +enum LocalStoreError: Error { + case couldNotSaveContext + case couldNotFetchCollections + case couldNotFetchPosts(String = "") + case couldNotPurgePosts(String = "") + case couldNotPurgeCollections + case couldNotLoadStore(String) + case couldNotMigrateStore(String) + case couldNotDeleteStoreAfterMigration(String) + case genericError(String = "") +} + +extension LocalStoreError: LocalizedError { + public var errorDescription: String? { + switch self { + case .couldNotSaveContext: + return NSLocalizedString("Error saving context", comment: "") + case .couldNotFetchCollections: + return NSLocalizedString("Failed to fetch blogs from local store.", comment: "") + case .couldNotFetchPosts(let postFilter): + if postFilter.isEmpty { + return NSLocalizedString("Failed to fetch posts from local store.", comment: "") + } else { + return NSLocalizedString("Failed to fetch \(postFilter) posts from local store.", comment: "") + } + case .couldNotPurgePosts(let postFilter): + if postFilter.isEmpty { + return NSLocalizedString("Failed to purge \(postFilter) posts from local store.", comment: "") + } else { + return NSLocalizedString("Failed to purge posts from local store.", comment: "") + } + case .couldNotPurgeCollections: + return NSLocalizedString("Failed to purge cached collections", comment: "") + case .couldNotLoadStore(let errorDescription): + return NSLocalizedString("Something went wrong loading local store: \(errorDescription)", comment: "") + case .couldNotMigrateStore(let errorDescription): + return NSLocalizedString("Something went wrong migrating local store: \(errorDescription)", comment: "") + case .couldNotDeleteStoreAfterMigration(let errorDescription): + return NSLocalizedString("Something went wrong deleting old store: \(errorDescription)", comment: "") + case .genericError(let customContent): + if customContent.isEmpty { + return NSLocalizedString("Something went wrong accessing device storage", comment: "") + } else { + return NSLocalizedString(customContent, comment: "") + } + } + } +} + +// MARK: - Application Errors + +enum AppError: Error { + case couldNotGetLoggedInClient + case couldNotGetPostId + case genericError(String = "") +} + +extension AppError: LocalizedError { + public var errorDescription: String? { + switch self { + case .couldNotGetLoggedInClient: + return NSLocalizedString("Something went wrong trying to access the WriteFreely client.", comment: "") + case .couldNotGetPostId: + return NSLocalizedString("Something went wrong trying to get the post's unique ID.", comment: "") + case .genericError(let customContent): + if customContent.isEmpty { + return NSLocalizedString("Something went wrong", comment: "") + } else { + return NSLocalizedString(customContent, comment: "") + } + } + } +} diff --git a/Shared/ErrorHandling/ErrorHandling.swift b/Shared/ErrorHandling/ErrorHandling.swift new file mode 100644 index 0000000..8644b33 --- /dev/null +++ b/Shared/ErrorHandling/ErrorHandling.swift @@ -0,0 +1,42 @@ +// Based on https://www.ralfebert.com/swiftui/generic-error-handling/ + +import SwiftUI + +struct ErrorAlert: Identifiable { + var id = UUID() + var message: String + var dismissAction: (() -> Void)? +} + +class ErrorHandling: ObservableObject { + @Published var currentAlert: ErrorAlert? + + func handle(error: Error) { + currentAlert = ErrorAlert(message: error.localizedDescription) + } +} + +struct HandleErrorByShowingAlertViewModifier: ViewModifier { + @StateObject var errorHandling = ErrorHandling() + + func body(content: Content) -> some View { + content + .environmentObject(errorHandling) + .background( + EmptyView() + .alert(item: $errorHandling.currentAlert) { currentAlert in + Alert(title: Text("Error"), + message: Text(currentAlert.message), + dismissButton: .default(Text("OK")) { + currentAlert.dismissAction?() + }) + } + ) + } +} + +extension View { + func withErrorHandling() -> some View { + modifier(HandleErrorByShowingAlertViewModifier()) + } +} diff --git a/Shared/Extensions/UserDefaults+Extensions.swift b/Shared/Extensions/UserDefaults+Extensions.swift index f40a824..dcf0267 100644 --- a/Shared/Extensions/UserDefaults+Extensions.swift +++ b/Shared/Extensions/UserDefaults+Extensions.swift @@ -1,68 +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 enum DefaultsError: Error { - case couldNotMigrateStandardDefaults - - var description: String { - switch self { - case .couldNotMigrateStandardDefaults: - return "Could not migrate user defaults to group container." - } - } - } - 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 DefaultsError.couldNotMigrateStandardDefaults + throw UserDefaultsError.couldNotMigrateStandardDefaults } } } diff --git a/Shared/Extensions/WriteFreelyModel+API.swift b/Shared/Extensions/WriteFreelyModel+API.swift index 939cf76..36e657a 100644 --- a/Shared/Extensions/WriteFreelyModel+API.swift +++ b/Shared/Extensions/WriteFreelyModel+API.swift @@ -1,152 +1,173 @@ import Foundation import WriteFreely extension WriteFreelyModel { func login(to server: URL, as username: String, password: String) { if !hasNetworkConnection { - isPresentingNetworkErrorAlert = true + self.currentError = NetworkError.noConnectionError return } let secureProtocolPrefix = "https://" let insecureProtocolPrefix = "http://" var serverString = server.absoluteString // If there's neither an http or https prefix, prepend "https://" to the server string. if !(serverString.hasPrefix(secureProtocolPrefix) || serverString.hasPrefix(insecureProtocolPrefix)) { serverString = secureProtocolPrefix + serverString } // If the server string is prefixed with http, upgrade to https before attempting to login. if serverString.hasPrefix(insecureProtocolPrefix) { serverString = serverString.replacingOccurrences(of: insecureProtocolPrefix, with: secureProtocolPrefix) } isLoggingIn = true var serverURL = URL(string: serverString)! if !serverURL.path.isEmpty { serverURL.deleteLastPathComponent() } account.server = serverURL.absoluteString client = WFClient(for: serverURL) client?.login(username: username, password: password, completion: loginHandler) } func logout() { if !hasNetworkConnection { - DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } + self.currentError = NetworkError.noConnectionError return } guard let loggedInClient = client else { do { try purgeTokenFromKeychain(username: account.username, server: account.server) account.logout() } catch { - fatalError("Failed to log out persisted state") + self.currentError = KeychainError.couldNotPurgeAccessToken } return } loggedInClient.logout(completion: logoutHandler) } func fetchUserCollections() { if !hasNetworkConnection { - DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } + self.currentError = NetworkError.noConnectionError + return + } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient return } - guard let loggedInClient = client else { return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } loggedInClient.getUserCollections(completion: fetchUserCollectionsHandler) } func fetchUserPosts() { if !hasNetworkConnection { - DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } + self.currentError = NetworkError.noConnectionError + return + } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient return } - guard let loggedInClient = client else { return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } loggedInClient.getPosts(completion: fetchUserPostsHandler) } func publish(post: WFAPost) { postToUpdate = nil if !hasNetworkConnection { - DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } + self.currentError = NetworkError.noConnectionError + return + } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient return } - guard let loggedInClient = client else { return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } if post.language == nil { if let languageCode = Locale.current.languageCode { post.language = languageCode post.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft } } var wfPost = WFPost( body: post.body, title: post.title.isEmpty ? "" : post.title, appearance: post.appearance, language: post.language, rtl: post.rtl, createdDate: post.status == PostStatus.local.rawValue ? Date() : post.createdDate ) if let existingPostId = post.postId { // This is an existing post. postToUpdate = post wfPost.postId = post.postId loggedInClient.updatePost( postId: existingPostId, updatedPost: wfPost, completion: publishHandler ) } else { // This is a new local draft. loggedInClient.createPost( post: wfPost, in: post.collectionAlias, completion: publishHandler ) } } func updateFromServer(post: WFAPost) { if !hasNetworkConnection { - DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } + self.currentError = NetworkError.noConnectionError + return + } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient + return + } + guard let postId = post.postId else { + self.currentError = AppError.couldNotGetPostId return } - guard let loggedInClient = client else { return } - guard let postId = post.postId else { return } // We're starting the network request. DispatchQueue.main.async { self.selectedPost = post self.isProcessingRequest = true } loggedInClient.getPost(byId: postId, completion: updateFromServerHandler) } func move(post: WFAPost, from oldCollection: WFACollection?, to newCollection: WFACollection?) { if !hasNetworkConnection { - DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } + self.currentError = NetworkError.noConnectionError + return + } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient + return + } + guard let postId = post.postId else { + self.currentError = AppError.couldNotGetPostId return } - guard let loggedInClient = client, - let postId = post.postId else { return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } selectedPost = post post.collectionAlias = newCollection?.alias loggedInClient.movePost(postId: postId, to: newCollection?.alias, completion: movePostHandler) } } diff --git a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift index 804dd41..3163676 100644 --- a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift +++ b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift @@ -1,278 +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 { - DispatchQueue.main.async { - self.loginErrorMessage = "There was a problem storing your access token to the Keychain." - self.isPresentingLoginErrorAlert = true - } + self.currentError = KeychainError.couldNotStoreAccessToken } } catch WFError.notFound { - DispatchQueue.main.async { - self.loginErrorMessage = AccountError.usernameNotFound.localizedDescription - self.isPresentingLoginErrorAlert = true - } + self.currentError = AccountError.usernameNotFound } catch WFError.unauthorized { - DispatchQueue.main.async { - self.loginErrorMessage = AccountError.invalidPassword.localizedDescription - self.isPresentingLoginErrorAlert = true - } + self.currentError = AccountError.invalidPassword } catch { if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == -1003 { - DispatchQueue.main.async { - self.loginErrorMessage = AccountError.serverNotFound.localizedDescription - self.isPresentingLoginErrorAlert = true - } + self.currentError = AccountError.serverNotFound } else { - DispatchQueue.main.async { - self.loginErrorMessage = error.localizedDescription - self.isPresentingLoginErrorAlert = true - } + 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() - self.posts.purgePublishedPosts() + do { + try LocalStorageManager.standard.purgeUserCollections() + try self.posts.purgePublishedPosts() + } catch { + self.currentError = error + } } } catch { - print("Something went wrong purging the token from the Keychain.") + 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() - self.posts.purgePublishedPosts() + do { + try LocalStorageManager.standard.purgeUserCollections() + try self.posts.purgePublishedPosts() + } catch { + self.currentError = error + } } } catch { - print("Something went wrong purging the token from the Keychain.") + 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 { - DispatchQueue.main.async { - self.loginErrorMessage = "Something went wrong, please try logging in again." - self.isPresentingLoginErrorAlert = true - } + self.currentError = AccountError.genericAuthError self.logout() } catch { - print(error) + 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 { print("Error: could not determine which copy of post is newer") } + } 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 { - print(error) + self.currentError = AppError.genericError(error.localizedDescription) } } catch WFError.unauthorized { - DispatchQueue.main.async { - self.loginErrorMessage = "Something went wrong, please try logging in again." - self.isPresentingLoginErrorAlert = true - } + self.currentError = AccountError.genericAuthError self.logout() } catch { - print("Error: Failed to fetch cached posts") + 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 { - print("Error: Failed to fetch cached posts") + self.currentError = LocalStoreError.couldNotFetchPosts("cached") } } } catch { - print(error) + 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 { - print(error) + 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() } - print(error) + 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/Extensions/WriteFreelyModel+Keychain.swift b/Shared/Extensions/WriteFreelyModel+Keychain.swift index f039e31..f6555aa 100644 --- a/Shared/Extensions/WriteFreelyModel+Keychain.swift +++ b/Shared/Extensions/WriteFreelyModel+Keychain.swift @@ -1,61 +1,55 @@ import Foundation extension WriteFreelyModel { - enum WFKeychainError: Error { - case saveToKeychainFailed - case purgeFromKeychainFailed - case fetchFromKeychainFailed - } - func saveTokenToKeychain(_ token: String, username: String?, server: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecValueData as String: token.data(using: .utf8)!, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecDuplicateItem || status == errSecSuccess else { - throw WFKeychainError.saveToKeychainFailed + throw KeychainError.couldNotStoreAccessToken } } func purgeTokenFromKeychain(username: String?, server: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server ] let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { - throw WFKeychainError.purgeFromKeychainFailed + throw KeychainError.couldNotPurgeAccessToken } } func fetchTokenFromKeychain(username: String?, server: String) throws -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnAttributes as String: true, kSecReturnData as String: true ] var secItem: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &secItem) guard status != errSecItemNotFound else { - return nil + throw KeychainError.couldNotFetchAccessToken } guard status == errSecSuccess else { - throw WFKeychainError.fetchFromKeychainFailed + throw KeychainError.couldNotFetchAccessToken } guard let existingSecItem = secItem as? [String: Any], let tokenData = existingSecItem[kSecValueData as String] as? Data, let token = String(data: tokenData, encoding: .utf8) else { - return nil + throw KeychainError.couldNotFetchAccessToken } return token } } diff --git a/Shared/LocalStorageManager.swift b/Shared/LocalStorageManager.swift index b644faf..2cf57ca 100644 --- a/Shared/LocalStorageManager.swift +++ b/Shared/LocalStorageManager.swift @@ -1,123 +1,138 @@ import CoreData #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif final class LocalStorageManager { + private let logger = Logging(for: String(describing: LocalStorageManager.self)) + 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 { + logger.log("Saving context to local store started...") try container.viewContext.save() + logger.log("Context saved to local store.") } catch { - print("Error saving context: \(error)") + logger.logCrashAndSetFlag(error: LocalStoreError.couldNotSaveContext) } } } - func purgeUserCollections() { + func purgeUserCollections() throws { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFACollection") let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { + logger.log("Purging user collections from local store...") try container.viewContext.executeAndMergeChanges(using: deleteRequest) + logger.log("User collections purged from local store.") } catch { - print("Error: Failed to purge cached collections.") + logger.log("\(LocalStoreError.couldNotPurgeCollections.localizedDescription)", level: .error) + 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.log("Loading local store...") if let error = error { - fatalError("Core Data store failed to load with error: \(error)") + self.logger.logCrashAndSetFlag(error: LocalStoreError.couldNotLoadStore(error.localizedDescription)) } + self.logger.log("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.log("Migrating local store to shared store...") try coordinator.migratePersistentStore(oldStore, to: sharedStoreURL, options: nil, withType: NSSQLiteStoreType) + self.logger.log("Migrated local store to shared store.") } catch { - fatalError("Something went wrong migrating the store: \(error)") + logger.logCrashAndSetFlag(error: LocalStoreError.couldNotMigrateStore(error.localizedDescription)) } // Attempt to delete the old store. do { + logger.log("Deleting migrated local store...") try FileManager.default.removeItem(at: oldStoreURL) + logger.log("Deleted migrated local store.") } catch { - fatalError("Something went wrong while deleting the old store: \(error)") + logger.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() } } diff --git a/Shared/Logging/Logging.swift b/Shared/Logging/Logging.swift new file mode 100644 index 0000000..64d7f9c --- /dev/null +++ b/Shared/Logging/Logging.swift @@ -0,0 +1,50 @@ +// +// Logging.swift +// WriteFreely-MultiPlatform +// +// Created by Angelo Stavrow on 2022-06-25. +// + +import Foundation +import os +import OSLog + +protocol LogWriter { + func log(_ message: String, withSensitiveInfo privateInfo: String?, level: OSLogType) + func logCrashAndSetFlag(error: Error) +} + +final class Logging { + + private let logger: Logger + private let subsystem = Bundle.main.bundleIdentifier! + + init(for category: String = "") { + self.logger = Logger(subsystem: subsystem, category: category) + } + +} + +extension Logging: LogWriter { + + func log( + _ message: String, + withSensitiveInfo privateInfo: String? = nil, + level: OSLogType = .default + ) { + if let privateInfo = privateInfo { + logger.log(level: level, "\(message): \(privateInfo, privacy: .sensitive)") + } else { + logger.log(level: level, "\(message)") + } + } + + func logCrashAndSetFlag(error: Error) { + let errorDescription = error.localizedDescription + UserDefaults.shared.set(true, forKey: WFDefaults.didHaveFatalError) + UserDefaults.shared.set(errorDescription, forKey: WFDefaults.fatalErrorDescription) + logger.log(level: .error, "\(errorDescription)") + fatalError(errorDescription) + } + +} diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index ecb575f..ba778d0 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,83 +1,103 @@ import Foundation import WriteFreely import Security import Network // MARK: - WriteFreelyModel final class WriteFreelyModel: ObservableObject { + + // MARK: - Models @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var posts = PostListModel() @Published var editor = PostEditorModel() + + // MARK: - Error handling + @Published var hasError: Bool = false + var currentError: Error? { + didSet { + // TODO: Remove print statements for debugging before closing #204. + #if DEBUG + print("⚠️ currentError -> didSet \(currentError?.localizedDescription ?? "nil")") + print(" > hasError was: \(self.hasError)") + #endif + DispatchQueue.main.async { + #if DEBUG + print(" > self.currentError != nil: \(self.currentError != nil)") + #endif + self.hasError = self.currentError != nil + #if DEBUG + print(" > hasError is now: \(self.hasError)") + #endif + } + } + } + + // MARK: - State @Published var isLoggingIn: Bool = false @Published var isProcessingRequest: Bool = false @Published var hasNetworkConnection: Bool = true @Published var selectedPost: WFAPost? @Published var selectedCollection: WFACollection? @Published var showAllPosts: Bool = true @Published var isPresentingDeleteAlert: Bool = false - @Published var isPresentingLoginErrorAlert: Bool = false - @Published var isPresentingNetworkErrorAlert: Bool = false @Published var postToDelete: WFAPost? - #if os(iOS) +#if os(iOS) @Published var isPresentingSettingsView: Bool = false - #endif +#endif static var shared = WriteFreelyModel() - var loginErrorMessage: String? - // swiftlint:disable line_length let helpURL = URL(string: "https://discuss.write.as/c/help/5")! let howToURL = URL(string: "https://discuss.write.as/t/using-the-writefreely-ios-app/1946")! let reviewURL = URL(string: "https://apps.apple.com/app/id1531530896?action=write-review")! let licensesURL = URL(string: "https://github.com/writeas/writefreely-swiftui-multiplatform/tree/main/Shared/Resources/Licenses")! // swiftlint:enable line_length internal var client: WFClient? private let defaults = UserDefaults.shared private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitor") internal var postToUpdate: WFAPost? init() { DispatchQueue.main.async { self.preferences.appearance = self.defaults.integer(forKey: WFDefaults.colorSchemeIntegerKey) self.preferences.font = self.defaults.integer(forKey: WFDefaults.defaultFontIntegerKey) self.account.restoreState() if self.account.isLoggedIn { guard let serverURL = URL(string: self.account.server) else { - print("Server URL not found") + self.currentError = AccountError.invalidServerURL return } do { guard let token = try self.fetchTokenFromKeychain( username: self.account.username, server: self.account.server ) else { - self.loginErrorMessage = AccountError.couldNotFetchTokenFromKeychain.localizedDescription - self.isPresentingLoginErrorAlert = true + self.currentError = KeychainError.couldNotFetchAccessToken return } self.account.login(WFUser(token: token, username: self.account.username)) self.client = WFClient(for: serverURL) self.client?.user = self.account.user self.fetchUserCollections() self.fetchUserPosts() } catch { - self.loginErrorMessage = AccountError.couldNotFetchTokenFromKeychain.localizedDescription - self.isPresentingLoginErrorAlert = true + self.currentError = KeychainError.couldNotFetchAccessToken + return } } } monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.hasNetworkConnection = path.status == .satisfied } } monitor.start(queue: queue) } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 4001266..f57a541 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,71 +1,86 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling var body: some View { NavigationView { #if os(macOS) CollectionListView() + .withErrorHandling() .toolbar { Button( action: { NSApp.keyWindow?.contentViewController?.tryToPerform( #selector(NSSplitViewController.toggleSidebar(_:)), with: nil ) }, label: { Image(systemName: "sidebar.left") } ) .help("Toggle the sidebar's visibility.") Spacer() Button(action: { 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 { DispatchQueue.main.asyncAfter(deadline: .now()) { // Load the new post in the editor self.model.selectedPost = managedPost } } }, label: { Image(systemName: "square.and.pencil") }) .help("Create a new local draft.") } #else CollectionListView() + .withErrorHandling() #endif #if os(macOS) ZStack { PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) + .withErrorHandling() if model.isProcessingRequest { ZStack { Color(NSColor.controlBackgroundColor).opacity(0.75) ProgressView() } } } #else PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) + .withErrorHandling() #endif Text("Select a post, or create a new local draft.") .foregroundColor(.secondary) } .environmentObject(model) + .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 ContentView_Previews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() return ContentView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/Shared/PostCollection/CollectionListModel.swift b/Shared/PostCollection/CollectionListModel.swift deleted file mode 100644 index 5e107bb..0000000 --- a/Shared/PostCollection/CollectionListModel.swift +++ /dev/null @@ -1,40 +0,0 @@ -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 { - print("Failed to fetch collections!") - } - } -} - -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..589903b 100644 --- a/Shared/PostCollection/CollectionListView.swift +++ b/Shared/PostCollection/CollectionListView.swift @@ -1,55 +1,64 @@ import SwiftUI struct CollectionListView: View { @EnvironmentObject var model: WriteFreelyModel - @ObservedObject var collections = CollectionListModel( - managedObjectContext: LocalStorageManager.standard.container.viewContext - ) + @EnvironmentObject var errorHandling: ErrorHandling + @FetchRequest(sortDescriptors: []) var collections: FetchedResults @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 + ForEach(collections, 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) } } diff --git a/Shared/PostList/PostListModel.swift b/Shared/PostList/PostListModel.swift index db0ff4a..edd545c 100644 --- a/Shared/PostList/PostListModel.swift +++ b/Shared/PostList/PostListModel.swift @@ -1,126 +1,126 @@ import SwiftUI import CoreData class PostListModel: ObservableObject { func remove(_ post: WFAPost) { withAnimation { LocalStorageManager.standard.container.viewContext.delete(post) LocalStorageManager.standard.saveContext() } } - func purgePublishedPosts() { + func purgePublishedPosts() throws { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFAPost") fetchRequest.predicate = NSPredicate(format: "status != %i", 0) let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try LocalStorageManager.standard.container.viewContext.executeAndMergeChanges(using: deleteRequest) } catch { - print("Error: Failed to purge cached posts.") + throw LocalStoreError.couldNotPurgePosts("cached") } } func getBodyPreview(of post: WFAPost) -> String { var elidedPostBody: String = "" // Strip any markdown from the post body. let strippedPostBody = stripMarkdown(from: post.body) // Extract lede from post. elidedPostBody = extractLede(from: strippedPostBody) return elidedPostBody } } private extension PostListModel { func stripMarkdown(from string: String) -> String { var strippedString = string strippedString = stripHeadingOctothorpes(from: strippedString) strippedString = stripImages(from: strippedString, keepAltText: true) return strippedString } func stripHeadingOctothorpes(from string: String) -> String { let newLines = CharacterSet.newlines var processedComponents: [String] = [] let components = string.components(separatedBy: newLines) for component in components { if component.isEmpty { continue } var newString = component while newString.first == "#" { newString.removeFirst() } if newString.hasPrefix(" ") { newString.removeFirst() } processedComponents.append(newString) } let headinglessString = processedComponents.joined(separator: "\n\n") return headinglessString } func stripImages(from string: String, keepAltText: Bool = false) -> String { let pattern = #"!\[[\"]?(.*?)[\"|]?\]\(.*?\)"# var processedComponents: [String] = [] let components = string.components(separatedBy: .newlines) for component in components { if component.isEmpty { continue } var processedString: String = component if keepAltText { let regex = try? NSRegularExpression(pattern: pattern, options: []) if let matches = regex?.matches( in: component, options: [], range: NSRange(location: 0, length: component.utf16.count) ) { for match in matches { if let range = Range(match.range(at: 1), in: component) { processedString = "\(component[range])" } } } } else { let range = component.startIndex.. String { let truncatedString = string.prefix(80) let terminatingPunctuation = ".。?" let terminatingCharacters = CharacterSet(charactersIn: terminatingPunctuation).union(.newlines) var lede: String = "" let sentences = truncatedString.components(separatedBy: terminatingCharacters) if let firstSentence = (sentences.filter { !$0.isEmpty }).first { if truncatedString.count > firstSentence.count { if terminatingPunctuation.contains(truncatedString[firstSentence.endIndex]) { lede = String(truncatedString[...firstSentence.endIndex]) } else { lede = firstSentence } } else if truncatedString.count == firstSentence.count { if string.count > 80 { if let endOfStringIndex = truncatedString.lastIndex(of: " ") { lede = truncatedString[.. - - - - SchemeUserState - - ActionExtension-iOS.xcscheme_^#shared#^_ - - orderHint - 1 - - WriteFreely-MultiPlatform (iOS).xcscheme_^#shared#^_ - - orderHint - 2 - - WriteFreely-MultiPlatform (macOS).xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/iOS/PostEditor/PostEditorView.swift b/iOS/PostEditor/PostEditorView.swift index 7a78ad6..4fed230 100644 --- a/iOS/PostEditor/PostEditorView.swift +++ b/iOS/PostEditor/PostEditorView.swift @@ -1,268 +1,275 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.managedObjectContext) var moc @Environment(\.presentationMode) var presentationMode @ObservedObject var post: WFAPost @State private var updatingTitleFromServer: Bool = false @State private var updatingBodyFromServer: Bool = false @State private var selectedCollection: WFACollection? @FetchRequest( entity: WFACollection.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)] ) var collections: FetchedResults var body: some View { VStack { if post.hasNewerRemoteCopy { RemoteChangePromptView( remoteChangeType: .remoteCopyUpdated, buttonHandler: { model.updateFromServer(post: post) } ) } else if post.wasDeletedFromServer { RemoteChangePromptView( remoteChangeType: .remoteCopyDeleted, buttonHandler: { self.presentationMode.wrappedValue.dismiss() DispatchQueue.main.async { model.posts.remove(post) } } ) } PostTextEditingView( post: _post, updatingTitleFromServer: $updatingTitleFromServer, updatingBodyFromServer: $updatingBodyFromServer ) + .withErrorHandling() } .navigationBarTitleDisplayMode(.inline) .padding() .toolbar { ToolbarItem(placement: .principal) { PostEditorStatusToolbarView(post: post) } ToolbarItem(placement: .primaryAction) { if model.isProcessingRequest { ProgressView() } else { Menu(content: { if post.status == PostStatus.local.rawValue { Menu(content: { Label("Publish to…", systemImage: "paperplane") Button(action: { if model.account.isLoggedIn { post.collectionAlias = nil publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Text(" \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")") }) ForEach(collections) { collection in Button(action: { if model.account.isLoggedIn { post.collectionAlias = collection.alias publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Text(" \(collection.title)") }) } }, label: { Label("Publish…", systemImage: "paperplane") }) .accessibilityHint(Text("Choose the blog you want to publish this post to")) .disabled(post.body.count == 0) } else { Button(action: { if model.account.isLoggedIn { publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Label("Publish", systemImage: "paperplane") }) .disabled(post.status == PostStatus.published.rawValue || post.body.count == 0) } Button(action: { sharePost() }, label: { Label("Share", systemImage: "square.and.arrow.up") }) .accessibilityHint(Text("Open the system share sheet to share a link to this post")) .disabled(post.postId == nil) -// Button(action: { -// print("Tapped 'Delete...' button") -// }, label: { -// Label("Delete…", systemImage: "trash") -// }) if model.account.isLoggedIn && post.status != PostStatus.local.rawValue { Section(header: Text("Move To Collection")) { Label("Move to:", systemImage: "arrowshape.zigzag.right") Picker(selection: $selectedCollection, label: Text("Move to…")) { Text( " \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")" ).tag(nil as WFACollection?) ForEach(collections) { collection in Text(" \(collection.title)").tag(collection as WFACollection?) } } } } }, label: { ZStack { Image("does.not.exist") .accessibilityHidden(true) Image(systemName: "ellipsis.circle") .imageScale(.large) .accessibilityHidden(true) } }) .accessibilityLabel(Text("Menu")) .accessibilityHint(Text("Opens a context menu to publish, share, or move the post")) .onTapGesture { hideKeyboard() } .disabled(post.body.count == 0) } } } .onChange(of: post.hasNewerRemoteCopy, perform: { _ in if !post.hasNewerRemoteCopy { updatingTitleFromServer = true updatingBodyFromServer = true } }) .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in if post.collectionAlias == newCollection?.alias { return } else { post.collectionAlias = newCollection?.alias model.move(post: post, from: selectedCollection, to: newCollection) } }) .onChange(of: post.status, perform: { value in if value != PostStatus.published.rawValue { self.model.editor.saveLastDraft(post) } else { self.model.editor.clearLastDraft() } DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } }) .onAppear(perform: { self.selectedCollection = collections.first { $0.alias == post.collectionAlias } if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { self.model.editor.saveLastDraft(post) } } else { self.model.editor.clearLastDraft() } }) + .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 + } + } .onDisappear(perform: { self.model.editor.clearLastDraft() if post.title.count == 0 && post.body.count == 0 && post.status == PostStatus.local.rawValue && post.updatedDate == nil && post.postId == nil { DispatchQueue.main.async { model.posts.remove(post) } } else if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } }) } private func publishPost() { DispatchQueue.main.async { LocalStorageManager.standard.saveContext() model.publish(post: post) } #if os(iOS) self.hideKeyboard() #endif } private func sharePost() { // If the post doesn't have a post ID, it isn't published, and therefore can't be shared, so return early. guard let postId = post.postId else { return } var urlString: String if let postSlug = post.slug, let postCollectionAlias = post.collectionAlias { // This post is in a collection, so share the URL as baseURL/postSlug. let urls = collections.filter { $0.alias == postCollectionAlias } let baseURL = urls.first?.url ?? "\(model.account.server)/\(postCollectionAlias)/" urlString = "\(baseURL)\(postSlug)" } else { // This is a draft post, so share the URL as server/postID urlString = "\(model.account.server)/\(postId)" } guard let data = URL(string: urlString) else { return } let activityView = UIActivityViewController(activityItems: [data], applicationActivities: nil) UIApplication.shared.windows.first?.rootViewController?.present(activityView, animated: true, completion: nil) if UIDevice.current.userInterfaceIdiom == .pad { activityView.popoverPresentationController?.permittedArrowDirections = .up activityView.popoverPresentationController?.sourceView = UIApplication.shared.windows.first activityView.popoverPresentationController?.sourceRect = CGRect( x: UIScreen.main.bounds.width, y: -125, width: 200, height: 200 ) } } } struct PostEditorView_EmptyPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.createdDate = Date() testPost.appearance = "norm" let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } struct PostEditorView_ExistingPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.createdDate = Date() testPost.appearance = "code" testPost.hasNewerRemoteCopy = true let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/iOS/Settings/SettingsView.swift b/iOS/Settings/SettingsView.swift index e64bc94..28c55a6 100644 --- a/iOS/Settings/SettingsView.swift +++ b/iOS/Settings/SettingsView.swift @@ -1,66 +1,67 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var model: WriteFreelyModel var body: some View { VStack { SettingsHeaderView() Form { Section(header: Text("Login Details")) { AccountView() + .withErrorHandling() } Section(header: Text("Appearance")) { PreferencesView(preferences: model.preferences) } Section(header: Text("External Links")) { HStack { Spacer() Link("View the Guide", destination: model.howToURL) Spacer() } HStack { Spacer() Link("Visit the Help Forum", destination: model.helpURL) Spacer() } HStack { Spacer() Link("Write a Review on the App Store", destination: model.reviewURL) Spacer() } } Section(header: Text("Acknowledgements")) { VStack { VStack(alignment: .leading) { Text("This application makes use of the following open-source projects:") .padding(.bottom) Text("• Lora typeface") .padding(.leading) Text("• Open Sans typeface") .padding(.leading) Text("• Hack typeface") .padding(.leading) } .padding(.bottom) .foregroundColor(.secondary) HStack { Spacer() Link("View the licenses", destination: model.licensesURL) Spacer() } } .padding() } } } // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } } struct SettingsView_Previews: PreviewProvider { static var previews: some View { SettingsView() .environmentObject(WriteFreelyModel()) } } diff --git a/macOS/PostEditor/PostEditorView.swift b/macOS/PostEditor/PostEditorView.swift index 56bedc9..b76e921 100644 --- a/macOS/PostEditor/PostEditorView.swift +++ b/macOS/PostEditor/PostEditorView.swift @@ -1,92 +1,103 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling @ObservedObject var post: WFAPost @State private var isHovering: Bool = false @State private var updatingFromServer: Bool = false var body: some View { PostTextEditingView( post: post, updatingFromServer: $updatingFromServer ) .padding() .background(Color(NSColor.controlBackgroundColor)) .onAppear(perform: { if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { self.model.editor.saveLastDraft(post) } } else { self.model.editor.clearLastDraft() } }) .onChange(of: post.hasNewerRemoteCopy, perform: { _ in if !post.hasNewerRemoteCopy { self.updatingFromServer = true } }) .onChange(of: post.status, perform: { value in if value != PostStatus.published.rawValue { self.model.editor.saveLastDraft(post) } else { self.model.editor.clearLastDraft() } DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } }) + .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 + } + } .onDisappear(perform: { DispatchQueue.main.async { model.editor.clearLastDraft() } if post.title.count == 0 && post.body.count == 0 && post.status == PostStatus.local.rawValue && post.updatedDate == nil && post.postId == nil { DispatchQueue.main.async { model.posts.remove(post) } } else if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } }) } } struct PostEditorView_EmptyPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.createdDate = Date() testPost.appearance = "norm" let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } struct PostEditorView_ExistingPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.createdDate = Date() testPost.appearance = "code" let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/macOS/Settings/MacAccountView.swift b/macOS/Settings/MacAccountView.swift index f0d4c30..9939e99 100644 --- a/macOS/Settings/MacAccountView.swift +++ b/macOS/Settings/MacAccountView.swift @@ -1,18 +1,29 @@ import SwiftUI struct MacAccountView: View { @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling var body: some View { - Form { - AccountView() + Form { + AccountView() + } + .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 MacAccountView_Previews: PreviewProvider { static var previews: some View { MacAccountView() .environmentObject(WriteFreelyModel()) } } diff --git a/macOS/Settings/MacPreferencesView.swift b/macOS/Settings/MacPreferencesView.swift index 85fa829..feb91e5 100644 --- a/macOS/Settings/MacPreferencesView.swift +++ b/macOS/Settings/MacPreferencesView.swift @@ -1,18 +1,31 @@ import SwiftUI struct MacPreferencesView: View { + @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling + @ObservedObject var preferences: PreferencesModel var body: some View { VStack { PreferencesView(preferences: preferences) Spacer() } + .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 MacPreferencesView_Previews: PreviewProvider { static var previews: some View { MacPreferencesView(preferences: PreferencesModel()) } } diff --git a/macOS/Settings/MacUpdatesView.swift b/macOS/Settings/MacUpdatesView.swift index afb6c48..ba9f6a3 100644 --- a/macOS/Settings/MacUpdatesView.swift +++ b/macOS/Settings/MacUpdatesView.swift @@ -1,91 +1,104 @@ import SwiftUI import Sparkle struct MacUpdatesView: View { + @EnvironmentObject var model: WriteFreelyModel + @EnvironmentObject var errorHandling: ErrorHandling + @ObservedObject var updaterViewModel: MacUpdatesViewModel @AppStorage(WFDefaults.automaticallyChecksForUpdates, store: UserDefaults.shared) var automaticallyChecksForUpdates: Bool = false @AppStorage(WFDefaults.subscribeToBetaUpdates, store: UserDefaults.shared) var subscribeToBetaUpdates: Bool = false @State private var lastUpdateCheck: Date? private let betaWarningString = """ To get brand new features before each official release, choose "Test versions." Note that test versions may have bugs \ that can cause crashes and data loss. """ static let lastUpdateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .short formatter.doesRelativeDateFormatting = true return formatter }() var body: some View { VStack(spacing: 24) { Toggle(isOn: $automaticallyChecksForUpdates, label: { Text("Check for updates automatically") }) VStack { Button(action: { updaterViewModel.checkForUpdates() // There's a delay between requesting an update, and the timestamp for that update request being // written to user defaults; we therefore delay updating the "Last checked" UI for one second. DispatchQueue.main.asyncAfter(deadline: .now() + 1) { lastUpdateCheck = updaterViewModel.getLastUpdateCheckDate() } }, label: { Text("Check For Updates") }) HStack { Text("Last checked:") .font(.caption) if let lastUpdateCheck = lastUpdateCheck { Text(lastUpdateCheck, formatter: Self.lastUpdateFormatter) .font(.caption) } else { Text("Never") .font(.caption) } } } VStack(spacing: 16) { HStack(alignment: .top) { Text("Download:") Picker(selection: $subscribeToBetaUpdates, label: Text("Download:"), content: { Text("Release versions").tag(false) Text("Test versions").tag(true) }) .pickerStyle(RadioGroupPickerStyle()) .labelsHidden() } Text(betaWarningString) .frame(width: 350) .foregroundColor(.secondary) } } .padding() .onAppear { lastUpdateCheck = updaterViewModel.getLastUpdateCheckDate() } .onChange(of: automaticallyChecksForUpdates) { value in updaterViewModel.automaticallyCheckForUpdates = value } .onChange(of: subscribeToBetaUpdates) { _ in updaterViewModel.toggleAllowedChannels() } + .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 MacUpdatesView_Previews: PreviewProvider { static var previews: some View { MacUpdatesView(updaterViewModel: MacUpdatesViewModel()) } }