diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index eacfcff..18e2ebc 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,178 +1,184 @@ import Foundation import WriteFreely import Security // MARK: - WriteFreelyModel class WriteFreelyModel: ObservableObject { @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var store = PostStore() @Published var post: Post? @Published var isLoggingIn: Bool = false private var client: WFClient? + private let defaults = UserDefaults.standard init() { + // Set the color scheme based on what's been saved in UserDefaults. + DispatchQueue.main.async { + self.preferences.appearance = self.defaults.integer(forKey: self.preferences.colorSchemeIntegerKey) + } + #if DEBUG for post in testPostData { store.add(post) } #endif DispatchQueue.main.async { self.account.restoreState() } } } // MARK: - WriteFreelyModel API extension WriteFreelyModel { func login(to server: URL, as username: String, password: String) { isLoggingIn = true account.server = server.absoluteString client = WFClient(for: server) client?.login(username: username, password: password, completion: loginHandler) } func logout() { 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) } } private extension WriteFreelyModel { func loginHandler(result: Result) { DispatchQueue.main.async { self.isLoggingIn = false } do { let user = try result.get() saveTokenToKeychain(user.token, username: user.username, server: account.server) DispatchQueue.main.async { self.account.login(user) } } catch WFError.notFound { DispatchQueue.main.async { self.account.currentError = AccountError.usernameNotFound } } catch WFError.unauthorized { DispatchQueue.main.async { self.account.currentError = AccountError.invalidPassword } } catch { if let error = error as? NSError, error.domain == NSURLErrorDomain, error.code == -1003 { DispatchQueue.main.async { self.account.currentError = AccountError.serverNotFound } } } } 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() } } 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() } } 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 let error = error as? NSError, error.domain == NSURLErrorDomain, error.code == NSURLErrorCannotParseResponse { if account.isLoggedIn { self.logout() } } } } } 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/Shared/Preferences/PreferencesModel.swift b/Shared/Preferences/PreferencesModel.swift index 39e9c24..c4ac721 100644 --- a/Shared/Preferences/PreferencesModel.swift +++ b/Shared/Preferences/PreferencesModel.swift @@ -1,54 +1,59 @@ import SwiftUI class PreferencesModel: ObservableObject { + private let defaults = UserDefaults.standard + let colorSchemeIntegerKey = "colorSchemeIntegerKey" + /* We're stuck dropping into AppKit/UIKit to set light/dark schemes for now, * because setting the .preferredColorScheme modifier on views in SwiftUI is * currently unreliable. * * Feedback submitted to Apple: * * FB8382883: "On macOS 11β4, preferredColorScheme modifier does not respect .light ColorScheme" * FB8383053: "On iOS 14β4/macOS 11β4, it is not possible to unset preferredColorScheme after setting * it to either .light or .dark" */ #if os(iOS) var window: UIWindow? { guard let scene = UIApplication.shared.connectedScenes.first, let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate, let window = windowSceneDelegate.window else { return nil } return window } #endif @Published var selectedColorScheme: ColorScheme? @Published var appearance: Int = 0 { didSet { switch appearance { case 1: // selectedColorScheme = .light #if os(macOS) NSApp.appearance = NSAppearance(named: .aqua) #else window?.overrideUserInterfaceStyle = .light #endif case 2: // selectedColorScheme = .dark #if os(macOS) NSApp.appearance = NSAppearance(named: .darkAqua) #else window?.overrideUserInterfaceStyle = .dark #endif default: // selectedColorScheme = .none #if os(macOS) NSApp.appearance = nil #else window?.overrideUserInterfaceStyle = .unspecified #endif } + + defaults.set(appearance, forKey: colorSchemeIntegerKey) } } }