mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-16 04:16:39 -05:00
<!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> Ensure mirrorStore is kept in sync when servers are removed. ServerManagerImpl now removes per-server mirrorStore entries and updates restoredMirroredServers when removing an identifier, and clears mirrorStore and restoredMirroredServers when removeAll() is called. The allKeys set is captured before mutating the cache so mirror keys are included in deletedServers. Tests updated to assert mirrorStore is cleared and that mirror-restore state behaves as expected. ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. -->
622 lines
21 KiB
Swift
622 lines
21 KiB
Swift
import HAKit
|
|
import KeychainAccess
|
|
import UserNotifications
|
|
import Version
|
|
|
|
public protocol ServerObserver: AnyObject {
|
|
func serversDidChange(_ serverManager: ServerManager)
|
|
}
|
|
|
|
public protocol ServerManager {
|
|
var all: [Server] { get }
|
|
var isMirrorRestorePending: Bool { get }
|
|
func server(for identifier: Identifier<Server>) -> Server?
|
|
func serverOrFirstIfAvailable(for identifier: Identifier<Server>) -> Server?
|
|
@discardableResult
|
|
func restoreKeychainFromMirrorIfNeeded() -> Bool
|
|
|
|
@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 {
|
|
return server(for: .init(rawValue: rawIdentifier))
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func serverOrFirstIfAvailable(for identifier: Identifier<Server>) -> 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Cache Helpers
|
|
|
|
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<Identifier<Server>> {
|
|
get {
|
|
let identifiers = Current.settingsStore.prefs.array(forKey: "deletedServers") as? [String] ?? []
|
|
return Set(identifiers.map { Identifier<Server>(rawValue: $0) })
|
|
}
|
|
set {
|
|
Current.settingsStore.prefs.set(newValue.map(\.rawValue), forKey: "deletedServers")
|
|
}
|
|
}
|
|
|
|
var info: [Identifier<Server>: ServerInfo] = [:] {
|
|
didSet {
|
|
if !deletedServers.isDisjoint(with: info.keys) {
|
|
Current.Log
|
|
.error(
|
|
"Stale server(s) in info cache overlapping with deleted servers, info keys: \(info.keys), deleted servers: \(deletedServers)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
var server: [Identifier<Server>: 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<Server>) {
|
|
info[identifier] = nil
|
|
server[identifier] = nil
|
|
all?.removeAll(where: { $0.identifier == identifier })
|
|
}
|
|
|
|
mutating func reset() {
|
|
info = [:]
|
|
server = [:]
|
|
all = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Server Manager
|
|
|
|
final class ServerManagerImpl: ServerManager {
|
|
private static let restoredMirroredServersKey = "restoredMirroredServers"
|
|
|
|
private var keychain: ServerManagerKeychain
|
|
private var historicKeychain: ServerManagerKeychain
|
|
private var mirrorStore: ServerManagerMirrorStore
|
|
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 = HAProtected<ServerCache>(value: .init())
|
|
|
|
// MARK: Lifecycle
|
|
|
|
init(
|
|
keychain: ServerManagerKeychain = Keychain(service: ServerManagerImpl.service),
|
|
historicKeychain: ServerManagerKeychain = Keychain(service: AppConstants.BundleID),
|
|
mirrorStore: ServerManagerMirrorStore = ServerManagerGRDBMirrorStore()
|
|
) {
|
|
self.keychain = keychain
|
|
self.historicKeychain = historicKeychain
|
|
self.mirrorStore = mirrorStore
|
|
|
|
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)")
|
|
}
|
|
|
|
// Keep a sanitized startup snapshot of non-secret server metadata so the app
|
|
// can still recover server shells if the developer-account migration wipes
|
|
// Keychain data later on.
|
|
syncMirrorStoreFromKeychainIfNeeded()
|
|
}
|
|
|
|
public var all: [Server] {
|
|
let snapshot = cache.read { cache in
|
|
(
|
|
restrictCaching: cache.restrictCaching,
|
|
deletedServers: cache.deletedServers,
|
|
cachedServers: cache.all
|
|
)
|
|
}
|
|
|
|
if !snapshot.restrictCaching, let cachedServers = snapshot.cachedServers {
|
|
return cachedServers
|
|
}
|
|
|
|
// Read from Keychain and GRDB outside the cache lock so persistence I/O
|
|
// does not block unrelated server-manager operations.
|
|
let persistedServers = mergedServerInfo(deletedServers: snapshot.deletedServers)
|
|
.sorted(by: { lhs, rhs -> Bool in
|
|
lhs.1.sortOrder < rhs.1.sortOrder
|
|
})
|
|
|
|
if let cachedOrFreshServers = cache.mutate(using: { cache -> [Server]? in
|
|
if !cache.restrictCaching, let cachedServers = cache.all {
|
|
return cachedServers
|
|
}
|
|
|
|
guard cache.deletedServers == snapshot.deletedServers else {
|
|
return nil
|
|
}
|
|
|
|
let all = persistedServers.map { key, value in
|
|
server(key: key, value: value, currentCache: &cache)
|
|
}
|
|
cache.all = all
|
|
return all
|
|
}) {
|
|
return cachedOrFreshServers
|
|
}
|
|
|
|
// Avoid retrying forever when another thread keeps mutating the server set.
|
|
// In that case we return a best-effort fresh view and let a later access cache it.
|
|
let latestDeletedServers = cache.read(\.deletedServers)
|
|
let latestPersistedServers = mergedServerInfo(deletedServers: latestDeletedServers)
|
|
.sorted(by: { lhs, rhs -> Bool in
|
|
lhs.1.sortOrder < rhs.1.sortOrder
|
|
})
|
|
|
|
return cache.mutate { cache in
|
|
latestPersistedServers.map { key, value in
|
|
server(key: key, value: value, currentCache: &cache)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func server(for identifier: Identifier<Server>) -> Server? {
|
|
if let fast = cache.read({ $0.server[identifier] }) {
|
|
return fast
|
|
} else {
|
|
return all.first(where: { $0.identifier == identifier })
|
|
}
|
|
}
|
|
|
|
// MARK: Mutations
|
|
|
|
@discardableResult
|
|
public func add(identifier: Identifier<Server>, 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)
|
|
}
|
|
|
|
mirrorStore.set(setValue, key: identifier.keychainKey)
|
|
var restoredMirroredServers = restoredMirroredServers
|
|
if restoredMirroredServers.remove(identifier.keychainKey) != nil {
|
|
self.restoredMirroredServers = restoredMirroredServers
|
|
}
|
|
|
|
notify()
|
|
|
|
return result
|
|
}
|
|
|
|
public func remove(identifier: Identifier<Server>) {
|
|
cache.mutate { cache in
|
|
cache.deletedServers.insert(identifier)
|
|
keychain.deleteServerInfo(key: identifier.keychainKey)
|
|
cache.remove(identifier: identifier)
|
|
}
|
|
|
|
mirrorStore.remove(identifier.keychainKey)
|
|
var restoredMirroredServers = restoredMirroredServers
|
|
if restoredMirroredServers.remove(identifier.keychainKey) != nil {
|
|
self.restoredMirroredServers = restoredMirroredServers
|
|
}
|
|
|
|
notify()
|
|
}
|
|
|
|
public func removeAll() {
|
|
let allKeys = Set(keychain.allKeys() + mirrorStore.allKeys())
|
|
cache.mutate { cache in
|
|
cache.deletedServers.formUnion(Set(allKeys.map { Identifier<Server>(keychainKey: $0) }))
|
|
cache.reset()
|
|
_ = try? keychain.removeAll()
|
|
}
|
|
|
|
mirrorStore.removeAll()
|
|
restoredMirroredServers = []
|
|
|
|
notify()
|
|
}
|
|
|
|
// MARK: Cache and Observation
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Server Accessors
|
|
|
|
private func serverInfoGetter(
|
|
cache: HAProtected<ServerCache>,
|
|
keychain: ServerManagerKeychain,
|
|
identifier: Identifier<Server>,
|
|
decoder: JSONDecoder,
|
|
fallback: ServerInfo
|
|
) -> () -> ServerInfo {
|
|
{
|
|
cache.mutate { cache -> ServerInfo in
|
|
if !cache.restrictCaching, let info = cache.info[identifier] {
|
|
return info
|
|
} else {
|
|
// Prefer live Keychain data, but fall back to the last startup
|
|
// snapshot in GRDB when the Keychain entry is gone.
|
|
let keychainInfo = keychain.getServerInfo(key: identifier.keychainKey, decoder: decoder)
|
|
let mirroredInfo = keychainInfo == nil ? self.mirrorStore
|
|
.getServerInfo(identifier.keychainKey) : nil
|
|
let shouldUseMirrorFallback = !(keychain.allKeys().isEmpty && mirroredInfo != nil)
|
|
let info = keychainInfo
|
|
?? (shouldUseMirrorFallback ? mirroredInfo : nil)
|
|
?? fallback
|
|
if !cache.deletedServers.contains(identifier) {
|
|
cache.info[identifier] = info
|
|
}
|
|
return info
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func serverInfoSetter(
|
|
cache: HAProtected<ServerCache>,
|
|
keychain: ServerManagerKeychain,
|
|
identifier: Identifier<Server>,
|
|
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: 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<Server>(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
|
|
}
|
|
|
|
// MARK: Mirror Reconciliation
|
|
|
|
private func mergedServerInfo(deletedServers: Set<Identifier<Server>>) -> [(String, ServerInfo)] {
|
|
let keychainValues = Dictionary(uniqueKeysWithValues: keychain.allServerInfo(decoder: decoder))
|
|
guard !keychainValues.isEmpty else {
|
|
return []
|
|
}
|
|
|
|
// When both stores have a copy, prefer Keychain because it still contains the
|
|
// full record. The mirror is only a best-effort recovery snapshot.
|
|
let mirrorValues = Dictionary(uniqueKeysWithValues: mirrorStore.allServerInfo())
|
|
return mirrorValues
|
|
.merging(keychainValues, uniquingKeysWith: { _, keychainInfo in keychainInfo })
|
|
.filter { key, _ in
|
|
!deletedServers.contains(.init(keychainKey: key))
|
|
}
|
|
.map { ($0.key, $0.value) }
|
|
}
|
|
|
|
private func syncMirrorStoreFromKeychainIfNeeded() {
|
|
pruneDeletedMirroredServers()
|
|
|
|
// Preserve the mirror while the recovery UI is deciding whether to restore
|
|
// mirrored servers back into Keychain, and never let an empty Keychain wipe
|
|
// the last preserved snapshot.
|
|
if keychain.allKeys().isEmpty || isMirrorRestorePending {
|
|
return
|
|
}
|
|
|
|
syncMirrorStoreFromKeychain()
|
|
}
|
|
|
|
private func syncMirrorStoreFromKeychain() {
|
|
// The mirror is a best-effort startup snapshot, not a second source of truth.
|
|
// Rebuild it from the current Keychain contents whenever the app opens.
|
|
mirrorStore.removeAll()
|
|
for (key, value) in keychain.allServerInfo(decoder: decoder) {
|
|
mirrorStore.set(value, key: key)
|
|
}
|
|
pruneRestoredMirroredServers(validKeys: Set(mirrorStore.allKeys()))
|
|
}
|
|
|
|
private var restoredMirroredServers: Set<String> {
|
|
get {
|
|
let values = Current.settingsStore.prefs.array(forKey: Self.restoredMirroredServersKey) as? [String] ?? []
|
|
return Set(values)
|
|
}
|
|
set {
|
|
Current.settingsStore.prefs.set(Array(newValue).sorted(), forKey: Self.restoredMirroredServersKey)
|
|
}
|
|
}
|
|
|
|
private func pruneRestoredMirroredServers(validKeys: Set<String>) {
|
|
let pruned = restoredMirroredServers.intersection(validKeys)
|
|
guard pruned != restoredMirroredServers else { return }
|
|
restoredMirroredServers = pruned
|
|
}
|
|
|
|
private func pruneDeletedMirroredServers() {
|
|
let deletedKeys = Set(cache.read(\.deletedServers).map(\.keychainKey))
|
|
guard !deletedKeys.isEmpty else { return }
|
|
|
|
let mirrorKeys = Set(mirrorStore.allKeys())
|
|
let keysToRemove = mirrorKeys.intersection(deletedKeys)
|
|
guard !keysToRemove.isEmpty else { return }
|
|
|
|
for key in keysToRemove {
|
|
mirrorStore.remove(key)
|
|
}
|
|
|
|
pruneRestoredMirroredServers(validKeys: mirrorKeys.subtracting(keysToRemove))
|
|
}
|
|
|
|
private func restorableMirroredServers(excludingPreviouslyRestored: Bool = false) -> [(String, ServerInfo)] {
|
|
let deletedServers = cache.read(\.deletedServers)
|
|
let restoredMirroredServers = excludingPreviouslyRestored ? restoredMirroredServers : []
|
|
return mirrorStore.allServerInfo().filter { key, _ in
|
|
!deletedServers.contains(.init(keychainKey: key)) && !restoredMirroredServers.contains(key)
|
|
}
|
|
}
|
|
|
|
public var isMirrorRestorePending: Bool {
|
|
keychain.allKeys().isEmpty && !restorableMirroredServers(excludingPreviouslyRestored: true).isEmpty
|
|
}
|
|
|
|
@discardableResult
|
|
public func restoreKeychainFromMirrorIfNeeded() -> Bool {
|
|
guard keychain.allKeys().isEmpty else { return false }
|
|
|
|
let mirroredServers = restorableMirroredServers(excludingPreviouslyRestored: true)
|
|
guard !mirroredServers.isEmpty else { return false }
|
|
|
|
// Rehydrate the Keychain with the sanitized mirror so startup sees the same
|
|
// server list and WebView can continue through the empty-token reauth flow.
|
|
for (key, value) in mirroredServers {
|
|
keychain.set(serverInfo: value, key: key, encoder: encoder)
|
|
}
|
|
var restoredMirroredServers = restoredMirroredServers
|
|
restoredMirroredServers.formUnion(mirroredServers.map(\.0))
|
|
self.restoredMirroredServers = restoredMirroredServers
|
|
|
|
cache.mutate { cache in
|
|
cache.reset()
|
|
}
|
|
|
|
notify()
|
|
return true
|
|
}
|
|
|
|
// MARK: Migration
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// MARK: State Restoration
|
|
|
|
public func restorableState() -> Data {
|
|
var state = [String: ServerInfo]()
|
|
|
|
for (id, info) in mergedServerInfo(deletedServers: cache.read({ $0.deletedServers })) {
|
|
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 Set(keychain.allKeys() + mirrorStore.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.read({ $0.server[identifier] }) {
|
|
existing.info = serverInfo
|
|
} else {
|
|
add(identifier: identifier, serverInfo: serverInfo)
|
|
}
|
|
}
|
|
} catch {
|
|
Current.Log.error(error)
|
|
}
|
|
|
|
suppressNotify = false
|
|
notify()
|
|
}
|
|
}
|