From e6599408eb80e56606172b2ffc306df7f3c5b1d4 Mon Sep 17 00:00:00 2001 From: Angelo Stavrow Date: Mon, 1 Feb 2021 11:37:46 -0500 Subject: [PATCH 1/9] Turn off smart dashes for easy insertion of shortcodes --- iOS/PostEditor/PostBodyTextView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/iOS/PostEditor/PostBodyTextView.swift b/iOS/PostEditor/PostBodyTextView.swift index 609533e..e9b071d 100644 --- a/iOS/PostEditor/PostBodyTextView.swift +++ b/iOS/PostEditor/PostBodyTextView.swift @@ -69,6 +69,7 @@ struct PostBodyTextView: UIViewRepresentable { textView.isUserInteractionEnabled = true textView.isScrollEnabled = true textView.alwaysBounceVertical = false + textView.smartDashesType = .no context.coordinator.textView = textView textView.delegate = context.coordinator From cae270e8c4ce72fb0e567ed4b8145d9b7247d894 Mon Sep 17 00:00:00 2001 From: Angelo Stavrow Date: Mon, 1 Feb 2021 13:46:25 -0500 Subject: [PATCH 2/9] Refactor WriteFreelyModel extensions into separate files --- Shared/Extensions/WriteFreelyModel+API.swift | 152 ++++++ .../WriteFreelyModel+APIHandlers.swift | 294 ++++++++++ .../WriteFreelyModel+Keychain.swift | 53 ++ Shared/Models/WriteFreelyModel.swift | 501 +----------------- .../project.pbxproj | 18 + 5 files changed, 519 insertions(+), 499 deletions(-) create mode 100644 Shared/Extensions/WriteFreelyModel+API.swift create mode 100644 Shared/Extensions/WriteFreelyModel+APIHandlers.swift create mode 100644 Shared/Extensions/WriteFreelyModel+Keychain.swift diff --git a/Shared/Extensions/WriteFreelyModel+API.swift b/Shared/Extensions/WriteFreelyModel+API.swift new file mode 100644 index 0000000..b13bd4b --- /dev/null +++ b/Shared/Extensions/WriteFreelyModel+API.swift @@ -0,0 +1,152 @@ +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") + } + return + } + loggedInClient.logout(completion: logoutHandler) + } + + func fetchUserCollections() { + if !hasNetworkConnection { + DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } + 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 } + 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 } + 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.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 } + // 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 } + // 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 new file mode 100644 index 0000000..9000ace --- /dev/null +++ b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift @@ -0,0 +1,294 @@ +import Foundation +import WriteFreely + +extension WriteFreelyModel { + func loginHandler(result: Result) { + DispatchQueue.main.async { + self.isLoggingIn = false + } + do { + let user = try result.get() + fetchUserCollections() + fetchUserPosts() + saveTokenToKeychain(user.token, username: user.username, server: account.server) + DispatchQueue.main.async { + self.account.login(user) + } + } catch WFError.notFound { + DispatchQueue.main.async { + self.loginErrorMessage = AccountError.usernameNotFound.localizedDescription + self.isPresentingLoginErrorAlert = true + } + } catch WFError.unauthorized { + DispatchQueue.main.async { + self.loginErrorMessage = AccountError.invalidPassword.localizedDescription + self.isPresentingLoginErrorAlert = true + } + } catch { + if (error as NSError).domain == NSURLErrorDomain, + (error as NSError).code == -1003 { + DispatchQueue.main.async { + self.loginErrorMessage = AccountError.serverNotFound.localizedDescription + self.isPresentingLoginErrorAlert = true + } + } else { + DispatchQueue.main.async { + self.loginErrorMessage = error.localizedDescription + self.isPresentingLoginErrorAlert = true + } + } + } + } + + 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().purgeUserCollections() + self.posts.purgePublishedPosts() + } + } catch { + print("Something went wrong purging the token from the Keychain.") + } + } 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().purgeUserCollections() + self.posts.purgePublishedPosts() + } + } catch { + print("Something went wrong purging the token from the Keychain.") + } + } 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.persistentContainer.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().saveContext() + } + } catch WFError.unauthorized { + DispatchQueue.main.async { + self.loginErrorMessage = "Something went wrong, please try logging in again." + self.isPresentingLoginErrorAlert = true + } + self.logout() + } catch { + print(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.persistentContainer.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") } + postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) + } + } else { + DispatchQueue.main.async { + let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) + managedPost.postId = fetchedPost.postId + managedPost.slug = fetchedPost.slug + managedPost.appearance = fetchedPost.appearance + managedPost.language = fetchedPost.language + managedPost.rtl = fetchedPost.rtl ?? false + managedPost.createdDate = fetchedPost.createdDate + managedPost.updatedDate = fetchedPost.updatedDate + managedPost.title = fetchedPost.title ?? "" + managedPost.body = fetchedPost.body + managedPost.collectionAlias = fetchedPost.collectionAlias + managedPost.status = PostStatus.published.rawValue + managedPost.wasDeletedFromServer = false + } + } + } + DispatchQueue.main.async { + for post in postsToDelete { post.wasDeletedFromServer = true } + LocalStorageManager().saveContext() + } + } catch { + print(error) + } + } catch WFError.unauthorized { + DispatchQueue.main.async { + self.loginErrorMessage = "Something went wrong, please try logging in again." + self.isPresentingLoginErrorAlert = true + } + self.logout() + } catch { + print("Error: Failed to fetch cached posts") + } + } + + 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 { + updatingPost.appearance = fetchedPost.appearance + updatingPost.body = fetchedPost.body + updatingPost.createdDate = fetchedPost.createdDate + updatingPost.language = fetchedPost.language + updatingPost.postId = fetchedPost.postId + updatingPost.rtl = fetchedPost.rtl ?? false + updatingPost.slug = fetchedPost.slug + updatingPost.status = PostStatus.published.rawValue + updatingPost.title = fetchedPost.title ?? "" + updatingPost.updatedDate = fetchedPost.updatedDate + DispatchQueue.main.async { + LocalStorageManager().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.persistentContainer.viewContext.fetch(request) + guard let cachedPost = cachedPostsResults.first else { return } + 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 + DispatchQueue.main.async { + LocalStorageManager().saveContext() + } + } catch { + print("Error: Failed to fetch cached posts") + } + } + } catch { + print(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 } + 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 + cachedPost.hasNewerRemoteCopy = false + DispatchQueue.main.async { + LocalStorageManager().saveContext() + } + } catch { + print(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 + } + } + } catch { + DispatchQueue.main.async { + LocalStorageManager.persistentContainer.viewContext.rollback() + } + print(error) + } + } +} diff --git a/Shared/Extensions/WriteFreelyModel+Keychain.swift b/Shared/Extensions/WriteFreelyModel+Keychain.swift new file mode 100644 index 0000000..fd37506 --- /dev/null +++ b/Shared/Extensions/WriteFreelyModel+Keychain.swift @@ -0,0 +1,53 @@ +import Foundation + +extension WriteFreelyModel { + func saveTokenToKeychain(_ token: String, username: String?, server: String) { + 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 { + fatalError("Error storing in Keychain with OSStatus: \(status)") + } + } + + 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 { + fatalError("Error deleting from Keychain with OSStatus: \(status)") + } + } + + func fetchTokenFromKeychain(username: String?, server: String) -> 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 + } + guard status == errSecSuccess else { + fatalError("Error fetching from Keychain with OSStatus: \(status)") + } + 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 + } + return token + } +} diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index 286f39b..3c9c903 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -33,11 +33,11 @@ class WriteFreelyModel: ObservableObject { let licensesURL = URL(string: "https://github.com/writeas/writefreely-swiftui-multiplatform/tree/main/Shared/Resources/Licenses")! // swiftlint:enable line_length - private var client: WFClient? + internal var client: WFClient? private let defaults = UserDefaults.standard private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitor") - private var postToUpdate: WFAPost? + internal var postToUpdate: WFAPost? init() { DispatchQueue.main.async { @@ -72,500 +72,3 @@ class WriteFreelyModel: ObservableObject { monitor.start(queue: queue) } } - -// MARK: - WriteFreelyModel API - -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") - } - return - } - loggedInClient.logout(completion: logoutHandler) - } - - func fetchUserCollections() { - if !hasNetworkConnection { - DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } - 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 } - 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 } - 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.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 } - // 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 } - // 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) - } -} - -private extension WriteFreelyModel { - func loginHandler(result: Result) { - DispatchQueue.main.async { - self.isLoggingIn = false - } - do { - let user = try result.get() - fetchUserCollections() - fetchUserPosts() - saveTokenToKeychain(user.token, username: user.username, server: account.server) - DispatchQueue.main.async { - self.account.login(user) - } - } catch WFError.notFound { - DispatchQueue.main.async { - self.loginErrorMessage = AccountError.usernameNotFound.localizedDescription - self.isPresentingLoginErrorAlert = true - } - } catch WFError.unauthorized { - DispatchQueue.main.async { - self.loginErrorMessage = AccountError.invalidPassword.localizedDescription - self.isPresentingLoginErrorAlert = true - } - } catch { - if (error as NSError).domain == NSURLErrorDomain, - (error as NSError).code == -1003 { - DispatchQueue.main.async { - self.loginErrorMessage = AccountError.serverNotFound.localizedDescription - self.isPresentingLoginErrorAlert = true - } - } else { - DispatchQueue.main.async { - self.loginErrorMessage = error.localizedDescription - self.isPresentingLoginErrorAlert = true - } - } - } - } - - 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().purgeUserCollections() - self.posts.purgePublishedPosts() - } - } catch { - print("Something went wrong purging the token from the Keychain.") - } - } 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().purgeUserCollections() - self.posts.purgePublishedPosts() - } - } catch { - print("Something went wrong purging the token from the Keychain.") - } - } 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.persistentContainer.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().saveContext() - } - } catch WFError.unauthorized { - DispatchQueue.main.async { - self.loginErrorMessage = "Something went wrong, please try logging in again." - self.isPresentingLoginErrorAlert = true - } - self.logout() - } catch { - print(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.persistentContainer.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") } - postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) - } - } else { - DispatchQueue.main.async { - let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) - managedPost.postId = fetchedPost.postId - managedPost.slug = fetchedPost.slug - managedPost.appearance = fetchedPost.appearance - managedPost.language = fetchedPost.language - managedPost.rtl = fetchedPost.rtl ?? false - managedPost.createdDate = fetchedPost.createdDate - managedPost.updatedDate = fetchedPost.updatedDate - managedPost.title = fetchedPost.title ?? "" - managedPost.body = fetchedPost.body - managedPost.collectionAlias = fetchedPost.collectionAlias - managedPost.status = PostStatus.published.rawValue - managedPost.wasDeletedFromServer = false - } - } - } - DispatchQueue.main.async { - for post in postsToDelete { post.wasDeletedFromServer = true } - LocalStorageManager().saveContext() - } - } catch { - print(error) - } - } catch WFError.unauthorized { - DispatchQueue.main.async { - self.loginErrorMessage = "Something went wrong, please try logging in again." - self.isPresentingLoginErrorAlert = true - } - self.logout() - } catch { - print("Error: Failed to fetch cached posts") - } - } - - 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 { - updatingPost.appearance = fetchedPost.appearance - updatingPost.body = fetchedPost.body - updatingPost.createdDate = fetchedPost.createdDate - updatingPost.language = fetchedPost.language - updatingPost.postId = fetchedPost.postId - updatingPost.rtl = fetchedPost.rtl ?? false - updatingPost.slug = fetchedPost.slug - updatingPost.status = PostStatus.published.rawValue - updatingPost.title = fetchedPost.title ?? "" - updatingPost.updatedDate = fetchedPost.updatedDate - DispatchQueue.main.async { - LocalStorageManager().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.persistentContainer.viewContext.fetch(request) - guard let cachedPost = cachedPostsResults.first else { return } - 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 - DispatchQueue.main.async { - LocalStorageManager().saveContext() - } - } catch { - print("Error: Failed to fetch cached posts") - } - } - } catch { - print(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 } - 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 - cachedPost.hasNewerRemoteCopy = false - DispatchQueue.main.async { - LocalStorageManager().saveContext() - } - } catch { - print(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 - } - } - } catch { - DispatchQueue.main.async { - LocalStorageManager.persistentContainer.viewContext.rollback() - } - print(error) - } - } -} - -private extension WriteFreelyModel { - // MARK: - Keychain Helpers - func saveTokenToKeychain(_ token: String, username: String?, server: String) { - 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 { - fatalError("Error storing in Keychain with OSStatus: \(status)") - } - } - - 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 { - fatalError("Error deleting from Keychain with OSStatus: \(status)") - } - } - - func fetchTokenFromKeychain(username: String?, server: String) -> 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 - } - guard status == errSecSuccess else { - fatalError("Error fetching from Keychain with OSStatus: \(status)") - } - 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 - } - return token - } -} diff --git a/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj b/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj index 2a02539..138793a 100644 --- a/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj +++ b/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj @@ -59,6 +59,12 @@ 17A67CAF251A5DD7002F163D /* PostEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A67CAE251A5DD7002F163D /* PostEditorView.swift */; }; 17AD0A5E25489E810057D763 /* PostTitleTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17AD0A5D25489E810057D763 /* PostTitleTextView.swift */; }; 17AD0A6425489E900057D763 /* PostBodyTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17AD0A6325489E900057D763 /* PostBodyTextView.swift */; }; + 17B37C4B25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */; }; + 17B37C4C25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */; }; + 17B37C5625C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */; }; + 17B37C5725C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */; }; + 17B37C5D25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */; }; + 17B37C5E25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */; }; 17B3E965250FAA9000EE9748 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 17B3E964250FAA9000EE9748 /* LaunchScreen.storyboard */; }; 17B5103B2515448D00E9631F /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 17B5103A2515448D00E9631F /* Credits.rtf */; }; 17B996D82502D23E0017B536 /* WFAPost+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B996D62502D23E0017B536 /* WFAPost+CoreDataClass.swift */; }; @@ -155,6 +161,9 @@ 17A67CAE251A5DD7002F163D /* PostEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEditorView.swift; sourceTree = ""; }; 17AD0A5D25489E810057D763 /* PostTitleTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTitleTextView.swift; sourceTree = ""; }; 17AD0A6325489E900057D763 /* PostBodyTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBodyTextView.swift; sourceTree = ""; }; + 17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+Keychain.swift"; sourceTree = ""; }; + 17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+API.swift"; sourceTree = ""; }; + 17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteFreelyModel+APIHandlers.swift"; sourceTree = ""; }; 17B3E964250FAA9000EE9748 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 17B5103A2515448D00E9631F /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = ""; }; 17B68D4F25A4FED2005ED37C /* Sparkle-License.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Sparkle-License.txt"; sourceTree = ""; }; @@ -261,6 +270,9 @@ children = ( 17C42E6F250AA12200072984 /* NSManagedObjectContext+ExecuteAndMergeChanges.swift */, 17480CA4251272EE00EB7765 /* Bundle+AppVersion.swift */, + 17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */, + 17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */, + 17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */, ); path = Extensions; sourceTree = ""; @@ -723,14 +735,17 @@ files = ( 17DF32AC24C87D3500BCE2E3 /* ContentView.swift in Sources */, 173E19D1254318F600440F0F /* RemoteChangePromptView.swift in Sources */, + 17B37C5625C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */, 17C42E622507D8E600072984 /* PostStatus.swift in Sources */, 1756DBBA24FED45500207AB8 /* LocalStorageManager.swift in Sources */, 1756AE8124CB844500FD7257 /* View+Keyboard.swift in Sources */, 17C42E652509237800072984 /* PostListFilteredView.swift in Sources */, 170DFA34251BBC44001D82A0 /* PostEditorModel.swift in Sources */, 17120DAC24E1B99F002B9F6C /* AccountLoginView.swift in Sources */, + 17B37C4B25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */, 17480CA5251272EE00EB7765 /* Bundle+AppVersion.swift in Sources */, 17AD0A6425489E900057D763 /* PostBodyTextView.swift in Sources */, + 17B37C5D25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift in Sources */, 17AD0A5E25489E810057D763 /* PostTitleTextView.swift in Sources */, 17120DA924E1B2F5002B9F6C /* AccountLogoutView.swift in Sources */, 171BFDFA24D4AF8300888236 /* CollectionListView.swift in Sources */, @@ -786,15 +801,18 @@ 171BFDFB24D4AF8300888236 /* CollectionListView.swift in Sources */, 17A67CAF251A5DD7002F163D /* PostEditorView.swift in Sources */, 17DF32AB24C87D3500BCE2E3 /* WriteFreely_MultiPlatformApp.swift in Sources */, + 17B37C5725C8679800FE75E9 /* WriteFreelyModel+API.swift in Sources */, 17A5388C24DDC83F00DEFF9A /* AccountModel.swift in Sources */, 17B996D92502D23E0017B536 /* WFAPost+CoreDataClass.swift in Sources */, 1756DBB824FED3A400207AB8 /* LocalStorageModel.xcdatamodeld in Sources */, + 17B37C4C25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift in Sources */, 17A5389324DDED0000DEFF9A /* PreferencesView.swift in Sources */, 1756AE6F24CB255B00FD7257 /* PostListModel.swift in Sources */, 1756DC0224FEE18400207AB8 /* WFACollection+CoreDataClass.swift in Sources */, 1756DBB424FECDBB00207AB8 /* PostEditorStatusToolbarView.swift in Sources */, 17A5388F24DDEC7400DEFF9A /* AccountView.swift in Sources */, 1780F6EF25895EDB00FE45FF /* PostCommands.swift in Sources */, + 17B37C5E25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift in Sources */, 170DFA35251BBC44001D82A0 /* PostEditorModel.swift in Sources */, 1756AE7524CB26FA00FD7257 /* PostCellView.swift in Sources */, 17A5388824DDA31F00DEFF9A /* MacAccountView.swift in Sources */, From fa8c0026e7c227ced5bf3c68d2f64b4a753d72dd Mon Sep 17 00:00:00 2001 From: Angelo Stavrow Date: Mon, 1 Feb 2021 14:20:42 -0500 Subject: [PATCH 3/9] Drop trailing slash when validating login form --- Shared/Account/AccountLoginView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Shared/Account/AccountLoginView.swift b/Shared/Account/AccountLoginView.swift index de7bb97..4028c93 100644 --- a/Shared/Account/AccountLoginView.swift +++ b/Shared/Account/AccountLoginView.swift @@ -58,6 +58,9 @@ struct AccountLoginView: View { #if os(iOS) hideKeyboard() #endif + if server.hasSuffix("/") { + server = String(server.dropLast(1)) + } model.login( to: URL(string: server)!, as: username, password: password From 461f9cca4076844153b2dc5a3950c13e791a53fd Mon Sep 17 00:00:00 2001 From: Angelo Stavrow Date: Mon, 1 Feb 2021 14:27:15 -0500 Subject: [PATCH 4/9] Mark WriteFreelyModel class as final --- Shared/Models/WriteFreelyModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index 3c9c903..f5b1011 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -5,7 +5,7 @@ import Network // MARK: - WriteFreelyModel -class WriteFreelyModel: ObservableObject { +final class WriteFreelyModel: ObservableObject { @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var posts = PostListModel() From c00b71cd432563f1e9224481ec5e05c1d930df98 Mon Sep 17 00:00:00 2001 From: Angelo Stavrow Date: Mon, 1 Feb 2021 15:45:33 -0500 Subject: [PATCH 5/9] Rewrite server URL string if logging into Write.as --- Shared/Account/AccountLoginView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Shared/Account/AccountLoginView.swift b/Shared/Account/AccountLoginView.swift index 4028c93..132a140 100644 --- a/Shared/Account/AccountLoginView.swift +++ b/Shared/Account/AccountLoginView.swift @@ -58,6 +58,13 @@ struct AccountLoginView: View { #if os(iOS) hideKeyboard() #endif + // If logging in to Write.as, trim any path in the hostname. + if server.hasPrefix("https://write.as") || + server.hasPrefix("http://write.as") || + server.hasPrefix("write.as") { + server = "https://write.as" + } + // Trim any trailing slashes that would cause the request to fail. if server.hasSuffix("/") { server = String(server.dropLast(1)) } From 1a8951a3e0afe659f4f0ec773bb2e0f51a79d4f3 Mon Sep 17 00:00:00 2001 From: Angelo Stavrow Date: Mon, 1 Feb 2021 17:03:40 -0500 Subject: [PATCH 6/9] Use URLComponents to parse out scheme and host if possible --- Shared/Account/AccountLoginView.swift | 23 ++++++++++++------- .../project.pbxproj | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Shared/Account/AccountLoginView.swift b/Shared/Account/AccountLoginView.swift index 132a140..05d2384 100644 --- a/Shared/Account/AccountLoginView.swift +++ b/Shared/Account/AccountLoginView.swift @@ -58,15 +58,22 @@ struct AccountLoginView: View { #if os(iOS) hideKeyboard() #endif - // If logging in to Write.as, trim any path in the hostname. - if server.hasPrefix("https://write.as") || - server.hasPrefix("http://write.as") || - server.hasPrefix("write.as") { - server = "https://write.as" + // If the server string is not prefixed with a scheme, prepend "https://" to it. + if !(server.hasPrefix("https://") || server.hasPrefix("http://")) { + server = "https://\(server)" } - // Trim any trailing slashes that would cause the request to fail. - if server.hasSuffix("/") { - server = String(server.dropLast(1)) + print(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 + } else { + // TODO: - throw an error if this is an invalid URL. } model.login( to: URL(string: server)!, diff --git a/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj b/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj index 138793a..88a78c6 100644 --- a/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj +++ b/WriteFreely-MultiPlatform.xcodeproj/project.pbxproj @@ -268,8 +268,8 @@ 1756AE7F24CB841200FD7257 /* Extensions */ = { isa = PBXGroup; children = ( - 17C42E6F250AA12200072984 /* NSManagedObjectContext+ExecuteAndMergeChanges.swift */, 17480CA4251272EE00EB7765 /* Bundle+AppVersion.swift */, + 17C42E6F250AA12200072984 /* NSManagedObjectContext+ExecuteAndMergeChanges.swift */, 17B37C5525C8679800FE75E9 /* WriteFreelyModel+API.swift */, 17B37C5C25C8698900FE75E9 /* WriteFreelyModel+APIHandlers.swift */, 17B37C4A25C8661300FE75E9 /* WriteFreelyModel+Keychain.swift */, From 6da33e22cc59337aecd9f4546c73ba8803aa31e7 Mon Sep 17 00:00:00 2001 From: Angelo Stavrow Date: Tue, 2 Feb 2021 11:12:30 -0500 Subject: [PATCH 7/9] Add logic for AccountError.invalidServerURL case when logging in --- Shared/Account/AccountLoginView.swift | 4 ++-- Shared/Account/AccountModel.swift | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Shared/Account/AccountLoginView.swift b/Shared/Account/AccountLoginView.swift index 05d2384..d420aa1 100644 --- a/Shared/Account/AccountLoginView.swift +++ b/Shared/Account/AccountLoginView.swift @@ -62,7 +62,6 @@ struct AccountLoginView: View { if !(server.hasPrefix("https://") || server.hasPrefix("http://")) { server = "https://\(server)" } - print(server) // We only need the protocol and host from the URL, so drop anything else. let url = URLComponents(string: server) if let validURL = url { @@ -73,7 +72,8 @@ struct AccountLoginView: View { hostURL.host = host server = hostURL.string ?? server } else { - // TODO: - throw an error if this is an invalid URL. + model.loginErrorMessage = AccountError.invalidServerURL.localizedDescription + model.isPresentingLoginErrorAlert = true } model.login( to: URL(string: server)!, diff --git a/Shared/Account/AccountModel.swift b/Shared/Account/AccountModel.swift index 25683a5..0010a44 100644 --- a/Shared/Account/AccountModel.swift +++ b/Shared/Account/AccountModel.swift @@ -5,6 +5,7 @@ enum AccountError: Error { case invalidPassword case usernameNotFound case serverNotFound + case invalidServerURL } extension AccountError: LocalizedError { @@ -25,6 +26,11 @@ extension AccountError: LocalizedError { "Username not found. Did you use your email address by mistake?", comment: "" ) + case .invalidServerURL: + return NSLocalizedString( + "The server entered doesn't appear to be a valid URL. Please check what you've entered and try again.", + comment: "" + ) } } } From de50f3c9a488b7aec96db0c8c76f675b491d9401 Mon Sep 17 00:00:00 2001 From: Angelo Stavrow Date: Tue, 2 Feb 2021 11:39:29 -0500 Subject: [PATCH 8/9] Change wording of error message --- Shared/Account/AccountModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shared/Account/AccountModel.swift b/Shared/Account/AccountModel.swift index 0010a44..4dc3aba 100644 --- a/Shared/Account/AccountModel.swift +++ b/Shared/Account/AccountModel.swift @@ -28,7 +28,7 @@ extension AccountError: LocalizedError { ) case .invalidServerURL: return NSLocalizedString( - "The server entered doesn't appear to be a valid URL. Please check what you've entered and try again.", + "Please enter a valid instance domain name. It should look like \"https://example.com\" or \"write.as\".", // swiftlint:disable:this line_length comment: "" ) } From a354b807fe6034079f45af5d6f4fd5b802e32ada Mon Sep 17 00:00:00 2001 From: Angelo Stavrow Date: Tue, 2 Feb 2021 11:40:01 -0500 Subject: [PATCH 9/9] Don't try to log in if the server string isn't valid --- Shared/Account/AccountLoginView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shared/Account/AccountLoginView.swift b/Shared/Account/AccountLoginView.swift index d420aa1..8284b95 100644 --- a/Shared/Account/AccountLoginView.swift +++ b/Shared/Account/AccountLoginView.swift @@ -71,14 +71,14 @@ struct AccountLoginView: View { 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 } - model.login( - to: URL(string: server)!, - as: username, password: password - ) }, label: { Text("Log In") })