diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index f8afef882..aca712d75 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -44,6 +44,10 @@ 1104FD06253292CD00B8BE34 /* Guarantee+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1104FD04253292CD00B8BE34 /* Guarantee+Additions.swift */; }; 1105CE1C272B9CB300F33BD8 /* ServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1105CE1B272B9CB300F33BD8 /* ServerManager.swift */; }; 1105CE1D272B9CB300F33BD8 /* ServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1105CE1B272B9CB300F33BD8 /* ServerManager.swift */; }; + 42C95E822F7EDAAA00112233 /* ServerManagerPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C95E812F7EDAAA00112233 /* ServerManagerPersistence.swift */; }; + 42C95E832F7EDAAA00112233 /* ServerManagerPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C95E812F7EDAAA00112233 /* ServerManagerPersistence.swift */; }; + 42C95E852F7EDB1000112233 /* ServerManagerMirrorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C95E842F7EDB1000112233 /* ServerManagerMirrorStore.swift */; }; + 42C95E862F7EDB1000112233 /* ServerManagerMirrorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C95E842F7EDB1000112233 /* ServerManagerMirrorStore.swift */; }; 1108BC4325A2FB5A006B3C83 /* MacBridgeAppDelegateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1108BC4225A2FB5A006B3C83 /* MacBridgeAppDelegateHandler.swift */; }; 1109F81F24A1C011002590F2 /* SensorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1109F81E24A1C011002590F2 /* SensorProvider.swift */; }; 1109F82024A1C011002590F2 /* SensorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1109F81E24A1C011002590F2 /* SensorProvider.swift */; }; @@ -839,6 +843,8 @@ 42790C472C4809DD00E31B38 /* UIScreen+PerfectCornerRadius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BE698E2C46D37800745ECA /* UIScreen+PerfectCornerRadius.swift */; }; 4279407F2B8369EC001D7E14 /* AppIntentVocabulary.plist in Resources */ = {isa = PBXBuildFile; fileRef = 42805A142B0226050095414C /* AppIntentVocabulary.plist */; }; 427940812B836A1A001D7E14 /* AppIntentVocabulary.plist in Resources */ = {isa = PBXBuildFile; fileRef = 42805A142B0226050095414C /* AppIntentVocabulary.plist */; }; + 427A21772F7EA57600BD40B7 /* ServerInfoMirrorTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427A21762F7EA57600BD40B7 /* ServerInfoMirrorTable.swift */; }; + 427A21782F7EA57600BD40B7 /* ServerInfoMirrorTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427A21762F7EA57600BD40B7 /* ServerInfoMirrorTable.swift */; }; 427A7CD92EBDFB1700D17841 /* AppArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427A7CD82EBDFB1700D17841 /* AppArea.swift */; }; 427A7CDA2EBDFB1700D17841 /* AppArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427A7CD82EBDFB1700D17841 /* AppArea.swift */; }; 427A7CDC2EBDFB1D00D17841 /* AppAreaTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427A7CDB2EBDFB1D00D17841 /* AppAreaTable.swift */; }; @@ -1867,6 +1873,8 @@ 1104FCCE253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchBackgroundRefreshScheduler.test.swift; sourceTree = ""; }; 1104FD04253292CD00B8BE34 /* Guarantee+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Guarantee+Additions.swift"; sourceTree = ""; }; 1105CE1B272B9CB300F33BD8 /* ServerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManager.swift; sourceTree = ""; }; + 42C95E812F7EDAAA00112233 /* ServerManagerPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManagerPersistence.swift; sourceTree = ""; }; + 42C95E842F7EDB1000112233 /* ServerManagerMirrorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManagerMirrorStore.swift; sourceTree = ""; }; 1108BC4225A2FB5A006B3C83 /* MacBridgeAppDelegateHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacBridgeAppDelegateHandler.swift; sourceTree = ""; }; 1109B6BA25263EEE005D51C2 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Intents.strings; sourceTree = ""; }; 1109B6BC25263EEF005D51C2 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2547,6 +2555,7 @@ 42790C432C48077200E31B38 /* ImprovSuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImprovSuccessView.swift; sourceTree = ""; }; 42790C452C4808FA00E31B38 /* AppleLikeBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleLikeBottomSheet.swift; sourceTree = ""; }; 4279407E2B8369EA001D7E14 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = bg; path = bg.lproj/AppIntentVocabulary.plist; sourceTree = ""; }; + 427A21762F7EA57600BD40B7 /* ServerInfoMirrorTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoMirrorTable.swift; sourceTree = ""; }; 427A7CD82EBDFB1700D17841 /* AppArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppArea.swift; sourceTree = ""; }; 427A7CDB2EBDFB1D00D17841 /* AppAreaTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAreaTable.swift; sourceTree = ""; }; 427A7CDE2EBDFB4200D17841 /* AppArea+Queries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppArea+Queries.swift"; sourceTree = ""; }; @@ -4724,6 +4733,7 @@ 420CFC622D3F9C1F009A94F3 /* Tables */ = { isa = PBXGroup; children = ( + 427A21762F7EA57600BD40B7 /* ServerInfoMirrorTable.swift */, 420CFC632D3F9C2C009A94F3 /* HAppEntityTable.swift */, 420CFC832D3FECF6009A94F3 /* CustomWidgetTable.swift */, 420CFC672D3F9C40009A94F3 /* WatchConfigTable.swift */, @@ -7083,6 +7093,8 @@ D05A4D31216DD206009FD1EB /* MJPEGStreamer.swift */, 11195F6C267EFC15003DF674 /* HACancellable+App.swift */, 1105CE1B272B9CB300F33BD8 /* ServerManager.swift */, + 42C95E812F7EDAAA00112233 /* ServerManagerPersistence.swift */, + 42C95E842F7EDB1000112233 /* ServerManagerMirrorStore.swift */, 11CFD78027364F450082D557 /* Identifier.swift */, 11CFD783273662DF0082D557 /* Server.swift */, 1120C57E274638330046C38B /* PerServerContainer.swift */, @@ -9784,6 +9796,7 @@ 119DE934263325C20099F7D8 /* IconDrawable+Settings.swift in Sources */, 42FF5E972E22E5C100BDF5EF /* TodoListItem.swift in Sources */, 114CBAE92839E49E00A9BAFF /* CustomServerTrustManager.swift in Sources */, + 427A21782F7EA57600BD40B7 /* ServerInfoMirrorTable.swift in Sources */, 4251AABE2C6CE242004CCC9D /* MagicItemProvider.swift in Sources */, 42D3E4BE2C5D31E000444BE6 /* LocalNotificationDispatcher.swift in Sources */, 420AE9E12CA559FE0020E9CB /* Color+hex.swift in Sources */, @@ -9911,6 +9924,8 @@ B67CE8A622200F220034C1D0 /* HAAPI.swift in Sources */, 42B2637E2E16A1DC0042DF10 /* BaseColors.swift in Sources */, 1105CE1D272B9CB300F33BD8 /* ServerManager.swift in Sources */, + 42C95E832F7EDAAA00112233 /* ServerManagerPersistence.swift in Sources */, + 42C95E862F7EDB1000112233 /* ServerManagerMirrorStore.swift in Sources */, 42D334272D105990008D8E78 /* AppPanel.swift in Sources */, 1141182724AF9A0500E6525C /* WebhookManager.swift in Sources */, 1104FC9225322C1800B8BE34 /* Dictionary+Additions.swift in Sources */, @@ -9966,6 +9981,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 427A21772F7EA57600BD40B7 /* ServerInfoMirrorTable.swift in Sources */, 42462E722DA4114800ECC8A7 /* Sizes.swift in Sources */, 118F046924CB895A00CBBD5C /* UIColor+CSS3+Hex.swift in Sources */, 1109F81F24A1C011002590F2 /* SensorProvider.swift in Sources */, @@ -10257,6 +10273,8 @@ 11EE9B5424C62EB300404AF8 /* RealmScene.swift in Sources */, 11F2F26E25871D8200F61F7C /* NotificationAttachmentParserURL.swift in Sources */, 1105CE1C272B9CB300F33BD8 /* ServerManager.swift in Sources */, + 42C95E822F7EDAAA00112233 /* ServerManagerPersistence.swift in Sources */, + 42C95E852F7EDB1000112233 /* ServerManagerMirrorStore.swift in Sources */, D0EEF322214DE56B00D1D360 /* LocationTrigger.swift in Sources */, 422368902D40FCDE005911E4 /* CustomWidget.swift in Sources */, D0B25BD62133128800678C2C /* UNNotificationContent+ClientEvent.swift in Sources */, diff --git a/Sources/Shared/API/Server.swift b/Sources/Shared/API/Server.swift index 97bd8818a..39511537a 100644 --- a/Sources/Shared/API/Server.swift +++ b/Sources/Shared/API/Server.swift @@ -223,6 +223,31 @@ public struct ServerInfo: Codable, Equatable { } } +extension ServerInfo { + // Used in the GRDB mirror so recovered servers have an explicit "no credentials" + // state instead of accidentally persisting real auth tokens outside Keychain. + static var mirrorPlaceholderToken: TokenInfo { + .init(accessToken: "", refreshToken: "", expiration: .distantPast) + } + + // Webhook-based features must stay disabled for mirrored servers because the + // webhook path itself is effectively a credential. + static var mirrorPlaceholderWebhookID: String { "" } + + var mirroredForPersistence: ServerInfo { + // Start from the full server info, then remove secrets before writing to GRDB. + var info = self + // The GRDB mirror is only for recovering non-secret server metadata if Keychain + // entries disappear during the developer-account migration. + info.token = Self.mirrorPlaceholderToken + info.connection.cloudhookURL = nil + info.connection.webhookID = Self.mirrorPlaceholderWebhookID + info.connection.webhookSecret = nil + info.connection.clientCertificate = nil + return info + } +} + public final class Server: Hashable, Comparable, CustomStringConvertible { public static let historicId: Identifier = "historic" diff --git a/Sources/Shared/API/ServerManager.swift b/Sources/Shared/API/ServerManager.swift index feccc6bec..df25dd4d5 100644 --- a/Sources/Shared/API/ServerManager.swift +++ b/Sources/Shared/API/ServerManager.swift @@ -1,6 +1,5 @@ import HAKit import KeychainAccess -import Sodium import UserNotifications import Version @@ -82,13 +81,7 @@ public extension ServerManager { } } -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 -} +// MARK: - Cache Helpers private extension Identifier where ObjectType == Server { var keychainKey: String { rawValue } @@ -139,23 +132,12 @@ private struct ServerCache { } } -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) - } -} +// MARK: - Server Manager final class ServerManagerImpl: ServerManager { private var keychain: ServerManagerKeychain private var historicKeychain: ServerManagerKeychain + private var mirrorStore: ServerManagerMirrorStore private var encoder: JSONEncoder private var decoder: JSONDecoder @@ -173,12 +155,16 @@ final class ServerManagerImpl: ServerManager { private let cache = HAProtected(value: .init()) + // MARK: Lifecycle + init( keychain: ServerManagerKeychain = Keychain(service: ServerManagerImpl.service), - historicKeychain: ServerManagerKeychain = Keychain(service: AppConstants.BundleID) + historicKeychain: ServerManagerKeychain = Keychain(service: AppConstants.BundleID), + mirrorStore: ServerManagerMirrorStore = ServerManagerGRDBMirrorStore() ) { self.keychain = keychain self.historicKeychain = historicKeychain + self.mirrorStore = mirrorStore let encoder = JSONEncoder() self.encoder = encoder @@ -199,22 +185,52 @@ final class ServerManagerImpl: ServerManager { } catch { Current.Log.error("failed to load historic server: \(error)") } + + // Keep a sanitized copy of non-secret server metadata so the app can still + // recover server shells if the developer-account migration wipes Keychain data. + syncMirrorStoreFromKeychain() } public var all: [Server] { - cache.mutate { cache -> [Server] in - if !cache.restrictCaching, let cachedServers = cache.all { + while true { + let snapshot = cache.read { cache in + ( + restrictCaching: cache.restrictCaching, + deletedServers: cache.deletedServers, + cachedServers: cache.all + ) + } + + if !snapshot.restrictCaching, let cachedServers = snapshot.cachedServers { 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 + } + + // 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 - }).map { key, value in + }) + + let result = cache.mutate { 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 } + + if let result { + return result + } } } @@ -226,6 +242,8 @@ final class ServerManagerImpl: ServerManager { } } + // MARK: Mutations + @discardableResult public func add(identifier: Identifier, serverInfo: ServerInfo) -> Server { let setValue = with(serverInfo) { @@ -236,7 +254,7 @@ final class ServerManagerImpl: ServerManager { let result = cache.mutate { cache -> Server in cache.deletedServers.remove(identifier) - keychain.set(serverInfo: setValue, key: identifier.keychainKey, encoder: encoder) + persistServerInfo(setValue, for: identifier) cache.info[identifier] = setValue cache.all = nil @@ -251,7 +269,7 @@ final class ServerManagerImpl: ServerManager { public func remove(identifier: Identifier) { cache.mutate { cache in cache.deletedServers.insert(identifier) - keychain.deleteServerInfo(key: identifier.keychainKey) + deletePersistedServerInfo(for: identifier) cache.remove(identifier: identifier) } @@ -260,14 +278,18 @@ final class ServerManagerImpl: ServerManager { public func removeAll() { cache.mutate { cache in - cache.deletedServers.formUnion(Set(keychain.allKeys().map { Identifier(keychainKey: $0) })) + let allKeys = Set(keychain.allKeys() + mirrorStore.allKeys()) + cache.deletedServers.formUnion(Set(allKeys.map { Identifier(keychainKey: $0) })) cache.reset() _ = try? keychain.removeAll() + mirrorStore.removeAll() } notify() } + // MARK: Cache and Observation + private var suppressNotify = false private func notify() { guard !suppressNotify else { return } @@ -278,6 +300,8 @@ final class ServerManagerImpl: ServerManager { } } + // MARK: Server Accessors + private func serverInfoGetter( cache: HAProtected, keychain: ServerManagerKeychain, @@ -290,7 +314,11 @@ final class ServerManagerImpl: ServerManager { if !cache.restrictCaching, let info = cache.info[identifier] { return info } else { - let info = keychain.getServerInfo(key: identifier.keychainKey, decoder: decoder) ?? fallback + // Prefer live Keychain data, but fall back to the GRDB mirror when + // the Keychain entry is gone and we still need to recover the server. + let info = keychain.getServerInfo(key: identifier.keychainKey, decoder: decoder) + ?? self.mirrorStore.getServerInfo(identifier.keychainKey) + ?? fallback if !cache.deletedServers.contains(identifier) { cache.info[identifier] = info } @@ -326,7 +354,7 @@ final class ServerManagerImpl: ServerManager { return false } - keychain.set(serverInfo: serverInfo, key: identifier.keychainKey, encoder: self.encoder) + self.persistServerInfo(serverInfo, for: identifier) cache.info[identifier] = serverInfo if old?.sortOrder != serverInfo.sortOrder { @@ -366,6 +394,43 @@ final class ServerManagerImpl: ServerManager { return server } + // MARK: Mirror Persistence + + private func persistServerInfo(_ serverInfo: ServerInfo, for identifier: Identifier) { + // Keychain remains the source of truth for the full record, while GRDB stores + // a sanitized mirror that can survive developer-account keychain changes. + keychain.set(serverInfo: serverInfo, key: identifier.keychainKey, encoder: encoder) + mirrorStore.set(serverInfo, key: identifier.keychainKey) + } + + private func deletePersistedServerInfo(for identifier: Identifier) { + keychain.deleteServerInfo(key: identifier.keychainKey) + mirrorStore.remove(identifier.keychainKey) + } + + // MARK: Mirror Reconciliation + + private func mergedServerInfo(deletedServers: Set>) -> [(String, ServerInfo)] { + // When both stores have a copy, prefer Keychain because it still contains the + // full record. The mirror only exists for non-secret recovery. + let keychainValues = Dictionary(uniqueKeysWithValues: keychain.allServerInfo(decoder: decoder)) + 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 syncMirrorStoreFromKeychain() { + for (key, value) in keychain.allServerInfo(decoder: decoder) { + mirrorStore.set(value, key: key) + } + } + + // MARK: Migration + private func migrateIfNeeded() throws { guard all.isEmpty else { return } @@ -396,10 +461,12 @@ final class ServerManagerImpl: ServerManager { } } + // MARK: State Restoration + public func restorableState() -> Data { var state = [String: ServerInfo]() - for (id, info) in keychain.allServerInfo(decoder: decoder) { + for (id, info) in mergedServerInfo(deletedServers: cache.read({ $0.deletedServers })) { state[id] = info } @@ -418,7 +485,7 @@ final class ServerManagerImpl: ServerManager { 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 { + for key in Set(keychain.allKeys() + mirrorStore.allKeys()) where state[key] == nil { remove(identifier: .init(keychainKey: key)) } @@ -439,40 +506,3 @@ final class ServerManagerImpl: ServerManager { 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)") - } - } -} diff --git a/Sources/Shared/API/ServerManagerMirrorStore.swift b/Sources/Shared/API/ServerManagerMirrorStore.swift new file mode 100644 index 000000000..4789c2a92 --- /dev/null +++ b/Sources/Shared/API/ServerManagerMirrorStore.swift @@ -0,0 +1,73 @@ +import Foundation +import GRDB + +private struct ServerInfoMirrorRecord: Codable, FetchableRecord, PersistableRecord { + static var databaseTableName: String { GRDBDatabaseTable.serverInfoMirror.rawValue } + + var id: String + var serverInfoJSON: ServerInfo +} + +// Stores a sanitized mirror of server metadata in GRDB so the app can recover the +// server list even if Keychain data is lost during the developer-account migration. +final class ServerManagerGRDBMirrorStore: ServerManagerMirrorStore { + func removeAll() { + do { + try Current.database().write { db in + _ = try ServerInfoMirrorRecord.deleteAll(db) + } + } catch { + Current.Log.error("failed to clear mirrored server info: \(error)") + } + } + + func allKeys() -> [String] { + allServerInfo().map(\.0) + } + + func allServerInfo() -> [(String, ServerInfo)] { + do { + return try Current.database().read { db in + try ServerInfoMirrorRecord + .fetchAll(db) + .map { ($0.id, $0.serverInfoJSON) } + } + } catch { + Current.Log.error("failed to fetch mirrored server info: \(error)") + return [] + } + } + + func getServerInfo(_ key: String) -> ServerInfo? { + do { + return try Current.database().read { db in + try ServerInfoMirrorRecord.fetchOne(db, key: key)?.serverInfoJSON + } + } catch { + Current.Log.error("failed to fetch mirrored server info for \(key): \(error)") + return nil + } + } + + func set(_ serverInfo: ServerInfo, key: String) { + let record = ServerInfoMirrorRecord(id: key, serverInfoJSON: serverInfo.mirroredForPersistence) + + do { + try Current.database().write { db in + try record.insert(db, onConflict: .replace) + } + } catch { + Current.Log.error("failed to persist mirrored server info for \(key): \(error)") + } + } + + func remove(_ key: String) { + do { + try Current.database().write { db in + _ = try ServerInfoMirrorRecord.deleteOne(db, key: key) + } + } catch { + Current.Log.error("failed to delete mirrored server info for \(key): \(error)") + } + } +} diff --git a/Sources/Shared/API/ServerManagerPersistence.swift b/Sources/Shared/API/ServerManagerPersistence.swift new file mode 100644 index 000000000..16cabc99e --- /dev/null +++ b/Sources/Shared/API/ServerManagerPersistence.swift @@ -0,0 +1,74 @@ +import Foundation +import KeychainAccess + +// These protocols let ServerManager keep the core lifecycle logic independent from +// the concrete persistence backends used for the full record and the sanitized mirror. +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 +} + +protocol ServerManagerMirrorStore { + func removeAll() + func allKeys() -> [String] + func allServerInfo() -> [(String, ServerInfo)] + func getServerInfo(_ key: String) -> ServerInfo? + func set(_ serverInfo: ServerInfo, key: String) + func remove(_ key: String) +} + +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) + } +} + +extension ServerManagerKeychain { + func allServerInfo(decoder: JSONDecoder) -> [(String, ServerInfo)] { + allKeys().compactMap { key in + getServerInfo(key: key, decoder: decoder).map { (key, $0) } + } + } + + // Decode failures are logged and ignored so one bad Keychain entry does not + // prevent the rest of the server list from loading. + 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 delete server info for \(key): \(error.localizedDescription)") + } + } +} diff --git a/Sources/Shared/Database/DatabaseTables.swift b/Sources/Shared/Database/DatabaseTables.swift index 918690de7..381fb83d7 100644 --- a/Sources/Shared/Database/DatabaseTables.swift +++ b/Sources/Shared/Database/DatabaseTables.swift @@ -5,6 +5,7 @@ public enum GRDBDatabaseTable: String { case watchConfig case assistPipelines case carPlayConfig + case serverInfoMirror case appEntityRegistryListForDisplay case entityRegistry case deviceRegistry @@ -53,6 +54,11 @@ public enum DatabaseTables { case quickAccessLayout } + public enum ServerInfoMirror: String, CaseIterable { + case id + case serverInfoJSON + } + // Table where it is store frontend related values such as // precision for sensors public enum AppEntityRegistryListForDisplay: String, CaseIterable { diff --git a/Sources/Shared/Database/GRDB+Initialization.swift b/Sources/Shared/Database/GRDB+Initialization.swift index 05cf340ce..01bd4d0a1 100644 --- a/Sources/Shared/Database/GRDB+Initialization.swift +++ b/Sources/Shared/Database/GRDB+Initialization.swift @@ -48,6 +48,7 @@ public extension DatabaseQueue { WatchConfigTable(), CarPlayConfigTable(), AssistPipelinesTable(), + ServerInfoMirrorTable(), AppEntityRegistryListForDisplayTable(), AppEntityRegistryTable(), AppDeviceRegistryTable(), diff --git a/Sources/Shared/Database/Tables/ServerInfoMirrorTable.swift b/Sources/Shared/Database/Tables/ServerInfoMirrorTable.swift new file mode 100644 index 000000000..a61c82fb2 --- /dev/null +++ b/Sources/Shared/Database/Tables/ServerInfoMirrorTable.swift @@ -0,0 +1,24 @@ +import Foundation +import GRDB + +final class ServerInfoMirrorTable: DatabaseTableProtocol { + var tableName: String { GRDBDatabaseTable.serverInfoMirror.rawValue } + + var definedColumns: [String] { DatabaseTables.ServerInfoMirror.allCases.map(\.rawValue) } + + func createIfNeeded(database: DatabaseQueue) throws { + let shouldCreateTable = try database.read { db in + try !db.tableExists(tableName) + } + if shouldCreateTable { + try database.write { db in + try db.create(table: tableName) { t in + t.primaryKey(DatabaseTables.ServerInfoMirror.id.rawValue, .text).notNull() + t.column(DatabaseTables.ServerInfoMirror.serverInfoJSON.rawValue, .jsonText).notNull() + } + } + } else { + try migrateColumns(database: database) + } + } +} diff --git a/Tests/Shared/Database/DatabaseTableProtocol.test.swift b/Tests/Shared/Database/DatabaseTableProtocol.test.swift index 4321c30bf..5665b08c9 100644 --- a/Tests/Shared/Database/DatabaseTableProtocol.test.swift +++ b/Tests/Shared/Database/DatabaseTableProtocol.test.swift @@ -143,10 +143,20 @@ struct DatabaseTableProtocolTests { #expect(Set(table.definedColumns) == Set(expectedColumns)) } - @Test("All 14 tables conform to DatabaseTableProtocol") + @Test("ServerInfoMirrorTable conforms to DatabaseTableProtocol") + func serverInfoMirrorTableConformance() throws { + let table = ServerInfoMirrorTable() + #expect(table.tableName == GRDBDatabaseTable.serverInfoMirror.rawValue) + #expect(!table.definedColumns.isEmpty, "definedColumns should not be empty") + + let expectedColumns = DatabaseTables.ServerInfoMirror.allCases.map(\.rawValue) + #expect(Set(table.definedColumns) == Set(expectedColumns)) + } + + @Test("All 15 tables conform to DatabaseTableProtocol") func allTablesConformToProtocol() throws { let tables = DatabaseQueue.tables() - #expect(tables.count == 14, "Should have exactly 14 tables") + #expect(tables.count == 15, "Should have exactly 15 tables") for table in tables { // Verify each table has a non-empty tableName diff --git a/Tests/Shared/Database/GRDB+Initialization.test.swift b/Tests/Shared/Database/GRDB+Initialization.test.swift index 1fe05c5cf..bd4d51c8a 100644 --- a/Tests/Shared/Database/GRDB+Initialization.test.swift +++ b/Tests/Shared/Database/GRDB+Initialization.test.swift @@ -27,10 +27,10 @@ struct GRDBInitializationTests { ) } - @Test("Tables returns exactly 14 tables") - func tablesReturns14Tables() throws { + @Test("Tables returns exactly 15 tables") + func tablesReturns15Tables() throws { let tables = DatabaseQueue.tables() - #expect(tables.count == 14, "DatabaseQueue.tables() should return exactly 14 tables") + #expect(tables.count == 15, "DatabaseQueue.tables() should return exactly 15 tables") } @Test("Tables contains all expected table names") @@ -44,6 +44,7 @@ struct GRDBInitializationTests { GRDBDatabaseTable.watchConfig.rawValue, GRDBDatabaseTable.carPlayConfig.rawValue, GRDBDatabaseTable.assistPipelines.rawValue, + GRDBDatabaseTable.serverInfoMirror.rawValue, GRDBDatabaseTable.appEntityRegistryListForDisplay.rawValue, GRDBDatabaseTable.entityRegistry.rawValue, GRDBDatabaseTable.deviceRegistry.rawValue, @@ -53,6 +54,7 @@ struct GRDBInitializationTests { GRDBDatabaseTable.homeViewConfiguration.rawValue, GRDBDatabaseTable.cameraListConfiguration.rawValue, GRDBDatabaseTable.assistConfiguration.rawValue, + GRDBDatabaseTable.kioskSettings.rawValue, ] for expectedName in expectedTableNames { diff --git a/Tests/Shared/Database/TableSchemaTests.test.swift b/Tests/Shared/Database/TableSchemaTests.test.swift index 6053f532f..5a37b806b 100644 --- a/Tests/Shared/Database/TableSchemaTests.test.swift +++ b/Tests/Shared/Database/TableSchemaTests.test.swift @@ -84,6 +84,17 @@ struct TableSchemaTests { ) } + @Test("ServerInfoMirrorTable schema validation") + func serverInfoMirrorTableSchema() throws { + let table = ServerInfoMirrorTable() + let expectedColumns = DatabaseTables.ServerInfoMirror.allCases.map(\.rawValue) + try verifyTableSchema( + table: table, + expectedTableName: GRDBDatabaseTable.serverInfoMirror.rawValue, + expectedColumns: expectedColumns + ) + } + @Test("AssistPipelinesTable schema validation") func assistPipelinesTableSchema() throws { let table = AssistPipelinesTable() @@ -198,13 +209,13 @@ struct TableSchemaTests { ) } - @Test("All 14 tables create successfully together") + @Test("All 15 tables create successfully together") func allTablesCreateTogether() throws { let database = try DatabaseQueue(path: ":memory:") let tables = DatabaseQueue.tables() - // Verify we have exactly 14 tables - #expect(tables.count == 14, "Should have exactly 14 tables, but found \(tables.count)") + // Verify we have exactly 15 tables + #expect(tables.count == 15, "Should have exactly 15 tables, but found \(tables.count)") // Create all tables for table in tables { diff --git a/Tests/Shared/ServerManager.test.swift b/Tests/Shared/ServerManager.test.swift index 883628d51..af4c8f188 100644 --- a/Tests/Shared/ServerManager.test.swift +++ b/Tests/Shared/ServerManager.test.swift @@ -6,6 +6,7 @@ class ServerManagerTests: XCTestCase { private var encoder: JSONEncoder! private var keychain: FakeServerManagerKeychain! private var historicKeychain: FakeServerManagerKeychain! + private var mirrorStore: FakeServerManagerMirrorStore! private var servers: ServerManagerImpl! override func setUp() { @@ -14,6 +15,7 @@ class ServerManagerTests: XCTestCase { encoder = .init() keychain = .init() historicKeychain = .init() + mirrorStore = .init() Current.settingsStore.prefs.removeObject(forKey: "deletedServers") } @@ -25,7 +27,7 @@ class ServerManagerTests: XCTestCase { try keychain.set(encoder.encode(value), key: key) } - servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain) + servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore) servers.setup() } @@ -122,6 +124,7 @@ class ServerManagerTests: XCTestCase { try XCTAssertEqual(keychain.getData("fake1")?.count, encoder.encode(server1.info).count) try XCTAssertEqual(keychain.getData("fake2")?.count, encoder.encode(server2.info).count) XCTAssertEqual(keychain.data.count, 2) + XCTAssertEqual(Set(mirrorStore.data.keys), Set(["fake1", "fake2"])) let stateS1S2 = servers.restorableState() @@ -135,6 +138,7 @@ class ServerManagerTests: XCTestCase { servers.remove(identifier: "fake1") } try XCTAssertNil(keychain.getData("fake1")) + XCTAssertNil(mirrorStore.data["fake1"]) // grab it, which may also side-effect insert into cache, if buggy _ = server1.info @@ -176,6 +180,7 @@ class ServerManagerTests: XCTestCase { XCTAssertNil(servers.server(for: "fake2")) XCTAssertNil(servers.server(for: "fake3")) XCTAssertTrue(keychain.data.isEmpty) + XCTAssertTrue(mirrorStore.data.isEmpty) expectingObserver { servers.restoreState(stateS2S3) @@ -454,6 +459,72 @@ class ServerManagerTests: XCTestCase { XCTAssertEqual(keychain.data[server1.identifier.rawValue]?.count, try encoder.encode(newInfo).count) } + func testSetupBackfillsMirrorForExistingKeychainServers() throws { + let info = with(ServerInfo.fake()) { + $0.connection.cloudhookURL = URL(string: "https://hooks.nabu.casa/webhook-id") + $0.connection.clientCertificate = ClientCertificate( + keychainIdentifier: "client-cert-1", + displayName: "Client Certificate" + ) + } + + try keychain.set(encoder.encode(info), key: "fake1") + + servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore) + servers.setup() + + let mirrored = try XCTUnwrap(mirrorStore.data["fake1"]) + XCTAssertEqual(mirrored.remoteName, info.remoteName) + XCTAssertEqual(mirrored.connection.webhookID, ServerInfo.mirrorPlaceholderWebhookID) + XCTAssertEqual(mirrored.connection.isLocalPushEnabled, info.connection.isLocalPushEnabled) + XCTAssertNil(mirrored.connection.cloudhookURL) + XCTAssertNil(mirrored.connection.webhookSecret) + XCTAssertEqual(mirrored.token, ServerInfo.mirrorPlaceholderToken) + XCTAssertNil(mirrored.connection.clientCertificate) + } + + func testMirrorFallbackRestoresServersWithoutSecrets() throws { + let info = with(ServerInfo.fake()) { + $0.connection.cloudhookURL = URL(string: "https://hooks.nabu.casa/webhook-id") + $0.connection.clientCertificate = ClientCertificate( + keychainIdentifier: "client-cert-1", + displayName: "Client Certificate" + ) + $0.connection.webhookSecret = "webhook_secret" + $0.hassDeviceId = "device-1" + } + + mirrorStore.set(info, key: "fake1") + servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore) + servers.setup() + + let restored = try XCTUnwrap(servers.server(for: "fake1")) + XCTAssertEqual(restored.info.remoteName, info.remoteName) + XCTAssertEqual(restored.info.hassDeviceId, info.hassDeviceId) + XCTAssertEqual(restored.info.connection.webhookID, ServerInfo.mirrorPlaceholderWebhookID) + XCTAssertEqual(restored.info.connection.isLocalPushEnabled, info.connection.isLocalPushEnabled) + XCTAssertNil(restored.info.connection.cloudhookURL) + XCTAssertNil(restored.info.connection.webhookSecret) + XCTAssertEqual(restored.info.token, ServerInfo.mirrorPlaceholderToken) + XCTAssertNil(restored.info.connection.clientCertificate) + } + + func testKeychainInfoWinsOverMirrorFallback() throws { + let keychainInfo = with(ServerInfo.fake()) { + $0.remoteName = "Keychain" + } + let mirroredInfo = with(ServerInfo.fake()) { + $0.remoteName = "Mirror" + } + + try keychain.set(encoder.encode(keychainInfo), key: "fake1") + mirrorStore.set(mirroredInfo, key: "fake1") + servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore) + servers.setup() + + XCTAssertEqual(servers.server(for: "fake1")?.info.remoteName, "Keychain") + } + func testThreadsafeChangesWithoutCaching() throws { Current.isAppExtension = true try base_testThreadsafeChanges() @@ -552,7 +623,7 @@ class ServerManagerTests: XCTestCase { Current.settingsStore.prefs.set(overrideDeviceName, forKey: "override_device_name") Current.settingsStore.prefs.set(locationName, forKey: "location_name") - servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain) + servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore) servers.setup() return .init(connectionInfo: connectionInfo, tokenInfo: tokenInfo) @@ -634,6 +705,34 @@ class FakeServerManagerKeychain: ServerManagerKeychain { } } +class FakeServerManagerMirrorStore: ServerManagerMirrorStore { + var data = [String: ServerInfo]() + + func removeAll() { + data.removeAll() + } + + func allKeys() -> [String] { + Array(data.keys) + } + + func allServerInfo() -> [(String, ServerInfo)] { + Array(data) + } + + func getServerInfo(_ key: String) -> ServerInfo? { + data[key] + } + + func set(_ serverInfo: ServerInfo, key: String) { + data[key] = serverInfo.mirroredForPersistence + } + + func remove(_ key: String) { + data.removeValue(forKey: key) + } +} + private struct FakeServerIdentifierProviding: ServerIdentifierProviding { var serverIdentifier: String }