From 35d21fb725bd1d19f99a9e27d4db1d24a3bf46e2 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 12 Sep 2022 23:05:35 -0400 Subject: [PATCH] Switch to stable, hash-based account IDs #160 --- Tusker/LocalData.swift | 113 ++++++++++++++---- .../Onboarding/OnboardingViewController.swift | 2 +- .../Screens/Preferences/PreferencesView.swift | 2 +- 3 files changed, 92 insertions(+), 25 deletions(-) diff --git a/Tusker/LocalData.swift b/Tusker/LocalData.swift index 020c5884..ba4a7e89 100644 --- a/Tusker/LocalData.swift +++ b/Tusker/LocalData.swift @@ -8,6 +8,7 @@ import Foundation import Combine +import CryptoKit class LocalData: ObservableObject { @@ -22,7 +23,6 @@ class LocalData: ObservableObject { if ProcessInfo.processInfo.environment.keys.contains("UI_TESTING_LOGIN") { accounts = [ UserAccountInfo( - id: UUID().uuidString, instanceURL: URL(string: "http://localhost:8080")!, clientID: "client_id", clientSecret: "client_secret", @@ -33,23 +33,15 @@ class LocalData: ObservableObject { } else { defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")! } + + migrateAccountIDsIfNecessary() } private let accountsKey = "accounts" - var accounts: [UserAccountInfo] { + private(set) var accounts: [UserAccountInfo] { get { if let array = defaults.array(forKey: accountsKey) as? [[String: String]] { - return array.compactMap { (info) in - guard let id = info["id"], - let instanceURL = info["instanceURL"], - let url = URL(string: instanceURL), - let clientId = info["clientID"], - let secret = info["clientSecret"], - let accessToken = info["accessToken"] else { - return nil - } - return UserAccountInfo(id: id, instanceURL: url, clientID: clientId, clientSecret: secret, username: info["username"], accessToken: accessToken) - } + return array.compactMap(UserAccountInfo.init(userDefaultsDict:)) } else { return [] } @@ -84,6 +76,41 @@ class LocalData: ObservableObject { } } + 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 } @@ -93,20 +120,12 @@ class LocalData: ObservableObject { if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) { accounts.remove(at: index) } - let id = UUID().uuidString - let info = UserAccountInfo(id: id, instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken) + let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken) accounts.append(info) self.accounts = accounts return info } - func setUsername(for info: UserAccountInfo, username: String) { - var info = info - info.username = username - removeAccount(info) - accounts.append(info) - } - func removeAccount(_ info: UserAccountInfo) { accounts.removeAll(where: { $0.id == info.id }) } @@ -138,9 +157,57 @@ extension LocalData { let instanceURL: URL let clientID: String let clientSecret: String - fileprivate(set) var username: 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 + } + func hash(into hasher: inout Hasher) { hasher.combine(id) } diff --git a/Tusker/Screens/Onboarding/OnboardingViewController.swift b/Tusker/Screens/Onboarding/OnboardingViewController.swift index 1ee459de..46e87f24 100644 --- a/Tusker/Screens/Onboarding/OnboardingViewController.swift +++ b/Tusker/Screens/Onboarding/OnboardingViewController.swift @@ -62,7 +62,7 @@ class OnboardingViewController: UINavigationController { } // construct a temporary UserAccountInfo instance for the MastodonController to use to fetch its own account - let tempAccountInfo = LocalData.UserAccountInfo(id: "temp", instanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, username: nil, accessToken: accessToken) + let tempAccountInfo = LocalData.UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken) mastodonController.accountInfo = tempAccountInfo let ownAccount: Account diff --git a/Tusker/Screens/Preferences/PreferencesView.swift b/Tusker/Screens/Preferences/PreferencesView.swift index 37f92969..ac876ab1 100644 --- a/Tusker/Screens/Preferences/PreferencesView.swift +++ b/Tusker/Screens/Preferences/PreferencesView.swift @@ -48,7 +48,7 @@ struct PreferencesView: View { indices.remove(index) } - localData.accounts.remove(atOffsets: indices) + indices.forEach { localData.removeAccount(localData.accounts[$0]) } if logoutFromCurrent { self.logoutPressed()