Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1867,6 +1873,8 @@
1104FCCE253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchBackgroundRefreshScheduler.test.swift; sourceTree = "<group>"; };
1104FD04253292CD00B8BE34 /* Guarantee+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Guarantee+Additions.swift"; sourceTree = "<group>"; };
1105CE1B272B9CB300F33BD8 /* ServerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManager.swift; sourceTree = "<group>"; };
42C95E812F7EDAAA00112233 /* ServerManagerPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManagerPersistence.swift; sourceTree = "<group>"; };
42C95E842F7EDB1000112233 /* ServerManagerMirrorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManagerMirrorStore.swift; sourceTree = "<group>"; };
1108BC4225A2FB5A006B3C83 /* MacBridgeAppDelegateHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacBridgeAppDelegateHandler.swift; sourceTree = "<group>"; };
1109B6BA25263EEE005D51C2 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Intents.strings; sourceTree = "<group>"; };
1109B6BC25263EEF005D51C2 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/InfoPlist.strings; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2547,6 +2555,7 @@
42790C432C48077200E31B38 /* ImprovSuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImprovSuccessView.swift; sourceTree = "<group>"; };
42790C452C4808FA00E31B38 /* AppleLikeBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleLikeBottomSheet.swift; sourceTree = "<group>"; };
4279407E2B8369EA001D7E14 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = bg; path = bg.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
427A21762F7EA57600BD40B7 /* ServerInfoMirrorTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoMirrorTable.swift; sourceTree = "<group>"; };
427A7CD82EBDFB1700D17841 /* AppArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppArea.swift; sourceTree = "<group>"; };
427A7CDB2EBDFB1D00D17841 /* AppAreaTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAreaTable.swift; sourceTree = "<group>"; };
427A7CDE2EBDFB4200D17841 /* AppArea+Queries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppArea+Queries.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4724,6 +4733,7 @@
420CFC622D3F9C1F009A94F3 /* Tables */ = {
isa = PBXGroup;
children = (
427A21762F7EA57600BD40B7 /* ServerInfoMirrorTable.swift */,
420CFC632D3F9C2C009A94F3 /* HAppEntityTable.swift */,
420CFC832D3FECF6009A94F3 /* CustomWidgetTable.swift */,
420CFC672D3F9C40009A94F3 /* WatchConfigTable.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
20 changes: 20 additions & 0 deletions Sources/Shared/API/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,26 @@ 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)
}

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.webhookSecret = nil
info.connection.clientCertificate = nil
return info
}
}

public final class Server: Hashable, Comparable, CustomStringConvertible {
public static let historicId: Identifier<Server> = "historic"

Expand Down
138 changes: 71 additions & 67 deletions Sources/Shared/API/ServerManager.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import HAKit
import KeychainAccess
import Sodium
import UserNotifications
import Version

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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

Expand All @@ -173,12 +155,16 @@ final class ServerManagerImpl: ServerManager {

private let cache = HAProtected<ServerCache>(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
Expand All @@ -199,6 +185,10 @@ 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] {
Expand All @@ -207,7 +197,7 @@ final class ServerManagerImpl: ServerManager {
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
let all = mergedServerInfo(deletedServers: cache.deletedServers).sorted(by: { lhs, rhs -> Bool in
lhs.1.sortOrder < rhs.1.sortOrder
}).map { key, value in
server(key: key, value: value, currentCache: &cache)
Expand All @@ -226,6 +216,8 @@ final class ServerManagerImpl: ServerManager {
}
}

// MARK: Mutations

@discardableResult
public func add(identifier: Identifier<Server>, serverInfo: ServerInfo) -> Server {
let setValue = with(serverInfo) {
Expand All @@ -236,7 +228,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

Expand All @@ -251,7 +243,7 @@ final class ServerManagerImpl: ServerManager {
public func remove(identifier: Identifier<Server>) {
cache.mutate { cache in
cache.deletedServers.insert(identifier)
keychain.deleteServerInfo(key: identifier.keychainKey)
deletePersistedServerInfo(for: identifier)
cache.remove(identifier: identifier)
}

Expand All @@ -260,14 +252,18 @@ final class ServerManagerImpl: ServerManager {

public func removeAll() {
cache.mutate { cache in
cache.deletedServers.formUnion(Set(keychain.allKeys().map { Identifier<Server>(keychainKey: $0) }))
let allKeys = Set(keychain.allKeys() + mirrorStore.allKeys())
cache.deletedServers.formUnion(Set(allKeys.map { Identifier<Server>(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 }
Expand All @@ -278,6 +274,8 @@ final class ServerManagerImpl: ServerManager {
}
}

// MARK: Server Accessors

private func serverInfoGetter(
cache: HAProtected<ServerCache>,
keychain: ServerManagerKeychain,
Expand All @@ -290,7 +288,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
}
Expand Down Expand Up @@ -326,7 +328,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 {
Expand Down Expand Up @@ -366,6 +368,43 @@ final class ServerManagerImpl: ServerManager {
return server
}

// MARK: Mirror Persistence

private func persistServerInfo(_ serverInfo: ServerInfo, for identifier: Identifier<Server>) {
// 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<Server>) {
keychain.deleteServerInfo(key: identifier.keychainKey)
mirrorStore.remove(identifier.keychainKey)
}

// MARK: Mirror Reconciliation

private func mergedServerInfo(deletedServers: Set<Identifier<Server>>) -> [(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 }

Expand Down Expand Up @@ -396,10 +435,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
}

Expand All @@ -418,7 +459,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))
}

Expand All @@ -439,40 +480,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)")
}
}
}
Loading
Loading