forked from shadowfacts/Tusker
232 lines
8.2 KiB
Swift
232 lines
8.2 KiB
Swift
//
|
|
// 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")
|
|
}
|