import CoreBluetooth import CoreLocation import CoreMotion import Foundation import GRDB import HAKit import NetworkExtension import PromiseKit import RealmSwift import UserNotifications import Version import XCGLogger public enum AppConfiguration: Int, CaseIterable, CustomStringConvertible, Equatable { case fastlaneSnapshot case debug case beta case release public var description: String { switch self { case .fastlaneSnapshot: return "fastlane" case .debug: return "debug" case .beta: return "beta" case .release: return "release" } } } private var underlyingWasSetUp: UInt32 = 0 private var underlyingCurrent = AppEnvironment() public var Current: AppEnvironment { get { let result = underlyingCurrent if OSAtomicTestAndSetBarrier(0, &underlyingWasSetUp) == false { // we only want to run setup once, but we _must_ have 'Current' work during it to allow 'Current' to be // reentrant, which is a requirement for touching things like Log but also touching more unexpected // things like accessing any L10n helper value, which funnels through Current as well. result.setup() } return result } set { underlyingCurrent = newValue } } /// The current "operating envrionment" the app. Implementations can be swapped out to facilitate better /// unit tests. public class AppEnvironment { init() { PromiseKit.conf.logHandler = { event in Current.Log.info { switch event { case .waitOnMainThread: return "PromiseKit: warning: `wait()` called on main thread!" case .pendingPromiseDeallocated: return "PromiseKit: warning: pending promise deallocated" case .pendingGuaranteeDeallocated: return "PromiseKit: warning: pending guarantee deallocated" case let .cauterized(error): return "PromiseKit:cauterized-error: \(error)" } } } HAGlobal.log = { level, log in let string = "WebSocket: \(log.replacingOccurrences(of: "\n", with: " "))" switch level { case .info: Current.Log.info(string) case .error: Current.Log.error(string) } } } func setup() { _ = Current // just to make sure we don't crash for this case (crashReporter as? CrashReporterImpl)?.setup() (servers as? ServerManagerImpl)?.setup() } /// Crash reporting and related metadata gathering public var crashReporter: CrashReporter = CrashReporterImpl() /// Provides current Date public var date: () -> Date = Date.init public var calendar: () -> Calendar = { Calendar.autoupdatingCurrent } /// Provides the Client Event store used for local logging. public var clientEventStore: ClientEventStoreProtocol = ClientEventStore() /// Provides the Realm used for many data storage tasks. public var realm: () -> Realm = Realm.live /// Provides the Realm given objectTypes to reduce memory usage mostly in extensions. public func realm(objectTypes: [ObjectBase.Type]) -> Realm { Realm.getRealm(objectTypes: objectTypes) } public var database: () -> DatabaseQueue = { .appDatabase } public var watchConfig: () throws -> WatchConfig? = { try WatchConfig.config() } public var customWidgets: () throws -> [CustomWidget] = { try CustomWidget.widgets() ?? [] } public var carPlayConfig: () throws -> CarPlayConfig? = { try CarPlayConfig.config() } public var magicItemProvider: () -> MagicItemProviderProtocol = { MagicItemProvider() } public var appEntitiesModel: () -> AppEntitiesModelProtocol = { AppEntitiesModel.shared } public var areasProvider: () -> AreasServiceProtocol = { AreasService.shared } /// APNs environment string for token reporting. "sandbox" in DEBUG builds, "production" otherwise. /// TestFlight uses distribution signing and routes through the production APNs endpoint. public var apnsEnvironment: String { #if DEBUG return "sandbox" #else return "production" #endif } #if os(iOS) #if !targetEnvironment(macCatalyst) /// Call `_ = Current.liveActivityRegistry` on the main thread at launch (before any /// background thread can access it) to avoid a lazy-init race between concurrent callers. public lazy var liveActivityRegistry: LiveActivityRegistryProtocol? = { if #available(iOS 17.2, *) { return LiveActivityRegistry() } return nil }() #endif public var appDatabaseUpdater: AppDatabaseUpdaterProtocol = AppDatabaseUpdater.shared public var panelsUpdater: PanelsUpdaterProtocol = PanelsUpdater.shared public var realmFatalPresentation: ((UIViewController) -> Void)? public var impactFeedback: ImpactFeedbackGeneratorProtocol = ImpactFeedbackGenerator() /// Wrapper around UIApplication for use in shared framework public var application: (() -> UIApplication)? /// Wrapper around UIScreen.main.brightness for testability public var screenBrightness: () -> CGFloat = { UIScreen.main.brightness } public var setScreenBrightness: (CGFloat) -> Void = { UIScreen.main.brightness = $0 } public lazy var isForegroundApp = { self.application?().applicationState == .active } #endif public var style: Style = .init() public var servers: ServerManager = ServerManagerImpl() // Thread-safe wrapper for the API cache. AppIntents (e.g. ScriptAppIntent, // SwitchIntent) run on arbitrary threads and can call api(for:) concurrently, // so unprotected dictionary access causes memory corruption in extensions. private var cachedApisLock = NSLock() private var _cachedApis = [Identifier: HomeAssistantAPI]() public var cachedApis: [Identifier: HomeAssistantAPI] { get { cachedApisLock.lock() defer { cachedApisLock.unlock() } return _cachedApis } set { cachedApisLock.lock() defer { cachedApisLock.unlock() } _cachedApis = newValue } } /// Thread-safe single-key insertion into the API cache. /// Prefer this over `cachedApis[id] = api` which involves a /// non-atomic get→mutate→set cycle on the computed property. public func setCachedApi(_ api: HomeAssistantAPI, for identifier: Identifier) { cachedApisLock.lock() defer { cachedApisLock.unlock() } _cachedApis[identifier] = api } public var apis: [HomeAssistantAPI] { servers.all.compactMap(api(for:)) } private var lastActiveURLForServer = [Identifier: URL?]() public func api(for server: Server) -> HomeAssistantAPI? { guard server.info.connection.activeURL() != nil else { return nil } // Use the same lock to make the read-check-write atomic cachedApisLock.lock() defer { cachedApisLock.unlock() } if let existing = _cachedApis[server.identifier] { return existing } else { let api = HomeAssistantAPI(server: server, urlConfig: .default) _cachedApis[server.identifier] = api return api } } private var underlyingAPI: Promise? public var modelManager = LegacyModelManager() public var settingsStore = SettingsStore() public var webhooks = with(WebhookManager()) { // ^ because background url session identifiers cannot be reused, this must be a singleton-ish $0.register(responseHandler: WebhookResponseUpdateSensors.self, for: .updateSensors) $0.register(responseHandler: WebhookResponseLocation.self, for: .location) $0.register(responseHandler: WebhookResponseServiceCall.self, for: .serviceCall) $0.register(responseHandler: WebhookResponseUpdateComplications.self, for: .updateComplications) } public var sensors = with(SensorContainer()) { $0.register(provider: ActivitySensor.self) $0.register(provider: PedometerSensor.self) $0.register(provider: BatterySensor.self) $0.register(provider: StorageSensor.self) $0.register(provider: ConnectivitySensor.self) $0.register(provider: GeocoderSensor.self) $0.register(provider: InputOutputDeviceSensor.self) $0.register(provider: DisplaySensor.self) $0.register(provider: ActiveSensor.self) $0.register(provider: FrontmostAppSensor.self) $0.register(provider: FocusSensor.self) $0.register(provider: LastUpdateSensor.self) $0.register(provider: WatchBatterySensor.self) $0.register(provider: AppVersionSensor.self) $0.register(provider: LocationPermissionSensor.self) $0.register(provider: AudioOutputSensor.self) } public var localized = LocalizedManager() public var tags: TagManager = EmptyTagManager() public var updater = Updater() public var serverAlerter = ServerAlerter() public var notificationAttachmentManager: NotificationAttachmentManager = NotificationAttachmentManagerImpl() /// Dispatchque local notifications (From the App to the App, not from Home Assistant) public var notificationDispatcher: LocalNotificationDispatcherProtocol = LocalNotificationDispatcher() #if os(watchOS) public var backgroundRefreshScheduler = WatchBackgroundRefreshScheduler() #endif #if targetEnvironment(macCatalyst) public var macBridge: MacBridge = { guard let pluginUrl = Bundle(for: AppEnvironment.self).builtInPlugInsURL, let bundle = Bundle(url: pluginUrl.appendingPathComponent("MacBridge.bundle")) else { fatalError("couldn't load mac bridge bundle") } bundle.load() if let principalClass = bundle.principalClass as? MacBridge.Type { return principalClass.init() } else { fatalError("couldn't load mac bridge principal class") } }() #endif public lazy var activeState: ActiveStateManager = .init() public lazy var clientVersion: () -> Version = { AppConstants.clientVersion } public var onboardingObservation = OnboardingStateObservation() public var isPerformingSingleShotLocationQuery = false public var backgroundTask: HomeAssistantBackgroundTaskRunner = ProcessInfoBackgroundTaskRunner() /// Use of 'appConfiguration' is preferred, but sometimes Beta builds are done as releases. public var isTestFlight = { #if DEBUG print("⚠️ isTestFlight returns TRUE while debugging") return true #else return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" #endif }() #if os(iOS) public var isAppExtension = AppConstants.BundleID != Bundle.main.bundleIdentifier #elseif os(watchOS) public var isAppExtension = false #endif public var isAppStore: Bool = { do { // https://developer.apple.com/library/archive/technotes/tn2259/_index.html suggested method if let url = Bundle.main.appStoreReceiptURL { // url is possibly provided but doesn't exist on disk _ = try Data(contentsOf: url) return true } else { return false } } catch { return false } }() public var isCatalyst: Bool = { #if targetEnvironment(macCatalyst) return true #else return false #endif }() private let isFastlaneSnapshot = UserDefaults(suiteName: AppConstants.AppGroupID)!.bool(forKey: "FASTLANE_SNAPSHOT") /// This can be used to add debug statements. public var isDebug: Bool { #if DEBUG return true #else return false #endif } public var isRunningTests: Bool { NSClassFromString("XCTest") != nil } public var isBackgroundRequestsImmediate = { #if os(watchOS) true #else false #endif } public var appConfiguration: AppConfiguration { if isFastlaneSnapshot { return .fastlaneSnapshot } else if isDebug { return .debug } else if (Bundle.main.bundleIdentifier ?? "").lowercased().contains("beta"), isTestFlight { return .beta } else { return .release } } public var Log: XCGLogger = { if NSClassFromString("XCTest") != nil { let logger = XCGLogger() logger.outputLevel = .verbose return logger } // Create a logger object with no destinations let log = XCGLogger(identifier: "advancedLogger", includeDefaultDestinations: false) #if DEBUG log.dateFormatter = with(DateFormatter()) { $0.dateFormat = "HH:mm:ss.SSS" $0.locale = Locale.current } log.add(destination: with(ConsoleDestination()) { $0.outputLevel = .verbose $0.showLogIdentifier = false $0.showFunctionName = true $0.showThreadName = true $0.showLevel = true $0.showFileName = true $0.showLineNumber = true $0.showDate = true }) #endif let logPath = AppConstants.LogsDirectory.appendingPathComponent( ProcessInfo.processInfo.processName + ".txt", isDirectory: false ) // Create a file log destination let isTestFlight = Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" let fileDestination = AutoRotatingFileDestination( writeToFile: logPath, identifier: "advancedLogger.fileDestination", shouldAppend: true, maxFileSize: 10_485_760, maxTimeInterval: 86400, // archived logs + 1 current, so realy this is -1'd targetMaxLogFiles: isTestFlight ? 8 : 4 ) // Optionally set some configuration options fileDestination.outputLevel = .verbose fileDestination.showLogIdentifier = false fileDestination.showFunctionName = true fileDestination.showThreadName = true fileDestination.showLevel = true fileDestination.showFileName = true fileDestination.showLineNumber = true fileDestination.showDate = true // Process this destination in the background fileDestination.logQueue = XCGLogger.logQueue // Add the destination to the logger log.add(destination: fileDestination) // Add basic app info, version info etc, to the start of the logs log.logAppDetails() return log }() /// Wrapper around CMMotionActivityManager public struct Motion { private let underlyingManager = CMMotionActivityManager() public var isAuthorized: () -> Bool = { guard !Current.isCatalyst else { return false } return CMMotionActivityManager.authorizationStatus() == .authorized } public var isActivityAvailable: () -> Bool = { #if os(iOS) && targetEnvironment(simulator) return { true } #else return CMMotionActivityManager.isActivityAvailable #endif }() public lazy var queryStartEndOnQueueHandler: ( Date, Date, OperationQueue, @escaping CMMotionActivityQueryHandler ) -> Void = { [underlyingManager] start, end, queue, handler in underlyingManager.queryActivityStarting(from: start, to: end, to: queue, withHandler: handler) } } public var motion = Motion() /// Wrapper around CMPedometeer public struct Pedometer { private let underlyingPedometer = CMPedometer() public var isAuthorized: () -> Bool = { guard !Current.isCatalyst else { return false } return CMPedometer.authorizationStatus() == .authorized } public var isStepCountingAvailable: () -> Bool = CMPedometer.isStepCountingAvailable public lazy var queryStartEndHandler: ( Date, Date, @escaping CMPedometerHandler ) -> Void = { [underlyingPedometer] start, end, handler in underlyingPedometer.queryPedometerData(from: start, to: end, withHandler: handler) } } public var pedometer = Pedometer() public var device = DeviceWrapper() public var matter = MatterWrapper() /// Wrapper around CLGeocoder public struct Geocoder { public var geocode: (CLLocation) -> Promise<[CLPlacemark]> = CLGeocoder.geocode(location:) } public var geocoder = Geocoder() /// Wrapper around One Shot public struct Location { public lazy var oneShotLocation: ( _ trigger: LocationUpdateTrigger, _ remaining: TimeInterval? ) -> Promise = { CLLocationManager.oneShotLocation(timeout: $0.oneShotTimeout(maximum: $1)) } public var permissionStatus: CLAuthorizationStatus { CLLocationManager().authorizationStatus } } public var location = Location() public var connectivity = ConnectivityWrapper() public var focusStatus = FocusStatusWrapper() public var diskCache: DiskCache = DiskCacheImpl() public var bluetoothPermissionStatus: CBManagerAuthorization { CBCentralManager.authorization } public var userNotificationCenter: UNUserNotificationCenter { UNUserNotificationCenter.current() } public var networkInformation: NEHotspotNetwork? { get async { await withCheckedContinuation { continuation in NEHotspotNetwork.fetchCurrent { hotspotNetwork in continuation.resume(returning: hotspotNetwork) } } } } public func networkInformation(completion: @escaping (NEHotspotNetwork?) -> Void) { NEHotspotNetwork.fetchCurrent { hotspotNetwork in completion(hotspotNetwork) } } #if !os(watchOS) /// Provides a way to handle Bonjour connections. Such as for scanning for Home Assistant instances. public var bonjour: () -> BonjourProtocol = { Bonjour() } /// Handles website data store for the app, such as cookies and local storage. public var websiteDataStoreHandler: WebsiteDataStoreHandlerProtocol = WebsiteDataStoreHandlerImpl.build() #endif /// Values stored for the given app session until terminated by the OS. public var appSessionValues: AppSessionValuesProtocol = AppSessionValues.shared public var locationManager: LocationManagerProtocol = LocationManager() }