Multi-server (#1906)

## Summary
Most, but not all, of the changes necessary to support multi-server throughout the app and all its features.

## Screenshots
| Light | Dark |
| ----- | ---- |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 24](https://user-images.githubusercontent.com/74188/143670011-9b9905ac-1b5b-4a82-b9f3-1490465c4ec5.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 26](https://user-images.githubusercontent.com/74188/143670012-0080230a-8f68-4f34-9691-db9f5e825a83.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 30](https://user-images.githubusercontent.com/74188/143670015-ceeac558-e039-4639-a186-b5001ab418b8.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 29](https://user-images.githubusercontent.com/74188/143670016-d72bb69d-83f5-4197-a742-59d208467258.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 47](https://user-images.githubusercontent.com/74188/143670021-6c90c40f-c2f1-4a33-aad9-da6626e99d9d.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 45](https://user-images.githubusercontent.com/74188/143670024-e99de69d-61d8-4e12-be73-a172242806a0.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 05](https://user-images.githubusercontent.com/74188/143670033-1a41ac7e-d4d1-458b-974e-2efdaf8e2288.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 03](https://user-images.githubusercontent.com/74188/143670049-baf4db64-64db-4bfb-88cf-4930f9e5661b.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 21](https://user-images.githubusercontent.com/74188/143670053-7ec794f1-857c-4ef6-a92a-5318e90ac6b6.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 19](https://user-images.githubusercontent.com/74188/143670056-a6a5207c-3bba-49fc-b5c6-fc6fa8141f9c.png) |

## Any other notes
- Encapsulates all connectivity, token & server-specific knowledge in a Server model object which gets passed around.
- Updates various places throughout the app to know about and use Server rather than accessing said information through non-server-specific methods.
- Visually requests/notes server in places where it's ambiguous. For example, the Open Page widget will gain a subtitle if multiple servers are set up.
- Allows switching which server is shown in the WebViews. Note that this doesn't take into account multi-window support on iPad/macOS yet.

Most things will migrate successfully however adding an additional server causes things like Shortcuts to start erroring requiring you specify which to use in the particular Shortcut.

Future work necessary:
- Model objects currently clobber each other if their identifiers match. For example, both servers having a zone named `home` means one of them wins the fight for which is known to the app.
- Being remotely logged out on any account causes the app to require onboarding again, when instead it should only do that if the last known server is logged out.
This commit is contained in:
Zac West
2021-11-27 12:33:46 -08:00
committed by GitHub
parent 3ad727934d
commit 5c104f76e9
169 changed files with 5556 additions and 2428 deletions

View File

@@ -0,0 +1,365 @@
import HAKit
import KeychainAccess
import Sodium
import UserNotifications
import Version
public protocol ServerObserver: AnyObject {
func serversDidChange(_ serverManager: ServerManager)
}
public protocol ServerManager {
var all: [Server] { get }
func server(for identifier: Identifier<Server>) -> Server?
@discardableResult
func add(identifier: Identifier<Server>, serverInfo: ServerInfo) -> Server
func remove(identifier: Identifier<Server>)
func removeAll()
func add(observer: ServerObserver)
func remove(observer: ServerObserver)
func restorableState() -> Data
func restoreState(_ state: Data)
}
public extension ServerManager {
func server(forWebhookID webhookID: String) -> Server? {
all.first(where: { $0.info.connection.webhookID == webhookID })
}
func server(forServerIdentifier rawIdentifier: String?) -> Server? {
if let rawIdentifier = rawIdentifier {
return server(for: .init(rawValue: rawIdentifier))
} else {
return nil
}
}
private var fallbackServer: Server? {
let all = all
if all.count == 1, let server = all.first {
return server
} else {
return nil
}
}
func server(for providing: ServerIdentifierProviding, fallback: Bool = true) -> Server? {
if let server = server(forServerIdentifier: providing.serverIdentifier) {
return server
} else if fallback {
return fallbackServer
} else {
return nil
}
}
func server(for intent: ServerIntentProviding, fallback: Bool = true) -> Server? {
if let server = server(forServerIdentifier: intent.server?.identifier) {
return server
} else if fallback {
return fallbackServer
} else {
return nil
}
}
func server(for content: UNNotificationContent) -> Server? {
if let webhookID = content.userInfo["webhook_id"] as? String {
return server(forWebhookID: webhookID)
} else {
// intentionally different, because 'webhook_id' is server version dependent
// if the value isn't provided, assume the first server
return all.first
}
}
}
internal protocol ServerManagerKeychain {
func removeAll() throws
func allKeys() -> [String]
func getData(_ key: String) throws -> Data?
func set(_ value: Data, key: String) throws
func remove(_ key: String) throws
}
private extension Identifier where ObjectType == Server {
var keychainKey: String { rawValue }
init(keychainKey: String) { rawValue = keychainKey }
}
private class ServerCache {
var restrictCaching: Bool = false
var info: [Identifier<Server>: ServerInfo] = [:]
var server: [Identifier<Server>: Server] = [:]
var all: [Server]?
func reset() {
info = [:]
server = [:]
all = nil
}
}
extension Keychain: ServerManagerKeychain {
public func set(_ value: Data, key: String) throws {
try set(value, key: key, ignoringAttributeSynchronizable: true)
}
public func getData(_ key: String) throws -> Data? {
try getData(key, ignoringAttributeSynchronizable: true)
}
public func remove(_ key: String) throws {
try remove(key, ignoringAttributeSynchronizable: true)
}
}
internal final class ServerManagerImpl: ServerManager {
private var keychain: ServerManagerKeychain
private var historicKeychain: ServerManagerKeychain
private var encoder: JSONEncoder
private var decoder: JSONDecoder
private var observers = NSHashTable<AnyObject>(options: .weakMemory)
public func add(observer: ServerObserver) {
observers.add(observer)
}
public func remove(observer: ServerObserver) {
observers.remove(observer)
}
static let service = "io.home-assistant.servers"
private let cache = ServerCache()
init(
keychain: ServerManagerKeychain = Keychain(service: ServerManagerImpl.service),
historicKeychain: ServerManagerKeychain = Keychain(service: Constants.BundleID)
) {
self.keychain = keychain
self.historicKeychain = historicKeychain
let encoder = JSONEncoder()
self.encoder = encoder
let decoder = JSONDecoder()
self.decoder = decoder
}
func setup(environment: AppEnvironment) {
cache.restrictCaching = environment.isAppExtension
// load to cache immediately
_ = all
do {
try migrateIfNeeded()
} catch {
environment.Log.error("failed to load historic server: \(error)")
}
}
public var all: [Server] {
if !cache.restrictCaching, let cachedServers = cache.all {
return cachedServers
} else {
let servers = loadServers()
cache.all = servers
return servers
}
}
public func server(for identifier: Identifier<Server>) -> Server? {
if let fast = cache.server[identifier] {
return fast
} else {
return all.first(where: { $0.identifier == identifier })
}
}
@discardableResult
public func add(identifier: Identifier<Server>, serverInfo: ServerInfo) -> Server {
let setValue = with(serverInfo) {
if $0.sortOrder == .max {
$0.sortOrder = all.map(\.info.sortOrder).max().map { $0 + 1000 } ?? 0
}
}
keychain.set(serverInfo: setValue, key: identifier.keychainKey, encoder: encoder)
cache.all = nil
notify()
return server(key: identifier.keychainKey, value: setValue)
}
public func remove(identifier: Identifier<Server>) {
keychain.deleteServerInfo(key: identifier.keychainKey)
cache.server[identifier] = nil
cache.all = nil
notify()
}
public func removeAll() {
cache.reset()
_ = try? keychain.removeAll()
notify()
}
private var suppressNotify = false
private func notify() {
guard !suppressNotify else { return }
DispatchQueue.main.async { [self] in
for observer in observers.allObjects.compactMap({ $0 as? ServerObserver }) {
observer.serversDidChange(self)
}
}
}
private func loadServers() -> [Server] {
keychain.allServerInfo(decoder: decoder).map { key, value in
server(key: key, value: value)
}.sorted()
}
private func server(key: String, value: ServerInfo) -> Server {
let identifier = Identifier<Server>(keychainKey: key)
if let server = cache.server[identifier] {
return server
}
var fallback = value
let server = Server(identifier: identifier, getter: { [cache, keychain, decoder] in
if let info = cache.info[identifier], !cache.restrictCaching {
return info
} else {
let info = keychain.getServerInfo(key: identifier.keychainKey, decoder: decoder) ?? fallback
cache.info[identifier] = info
return info
}
}, setter: { [weak self] baseServerInfo in
guard let self = self else { return }
var serverInfo = baseServerInfo
// update active URL so we can update just once if it's different than the save is doing
_ = serverInfo.connection.activeURL()
let old = self.cache.info[identifier]
guard old != serverInfo || self.cache.restrictCaching else { return }
self.keychain.set(serverInfo: serverInfo, key: identifier.keychainKey, encoder: self.encoder)
self.cache.info[identifier] = serverInfo
fallback = serverInfo
if old?.sortOrder != serverInfo.sortOrder {
self.cache.all = nil
}
self.notify()
})
cache.server[identifier] = server
return server
}
private func migrateIfNeeded() throws {
guard all.isEmpty else { return }
let userDefaults = UserDefaults(suiteName: Constants.AppGroupID)!
if let tokenInfoData = try historicKeychain.getData("tokenInfo"),
let connectionInfoData = try historicKeychain.getData("connectionInfo") {
// UserDefaults may be missing due to delete/reinstall, so fill in values for those if needed
let versionString = userDefaults.string(forKey: "version") ?? "2021.1"
let name = userDefaults.string(forKey: "location_name") ?? ServerInfo.defaultName
var serverInfo = ServerInfo(
name: name,
connection: try decoder.decode(ConnectionInfo.self, from: connectionInfoData),
token: try decoder.decode(TokenInfo.self, from: tokenInfoData),
version: try Version(hassVersion: versionString)
)
if let name = userDefaults.string(forKey: "override_device_name") {
serverInfo.setSetting(value: name, for: .overrideDeviceName)
}
add(identifier: Server.historicId, serverInfo: serverInfo)
try historicKeychain.removeAll()
}
}
public func restorableState() -> Data {
var state = [String: ServerInfo]()
for (id, info) in keychain.allServerInfo(decoder: decoder) {
state[id] = info
}
do {
return try encoder.encode(state)
} catch {
Current.Log.error(error)
return Data()
}
}
public func restoreState(_ state: Data) {
suppressNotify = true
do {
let state = try decoder.decode([String: ServerInfo].self, from: state)
// delete servers that aren't present
for key in keychain.allKeys() where state[key] == nil {
remove(identifier: .init(keychainKey: key))
}
// set the values for the still-existing or new servers
for (key, serverInfo) in state {
let identifier = Identifier<Server>(rawValue: key)
if let existing = cache.server[identifier] {
existing.info = serverInfo
} else {
add(identifier: identifier, serverInfo: serverInfo)
}
}
} catch {
Current.Log.error(error)
}
suppressNotify = false
notify()
}
}
private extension ServerManagerKeychain {
func allServerInfo(decoder: JSONDecoder) -> [(String, ServerInfo)] {
allKeys().compactMap { key in
getServerInfo(key: key, decoder: decoder).map { (key, $0) }
}
}
func getServerInfo(key: String, decoder: JSONDecoder) -> ServerInfo? {
guard let data = try? getData(key) else {
return nil
}
return try? decoder.decode(ServerInfo.self, from: data)
}
func set(serverInfo: ServerInfo, key: String, encoder: JSONEncoder) {
try? set(encoder.encode(serverInfo), key: key)
}
func deleteServerInfo(key: String) {
try? remove(key)
}
}