// // LocalData.swift // Tusker // // Created by Shadowfacts on 8/18/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import Foundation import Combine import CryptoKit class LocalData: ObservableObject { static let shared = LocalData() let defaults: UserDefaults private init() { if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING") { defaults = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier!).uitesting")! defaults.removePersistentDomain(forName: "\(Bundle.main.bundleIdentifier!).uitesting") if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") { accounts = [ UserAccountInfo( instanceURL: URL(string: "http://localhost:8080")!, clientID: "client_id", clientSecret: "client_secret", username: "admin", accessToken: "access_token") ] } } else { defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")! } migrateAccountIDsIfNecessary() } private let accountsKey = "accounts" private(set) var accounts: [UserAccountInfo] { get { if let array = defaults.array(forKey: accountsKey) as? [[String: String]] { return array.compactMap(UserAccountInfo.init(userDefaultsDict:)) } else { return [] } } set { objectWillChange.send() let array = newValue.map { (info) -> [String: String] in var res = [ "id": info.id, "instanceURL": info.instanceURL.absoluteString, "clientID": info.clientID, "clientSecret": info.clientSecret, "accessToken": info.accessToken ] if let username = info.username { res["username"] = username } return res } defaults.set(array, forKey: accountsKey) } } private let mostRecentAccountKey = "mostRecentAccount" private(set) var mostRecentAccountID: String? { get { return defaults.string(forKey: mostRecentAccountKey) } set { objectWillChange.send() defaults.set(newValue, forKey: mostRecentAccountKey) } } private let usesAccountIDHashesKey = "usesAccountIDHashes" private var usesAccountIDHashes: Bool { get { return defaults.bool(forKey: usesAccountIDHashesKey) } set { return defaults.set(newValue, forKey: usesAccountIDHashesKey) } } private func migrateAccountIDsIfNecessary() { if usesAccountIDHashes { return } if let mostRecentAccount = getMostRecentAccount() { let hashedMostRecentID = UserAccountInfo.id(instanceURL: mostRecentAccount.instanceURL, username: mostRecentAccount.username) mostRecentAccountID = hashedMostRecentID } if let array = defaults.array(forKey: accountsKey) as? [[String: String]] { accounts = array.compactMap { guard let urlString = $0["instanceURL"], let url = URL(string: urlString), let username = $0["username"] else { return nil } var dict = $0 dict["id"] = UserAccountInfo.id(instanceURL: url, username: username) return UserAccountInfo(userDefaultsDict: dict) } } usesAccountIDHashes = true } // MARK: - Account Management var onboardingComplete: Bool { return !accounts.isEmpty } func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo { var accounts = self.accounts if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) { accounts.remove(at: index) } let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken) accounts.append(info) self.accounts = accounts return info } func removeAccount(_ info: UserAccountInfo) { accounts.removeAll(where: { $0.id == info.id }) } func getAccount(id: String) -> UserAccountInfo? { return accounts.first(where: { $0.id == id }) } func getMostRecentAccount() -> UserAccountInfo? { guard onboardingComplete else { return nil } let mostRecent: UserAccountInfo? if let id = mostRecentAccountID { mostRecent = accounts.first { $0.id == id } } else { mostRecent = nil } return mostRecent ?? accounts.first! } func setMostRecentAccount(_ account: UserAccountInfo?) { mostRecentAccountID = account?.id } } extension LocalData { struct UserAccountInfo: Equatable, Hashable { let id: String let instanceURL: URL let clientID: String let clientSecret: String private(set) var username: String! let accessToken: String fileprivate static let tempAccountID = "temp" fileprivate static func id(instanceURL: URL, username: String?) -> String { // We hash the instance host and username to form the account ID // so that account IDs will match across devices, allowing for data syncing and handoff. var hasher = SHA256() hasher.update(data: instanceURL.host!.data(using: .utf8)!) if let username { hasher.update(data: username.data(using: .utf8)!) } return Data(hasher.finalize()).base64EncodedString() } /// Only to be used for temporary MastodonController needed to fetch own account info and create final UserAccountInfo with real username init(tempInstanceURL instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) { self.id = UserAccountInfo.tempAccountID self.instanceURL = instanceURL self.clientID = clientID self.clientSecret = clientSecret self.accessToken = accessToken } fileprivate init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) { self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username) self.instanceURL = instanceURL self.clientID = clientID self.clientSecret = clientSecret self.username = username self.accessToken = accessToken } fileprivate init?(userDefaultsDict dict: [String: String]) { guard let id = dict["id"], let instanceURL = dict["instanceURL"], let url = URL(string: instanceURL), let clientID = dict["clientID"], let secret = dict["clientSecret"], let accessToken = dict["accessToken"] else { return nil } self.id = id self.instanceURL = url self.clientID = clientID self.clientSecret = secret self.username = dict["username"] self.accessToken = accessToken } /// A filename-safe string for this account var persistenceKey: String { // slashes are not allowed in the persistent store coordinator name id.replacingOccurrences(of: "/", with: "_") } func hash(into hasher: inout Hasher) { hasher.combine(id) } static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool { return lhs.id == rhs.id } } } extension Notification.Name { static let userLoggedOut = Notification.Name("Tusker.userLoggedOut") static let addAccount = Notification.Name("Tusker.addAccount") static let activateAccount = Notification.Name("Tusker.activateAccount") }