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? func serverOrFirstIfAvailable(for identifier: Identifier) -> Server? @discardableResult func add(identifier: Identifier, serverInfo: ServerInfo) -> Server func remove(identifier: Identifier) 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 { return server(for: .init(rawValue: rawIdentifier)) } else { return nil } } func serverOrFirstIfAvailable(for identifier: Identifier) -> Server? { server(forServerIdentifier: identifier.rawValue) ?? all.first } 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 } } } 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 struct ServerCache { var restrictCaching: Bool = false var deletedServers: Set> { get { let identifiers = Current.settingsStore.prefs.array(forKey: "deletedServers") as? [String] ?? [] return Set(identifiers.map { Identifier(rawValue: $0) }) } set { Current.settingsStore.prefs.set(newValue.map(\.rawValue), forKey: "deletedServers") } } var info: [Identifier: ServerInfo] = [:] { didSet { precondition(deletedServers.isDisjoint(with: info.keys)) } } var server: [Identifier: Server] = [:] { didSet { if !deletedServers.isDisjoint(with: server.keys) { Current.Log .error( "There are server(s) in cache that are deleted also in deleted servers set, servers: \(server.keys), deleted servers: \(deletedServers)" ) } } } var all: [Server]? mutating func remove(identifier: Identifier) { info[identifier] = nil server[identifier] = nil all?.removeAll(where: { $0.identifier == identifier }) } mutating 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) } } final class ServerManagerImpl: ServerManager { private var keychain: ServerManagerKeychain private var historicKeychain: ServerManagerKeychain private var encoder: JSONEncoder private var decoder: JSONDecoder private var observers = NSHashTable(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 = HAProtected(value: .init()) init( keychain: ServerManagerKeychain = Keychain(service: ServerManagerImpl.service), historicKeychain: ServerManagerKeychain = Keychain(service: AppConstants.BundleID) ) { self.keychain = keychain self.historicKeychain = historicKeychain let encoder = JSONEncoder() self.encoder = encoder let decoder = JSONDecoder() self.decoder = decoder } func setup() { cache.mutate { value in value.restrictCaching = Current.isAppExtension } // load to cache immediately _ = all do { try migrateIfNeeded() } catch { Current.Log.error("failed to load historic server: \(error)") } } public var all: [Server] { cache.mutate { cache -> [Server] in if !cache.restrictCaching, let cachedServers = cache.all { return cachedServers } else { // we sort outside the Server because that will reenter our cache lock let all = keychain.allServerInfo(decoder: decoder).sorted(by: { lhs, rhs -> Bool in lhs.1.sortOrder < rhs.1.sortOrder }).map { key, value in server(key: key, value: value, currentCache: &cache) } cache.all = all return all } } } public func server(for identifier: Identifier) -> Server? { if let fast = cache.read({ $0.server[identifier] }) { return fast } else { return all.first(where: { $0.identifier == identifier }) } } @discardableResult public func add(identifier: Identifier, serverInfo: ServerInfo) -> Server { let setValue = with(serverInfo) { if $0.sortOrder == ServerInfo.defaultSortOrder { $0.sortOrder = all.map(\.info.sortOrder).max().map { $0 + 1000 } ?? 0 } } let result = cache.mutate { cache -> Server in cache.deletedServers.remove(identifier) keychain.set(serverInfo: setValue, key: identifier.keychainKey, encoder: encoder) cache.info[identifier] = setValue cache.all = nil return server(key: identifier.keychainKey, value: setValue, currentCache: &cache) } notify() return result } public func remove(identifier: Identifier) { cache.mutate { cache in cache.deletedServers.insert(identifier) keychain.deleteServerInfo(key: identifier.keychainKey) cache.remove(identifier: identifier) } notify() } public func removeAll() { cache.mutate { cache in cache.deletedServers.formUnion(Set(keychain.allKeys().map { Identifier(keychainKey: $0) })) 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 serverInfoGetter( cache: HAProtected, keychain: ServerManagerKeychain, identifier: Identifier, decoder: JSONDecoder, fallback: ServerInfo ) -> () -> ServerInfo { { cache.mutate { cache -> ServerInfo in if !cache.restrictCaching, let info = cache.info[identifier] { return info } else { let info = keychain.getServerInfo(key: identifier.keychainKey, decoder: decoder) ?? fallback if !cache.deletedServers.contains(identifier) { cache.info[identifier] = info } return info } } } } private func serverInfoSetter( cache: HAProtected, keychain: ServerManagerKeychain, identifier: Identifier, encoder: JSONEncoder, notify: @escaping () -> Void ) -> (ServerInfo) -> Bool { { baseServerInfo in var serverInfo = baseServerInfo // update active URL so we can update just once if it's different than the save is doing // intentionally not in the lock _ = serverInfo.connection.activeURL() return cache.mutate { cache in guard !cache.deletedServers.contains(identifier) else { Current.Log.verbose("ignoring update to deleted server \(identifier)") return false } let old = cache.info[identifier] guard old != serverInfo || cache.restrictCaching else { return false } keychain.set(serverInfo: serverInfo, key: identifier.keychainKey, encoder: self.encoder) cache.info[identifier] = serverInfo if old?.sortOrder != serverInfo.sortOrder { cache.all = nil } notify() return true } } } private func server(key: String, value: ServerInfo, currentCache: inout ServerCache) -> Server { let identifier = Identifier(keychainKey: key) if let server = currentCache.server[identifier] { return server } let server = Server( identifier: identifier, getter: serverInfoGetter( cache: cache, keychain: keychain, identifier: identifier, decoder: decoder, fallback: value ), setter: serverInfoSetter( cache: cache, keychain: keychain, identifier: identifier, encoder: encoder, notify: { [weak self] in self?.notify() } ) ) currentCache.server[identifier] = server return server } private func migrateIfNeeded() throws { guard all.isEmpty else { return } let userDefaults = Current.settingsStore.prefs if let tokenInfoData = try historicKeychain.getData("tokenInfo"), let connectionInfoData = try historicKeychain.getData("connectionInfo") { Current.Log.info("migrating historic server") // 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 = try ServerInfo( name: name, connection: decoder.decode(ConnectionInfo.self, from: connectionInfoData), token: decoder.decode(TokenInfo.self, from: tokenInfoData), version: 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() } else { Current.Log.info("no historic server found to import") } } 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(rawValue: key) if let existing = cache.read({ $0.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? { do { guard let data = try getData(key) else { return nil } return try decoder.decode(ServerInfo.self, from: data) } catch { Current.Log.error("failed to get server info for \(key): \(error)") return nil } } func set(serverInfo: ServerInfo, key: String, encoder: JSONEncoder) { do { try set(encoder.encode(serverInfo), key: key) } catch { Current.Log.error("failed to set server info for \(key): \(error)") } } func deleteServerInfo(key: String) { do { try remove(key) } catch { Current.Log.error("failed to get delete \(key): \(error)") } } }