diff --git a/Shared/Account/AccountLoginView.swift b/Shared/Account/AccountLoginView.swift index 8284b95..75237c9 100644 --- a/Shared/Account/AccountLoginView.swift +++ b/Shared/Account/AccountLoginView.swift @@ -1,106 +1,104 @@ 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")) - ) + .onChange(of: model.shouldHandleError) { _ in + guard let error = model.currentError else { return } + self.errorHandling.handle(error: error) + model.currentError = nil } } } 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..a0dcd85 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("edited")) } 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..d4b33fb 100644 --- a/Shared/Account/AccountView.swift +++ b/Shared/Account/AccountView.swift @@ -1,26 +1,28 @@ import SwiftUI struct AccountView: View { @EnvironmentObject var model: WriteFreelyModel var body: some View { if model.account.isLoggedIn { HStack { Spacer() AccountLogoutView() + .withErrorHandling() Spacer() } .padding() } else { AccountLoginView() + .withErrorHandling() .padding(.top) } } } 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..86821e1 --- /dev/null +++ b/Shared/ErrorHandling/ErrorConstants.swift @@ -0,0 +1,164 @@ +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 +} + +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: "") + } + } +} + +// MARK: - Account Errors + +enum AccountError: Error { + case invalidPassword + case usernameNotFound + case serverNotFound + case invalidServerURL + case couldNotSaveTokenToKeychain + case couldNotFetchTokenFromKeychain + case couldNotDeleteTokenFromKeychain + 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 .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: "" + ) + 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: - Local Store Errors + +enum LocalStoreError: Error { + case couldNotSaveContext + case couldNotFetchCollections + case couldNotFetchPosts(String) + case couldNotPurgePublishedPosts + 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 .couldNotPurgePublishedPosts: + return NSLocalizedString("Failed to purge published 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 +} + +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: "") + } + } +} + +// MARK: - Fetch Errors 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/WriteFreelyModel+API.swift b/Shared/Extensions/WriteFreelyModel+API.swift index 939cf76..55282b1 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 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 } 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 } return } - guard let loggedInClient = client else { return } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient + 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 } return } - guard let loggedInClient = client else { return } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient + 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 } return } - guard let loggedInClient = client else { return } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient + 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 } return } - guard let loggedInClient = client else { return } - guard let postId = post.postId else { return } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient + return + } + guard let postId = post.postId else { + self.currentError = AppError.couldNotGetPostId + 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 } return } - guard let loggedInClient = client, - let postId = post.postId else { return } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient + return + } + guard let postId = post.postId else { + self.currentError = AppError.couldNotGetPostId + 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..6f67adf 100644 --- a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift +++ b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift @@ -1,278 +1,285 @@ 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 - } + setCurrentError(to: KeychainError.couldNotStoreAccessToken) } } catch WFError.notFound { - DispatchQueue.main.async { - self.loginErrorMessage = AccountError.usernameNotFound.localizedDescription - self.isPresentingLoginErrorAlert = true - } + setCurrentError(to: AccountError.usernameNotFound) } catch WFError.unauthorized { - DispatchQueue.main.async { - self.loginErrorMessage = AccountError.invalidPassword.localizedDescription - self.isPresentingLoginErrorAlert = true - } + setCurrentError(to: 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 - } + setCurrentError(to: AccountError.serverNotFound) } else { - DispatchQueue.main.async { - self.loginErrorMessage = error.localizedDescription - self.isPresentingLoginErrorAlert = true - } + setCurrentError(to: 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() + } catch { + self.currentError = error + } + do { + try self.posts.purgePublishedPosts() + } catch { + self.currentError = error + } } } catch { - print("Something went wrong purging the token from the Keychain.") + setCurrentError(to: 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() + } catch { + self.currentError = error + } + do { try self.posts.purgePublishedPosts() } catch { self.currentError = error } } } catch { - print("Something went wrong purging the token from the Keychain.") + setCurrentError(to: 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 - } + setCurrentError(to: AccountError.genericAuthError) self.logout() } catch { - print(error) + setCurrentError(to: error) } } 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.setCurrentError( + to: LocalStoreError.genericError("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) + setCurrentError(to: error) } } catch WFError.unauthorized { - DispatchQueue.main.async { - self.loginErrorMessage = "Something went wrong, please try logging in again." - self.isPresentingLoginErrorAlert = true - } + setCurrentError(to: AccountError.genericAuthError) self.logout() } catch { - print("Error: Failed to fetch cached posts") + setCurrentError(to: 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 } + guard let cachedPost = cachedPostsResults.first else { + setCurrentError(to: LocalStoreError.genericError("Could not get cached post from results")) + return + } importData(from: fetchedPost, into: cachedPost) DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { - print("Error: Failed to fetch cached posts") + setCurrentError(to: LocalStoreError.couldNotFetchPosts("cached")) } } } catch { - print(error) + setCurrentError(to: error) } } 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 } + guard let cachedPost = self.selectedPost else { + setCurrentError(to: LocalStoreError.genericError("Could not get cached post")) + return + } importData(from: fetchedPost, into: cachedPost) cachedPost.hasNewerRemoteCopy = false DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { - print(error) + setCurrentError(to: error) } } 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 + setCurrentError(to: LocalStoreError.genericError("Could not update post from server")) } } } catch { DispatchQueue.main.async { LocalStorageManager.standard.container.viewContext.rollback() + self.currentError = error } - print(error) } } 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 } + + private func setCurrentError(to error: Error) { + DispatchQueue.main.async { + self.currentError = error + } + } } diff --git a/Shared/LocalStorageManager.swift b/Shared/LocalStorageManager.swift index b644faf..8334c4a 100644 --- a/Shared/LocalStorageManager.swift +++ b/Shared/LocalStorageManager.swift @@ -1,123 +1,123 @@ import CoreData #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif final class LocalStorageManager { public static var standard = LocalStorageManager() public let container: NSPersistentContainer private let containerName = "LocalStorageModel" private init() { container = NSPersistentContainer(name: containerName) setupStore(in: container) registerObservers() } func saveContext() { if container.viewContext.hasChanges { do { try container.viewContext.save() } catch { - print("Error saving context: \(error)") + fatalError("Error saving context: \(error)") } } } - func purgeUserCollections() { + func purgeUserCollections() throws { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFACollection") let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try container.viewContext.executeAndMergeChanges(using: deleteRequest) } catch { - print("Error: Failed to purge cached collections.") + throw LocalStoreError.couldNotPurgeCollections } } } private extension LocalStorageManager { var oldStoreURL: URL { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return appSupport.appendingPathComponent("LocalStorageModel.sqlite") } var sharedStoreURL: URL { let id = "group.com.abunchtell.writefreely" let groupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: id)! return groupContainer.appendingPathComponent("LocalStorageModel.sqlite") } func setupStore(in container: NSPersistentContainer) { if !FileManager.default.fileExists(atPath: oldStoreURL.path) { container.persistentStoreDescriptions.first!.url = sharedStoreURL } container.loadPersistentStores { _, error in if let error = error { fatalError("Core Data store failed to load with error: \(error)") } } migrateStore(for: container) container.viewContext.automaticallyMergesChangesFromParent = true container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy } func migrateStore(for container: NSPersistentContainer) { // Check if the shared store exists before attempting a migration — for example, in case we've already attempted // and successfully completed a migration, but the deletion of the old store failed for some reason. guard !FileManager.default.fileExists(atPath: sharedStoreURL.path) else { return } let coordinator = container.persistentStoreCoordinator // Get a reference to the old store. guard let oldStore = coordinator.persistentStore(for: oldStoreURL) else { return } // Attempt to migrate the old store over to the shared store URL. do { try coordinator.migratePersistentStore(oldStore, to: sharedStoreURL, options: nil, withType: NSSQLiteStoreType) } catch { fatalError("Something went wrong migrating the store: \(error)") } // Attempt to delete the old store. do { try FileManager.default.removeItem(at: oldStoreURL) } catch { fatalError("Something went wrong while deleting the old store: \(error)") } } 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/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index ecb575f..c9487ab 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,83 +1,85 @@ import Foundation import WriteFreely import Security import Network // MARK: - WriteFreelyModel final class WriteFreelyModel: ObservableObject { @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var posts = PostListModel() @Published var editor = PostEditorModel() @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 shouldHandleError: Bool = false @Published var isPresentingNetworkErrorAlert: Bool = false @Published var postToDelete: WFAPost? #if os(iOS) @Published var isPresentingSettingsView: Bool = false #endif static var shared = WriteFreelyModel() - var loginErrorMessage: String? + var currentError: Error? { + didSet { + self.shouldHandleError = currentError != nil + } + } // 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") 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 = AccountError.couldNotFetchTokenFromKeychain 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 = AccountError.couldNotFetchTokenFromKeychain } } } monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.hasNetworkConnection = path.status == .satisfied } } monitor.start(queue: queue) } } diff --git a/Shared/PostCollection/CollectionListModel.swift b/Shared/PostCollection/CollectionListModel.swift index 5e107bb..c6db64e 100644 --- a/Shared/PostCollection/CollectionListModel.swift +++ b/Shared/PostCollection/CollectionListModel.swift @@ -1,40 +1,42 @@ 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!") + DispatchQueue.main.async { + WriteFreelyModel.shared.currentError = LocalStoreError.couldNotFetchCollections + } } } } 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/PostList/PostListModel.swift b/Shared/PostList/PostListModel.swift index db0ff4a..86c6b9e 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.couldNotPurgePublishedPosts } } 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 + 0 WriteFreely-MultiPlatform (iOS).xcscheme_^#shared#^_ orderHint - 2 + 1 WriteFreely-MultiPlatform (macOS).xcscheme_^#shared#^_ orderHint - 0 + 2