From cae270e8c4ce72fb0e567ed4b8145d9b7247d894 Mon Sep 17 00:00:00 2001 From: Angelo Stavrow Date: Mon, 1 Feb 2021 13:46:25 -0500 Subject: [PATCH] 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 */,