424 lines
14 KiB
Swift
424 lines
14 KiB
Swift
import CoreBluetooth
|
|
import CoreLocation
|
|
import CoreMotion
|
|
import Foundation
|
|
import GRDB
|
|
import HAKit
|
|
import PromiseKit
|
|
import RealmSwift
|
|
import Version
|
|
import XCGLogger
|
|
|
|
public enum AppConfiguration: Int, CaseIterable, CustomStringConvertible {
|
|
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 URLs usable for storing data.
|
|
public var date: () -> Date = Date.init
|
|
public var calendar: () -> Calendar = { Calendar.autoupdatingCurrent }
|
|
|
|
/// Provides the Client Event store used for local logging.
|
|
public var clientEventStore = 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 carPlayConfig: () throws -> CarPlayConfig? = {
|
|
try CarPlayConfig.config()
|
|
}
|
|
|
|
public var magicItemProvider: () -> MagicItemProviderProtocol = {
|
|
MagicItemProvider()
|
|
}
|
|
|
|
public var appEntitiesModel: AppEntitiesModelProtocol = AppEntitiesModel()
|
|
|
|
#if os(iOS)
|
|
public var realmFatalPresentation: ((UIViewController) -> Void)?
|
|
#endif
|
|
|
|
public var style: Style = .init()
|
|
|
|
public var servers: ServerManager = ServerManagerImpl()
|
|
|
|
public var cachedApis = [Identifier<Server>: HomeAssistantAPI]()
|
|
|
|
public var apis: [HomeAssistantAPI] { servers.all.compactMap(api(for:)) }
|
|
|
|
public func api(for server: Server) -> HomeAssistantAPI? {
|
|
if let existing = cachedApis[server.identifier] {
|
|
return existing
|
|
} else if server.info.connection.activeURL() != nil {
|
|
let api = HomeAssistantAPI(server: server, urlConfig: .default)
|
|
cachedApis[server.identifier] = api
|
|
return api
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private var underlyingAPI: Promise<HomeAssistantAPI>?
|
|
|
|
public var modelManager = ModelManager()
|
|
|
|
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)
|
|
}
|
|
|
|
public var localized = LocalizedManager()
|
|
|
|
public var tags: TagManager = EmptyTagManager()
|
|
|
|
public var updater = Updater()
|
|
public var serverAlerter = ServerAlerter()
|
|
public var notificationAttachmentManager: NotificationAttachmentManager = NotificationAttachmentManagerImpl()
|
|
|
|
#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 = Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
|
|
#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 isForegroundApp = { false }
|
|
|
|
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<CLLocation> = {
|
|
CLLocationManager.oneShotLocation(timeout: $0.oneShotTimeout(maximum: $1))
|
|
}
|
|
}
|
|
|
|
public var location = Location()
|
|
|
|
public var connectivity = ConnectivityWrapper()
|
|
|
|
public var focusStatus = FocusStatusWrapper()
|
|
|
|
public var diskCache: DiskCache = DiskCacheImpl()
|
|
|
|
public var bluetoothPermissionStatus: CBManagerAuthorization {
|
|
CBCentralManager.authorization
|
|
}
|
|
}
|