import Foundation import ObjectMapper import PromiseKit import UserNotifications enum WebhookError: LocalizedError, Equatable, CancellableError { case unregisteredIdentifier(handler: String) case unexpectedType(given: String, desire: String) case unacceptableStatusCode(Int) case unmappableValue case replaced var isCancelled: Bool { switch self { case .replaced: return true default: return false } } var errorDescription: String? { switch self { case .unregisteredIdentifier: return L10n.HaApi.ApiError.unknown case let .unexpectedType(given, desire): return L10n.HaApi.ApiError.unexpectedType(given, desire) case let .unacceptableStatusCode(statusCode): return L10n.HaApi.ApiError.unacceptableStatusCode(statusCode) case .unmappableValue: return L10n.HaApi.ApiError.invalidResponse case .replaced: // this shouldn't be user-facing return "" } } } public class WebhookManager: NSObject { public static func isManager(forSessionIdentifier identifier: String) -> Bool { identifier.starts(with: baseURLSessionIdentifier) } private static let baseURLSessionIdentifier = "webhook-" private static var currentURLSessionIdentifier: String { baseURLSessionIdentifier + Bundle.main.bundleIdentifier! } private static var currentRegularURLSessionIdentifier: String { "non-background" } var sessionInfos = Set() var currentBackgroundSessionInfo: WebhookSessionInfo { sessionInfo(forIdentifier: Self.currentURLSessionIdentifier) } var currentRegularSessionInfo: WebhookSessionInfo { sessionInfo(forIdentifier: Self.currentRegularURLSessionIdentifier) } // must be accessed on appropriate queue private let dataQueue: DispatchQueue private let dataQueueSpecificKey: DispatchSpecificKey // underlying queue is the dataQueue private let dataOperationQueue: OperationQueue private var pendingDataForTask: [TaskKey: Data] = [:] { willSet { assert(DispatchQueue.getSpecific(key: dataQueueSpecificKey) == true) } } private var resolverForTask: [TaskKey: Resolver] = [:] { willSet { assert(DispatchQueue.getSpecific(key: dataQueueSpecificKey) == true) } } private var serverForEphemeralTask: [TaskKey: Server] = [:] { willSet { assert(DispatchQueue.getSpecific(key: dataQueueSpecificKey) == true) } } private var responseHandlers = [WebhookResponseIdentifier: WebhookResponseHandler.Type]() var serverCache = [Identifier: Server]() // MARK: - Lifecycle override init() { let specificKey = DispatchSpecificKey() let underlyingQueue = DispatchQueue(label: "webhookmanager-data") underlyingQueue.setSpecific(key: specificKey, value: true) self.dataQueue = underlyingQueue self.dataQueueSpecificKey = specificKey self.dataOperationQueue = with(OperationQueue()) { $0.underlyingQueue = underlyingQueue } super.init() // cause the current sessions to be created dataQueue.sync { _ = self.currentBackgroundSessionInfo _ = self.currentRegularSessionInfo } register(responseHandler: WebhookResponseUnhandled.self, for: .unhandled) } func register( responseHandler: WebhookResponseHandler.Type, for identifier: WebhookResponseIdentifier ) { precondition(responseHandlers[identifier] == nil) responseHandlers[identifier] = responseHandler } private func sessionInfo(for session: URLSession) -> WebhookSessionInfo { assert(DispatchQueue.getSpecific(key: dataQueueSpecificKey) == true || Current.isRunningTests) guard let identifier = session.configuration.identifier else { if let sameSession = sessionInfos.first(where: { $0.session == session }) { return sameSession } Current.Log.error("asked for session \(session) but couldn't identify info for it") return currentBackgroundSessionInfo } return sessionInfo(forIdentifier: identifier) } private func sessionInfo(forIdentifier identifier: String) -> WebhookSessionInfo { assert(DispatchQueue.getSpecific(key: dataQueueSpecificKey) == true || Current.isRunningTests) if let sessionInfo = sessionInfos.first(where: { $0.identifier == identifier }) { return sessionInfo } let sessionInfo = WebhookSessionInfo( identifier: identifier, delegate: self, delegateQueue: dataOperationQueue, background: identifier != Self.currentRegularURLSessionIdentifier ) sessionInfos.insert(sessionInfo) return sessionInfo } public func handleBackground(for identifier: String, completionHandler: @escaping () -> Void) { precondition(Self.isManager(forSessionIdentifier: identifier)) Current.Log.notify("handleBackground started for \(identifier)") dataQueue.async { [dataQueue] in let sessionInfo = self.sessionInfo(forIdentifier: identifier) Current.Log.info("created or retrieved: \(sessionInfo)") // enter before setting finish, in case we had another leave/enter pair set up, we want to prevent notifying sessionInfo.eventGroup.enter() sessionInfo.setDidFinish { // this is wrapped via a block -- rather than being invoked directly -- because iOS 14 (at least b1/b2) // sends `urlSessionDidFinishEvents` when it didn't send `handleEventsForBackgroundURLSession` sessionInfo.eventGroup.leave() } sessionInfo.eventGroup.notify(queue: DispatchQueue.main) { Current.Log.notify("final completion for \(identifier)") completionHandler() } if self.currentBackgroundSessionInfo != sessionInfo { sessionInfo.eventGroup.notify(queue: dataQueue) { [weak self] in Current.Log.info("removing session info \(sessionInfo)") self?.sessionInfos.remove(sessionInfo) } } } } // MARK: - Sending Ephemeral public func sendEphemeral(server: Server, request: WebhookRequest) -> Promise { let promise: Promise = sendEphemeral(server: server, request: request) return promise.asVoid() } public func sendEphemeral( server: Server, request: WebhookRequest ) -> Promise { let promise: Promise = sendEphemeral(server: server, request: request) return promise.map { if let result = Mapper().map(JSONObject: $0) { return result } else { throw WebhookError.unmappableValue } } } public func sendEphemeral( server: Server, request: WebhookRequest ) -> Promise<[MappableResult]> { let promise: Promise = sendEphemeral(server: server, request: request) return promise.map { if let result = Mapper(shouldIncludeNilValues: false).mapArray(JSONObject: $0) { return result } else { throw WebhookError.unmappableValue } } } public func sendEphemeral( server: Server, request: WebhookRequest, overrideURL: URL? = nil ) -> Promise { Current.backgroundTask(withName: BackgroundTask.webhookSendEphemeral.rawValue) { [self, dataQueue] _ in attemptNetworking { firstly { Self.urlRequest(for: request, server: server, baseURL: overrideURL) }.get { _, _ in Current.Log.info("sending to \(server.identifier): \(request)") }.then(on: dataQueue) { [self] urlRequest, data -> Promise<(Data, URLResponse)> in let (promise, seal) = Promise<(Data, URLResponse)>.pending() let task = currentRegularSessionInfo.session.uploadTask( with: urlRequest, from: data, completionHandler: { data, response, error in if let data, let response { seal.fulfill((data, response)) } else { seal.resolve(nil, error) } } ) let taskKey = TaskKey(sessionInfo: currentRegularSessionInfo, task: task) serverForEphemeralTask[taskKey] = server task.resume() return promise.ensure(on: dataQueue) { [self] in serverForEphemeralTask[taskKey] = nil } } } }.then { data, response in Promise.value(data).webhookJson( on: DispatchQueue.global(qos: .utility), server: server, statusCode: (response as? HTTPURLResponse)?.statusCode, requestURL: response.url, secretGetter: { server.info.connection.webhookSecretBytes(version: server.info.version) } ) }.map { possible in if let value = possible as? ResponseType { return value } else { throw WebhookError.unexpectedType( given: String(describing: type(of: possible)), desire: String(describing: ResponseType.self) ) } }.tap { [weak self] result in switch result { case let .fulfilled(response): Current.Log.info("got successful response from \(server.identifier) for \(request.type): \(response)") case let .rejected(error): Current.Log.error("got failure from \(server.identifier) for \(request.type): \(error)") /* If user's cloud subscription expired or account was signed out, retry with activeURL instad of cloud hook */ if let self, let error = error as? WebhookError, error == WebhookError.unacceptableStatusCode(503), overrideURL == nil, let activeURL = server.info.connection.activeURL() { let event = ClientEvent( text: "Retrying with active URL - \(activeURL.absoluteString)", type: .networkRequest ) Current.clientEventStore.addEvent(event) let promise: Promise = sendEphemeral( server: server, request: request, overrideURL: activeURL ) promise.cauterize() } else { Current.Log.error( "Not retrying with active URL, error: \(error), overrideURL: \(overrideURL?.absoluteString ?? "--"), activeURL: \(server.info.connection.activeURL()?.absoluteString ?? "Unknown")" ) } } } } // MARK: - Sending Persistent public func send( identifier: WebhookResponseIdentifier = .unhandled, server: Server, request: WebhookRequest ) -> Promise { let (promise, seal) = Promise.pending() dataQueue.async { [dataQueue] in let sendRegular: () -> Promise = { [self] in send( on: currentRegularSessionInfo, server: server, identifier: identifier, request: request, waitForResponse: true ) } let sendBackground: () -> Promise = { [self] in send( on: currentBackgroundSessionInfo, server: server, identifier: identifier, request: request, waitForResponse: true ) } let promise: Promise if Current.isBackgroundRequestsImmediate() { promise = sendBackground() } else { Current.Log.info("in background, choosing to not use background session") promise = sendRegular().recover(on: dataQueue) { error -> Promise in Current.Log.error("in-background non-background failed: \(error)") if error is HomeAssistantAPI.APIError { // not worth retrying, since we got a real response that we didn't like throw error } else { return sendBackground() } } } promise.pipe(to: { seal.resolve($0) }) } return promise } public func sendPassive( identifier: WebhookResponseIdentifier = .unhandled, server: Server, request: WebhookRequest ) -> Promise { let (promise, seal) = Promise.pending() dataQueue.async { [self] in send( on: currentBackgroundSessionInfo, server: server, identifier: identifier, request: request, waitForResponse: false ) .pipe(to: seal.resolve) } return promise } private func send( on sessionInfo: WebhookSessionInfo, server: Server, identifier: WebhookResponseIdentifier, request: WebhookRequest, waitForResponse: Bool ) -> Promise { guard let handlerType = responseHandlers[identifier] else { Current.Log.error("no existing handler for \(identifier), not sending request") return .init(error: WebhookError.unregisteredIdentifier(handler: identifier.rawValue)) } let (promise, seal) = Promise.pending() // if we're asked to send on a non-persisted server, we may need to refer back to it serverCache[server.identifier] = server // wrap this in a background task, but don't let the expiration cause the resolve chain to be aborted // this is important because we may be woken up later and asked to continue the same request, even if timed out // since, you know, background execution and whatnot Current.backgroundTask(withName: BackgroundTask.webhookSend.rawValue) { _ in promise }.cauterize() firstly { Self.urlRequest(for: request, server: server) }.done(on: dataQueue) { urlRequest, data in let task: URLSessionUploadTask let filesToRemove: [URL] if sessionInfo.isBackground { let temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let temporaryFile = temporaryDirectory .appendingPathComponent(UUID().uuidString) .appendingPathExtension("json") try data.write(to: temporaryFile) task = sessionInfo.session.uploadTask(with: urlRequest, fromFile: temporaryFile) filesToRemove = [temporaryFile] } else { // not writing to disk so we don't have to deal with the cleanup logic across sessions task = sessionInfo.session.uploadTask(with: urlRequest, from: data) filesToRemove = [] } let persisted = WebhookPersisted(server: server.identifier, request: request, identifier: identifier) task.webhookPersisted = persisted let taskKey = TaskKey(sessionInfo: sessionInfo, task: task) self.evaluateCancellable( by: task, type: handlerType, persisted: persisted, with: promise ) self.resolverForTask[taskKey] = seal task.resume() Current.Log.info { let values = [ "\(taskKey)", "server(\(server.identifier))", "type(\(handlerType))", "request(\(persisted.request))", ] return "starting request: " + values.joined(separator: ", ") } for file in filesToRemove { // the background session takes over ownership of the files, so that code path needs these cleaned up try FileManager.default.removeItem(at: file) } }.catch { error in self.invoke( sessionInfo: sessionInfo, handler: handlerType, server: server, request: request, result: .init(error: error), resolver: seal ) }.finally { if !waitForResponse { seal.fulfill(()) } } return promise } // MARK: - Testing Connection Info public func sendTest(server: Server, baseURL: URL) -> Promise { firstly { Self.urlRequest( for: .init(type: "get_config", data: [:]), server: server, baseURL: baseURL ) }.then(on: dataQueue) { urlRequest, data in self.currentRegularSessionInfo.session.uploadTask(.promise, with: urlRequest, from: data) }.then { data, response in Promise.value(data).webhookJson( on: DispatchQueue.global(qos: .utility), server: server, statusCode: (response as? HTTPURLResponse)?.statusCode, requestURL: response.url, secretGetter: { server.info.connection.webhookSecretBytes(version: server.info.version) } ) }.asVoid() } // MARK: - Private private func evaluateCancellable( by newTask: URLSessionTask, type newType: WebhookResponseHandler.Type, persisted newPersisted: WebhookPersisted, with newPromise: Promise ) { let evaluate = { [self] (session: WebhookSessionInfo, tasks: [URLSessionTask]) in tasks.filter { thisTask in guard let (thisType, thisPersisted) = responseInfo(from: thisTask) else { if session.isBackground { // only some requests on the regular session have info, ephemeral tasks do not for example // all requests on the background session have persistence info Current.Log.error("cancelling request without persistence info: \(thisTask)") thisTask.cancel() } return false } if thisType == newType, thisTask != newTask, newPersisted.server == thisPersisted.server { return newType.shouldReplace(request: newPersisted.request, with: thisPersisted.request) } else { return false } }.forEach { existingTask in let taskKey = TaskKey(sessionInfo: session, task: existingTask) if let existingResolver = resolverForTask[taskKey] { existingResolver.reject(WebhookError.replaced) } existingTask.cancel() } } currentRegularSessionInfo.session.getAllTasks { [self] tasks in dataQueue.async { [self] in evaluate(currentRegularSessionInfo, tasks) } } currentBackgroundSessionInfo.session.getAllTasks { [self] tasks in dataQueue.async { [self] in evaluate(currentBackgroundSessionInfo, tasks) } } } private static func urlRequest( for request: WebhookRequest, server: Server, baseURL: URL? = nil ) -> Promise<(URLRequest, Data)> { Promise { seal in let webhookURL: URL if let baseURL { webhookURL = baseURL.appendingPathComponent(server.info.connection.webhookPath, isDirectory: false) } else { if let url = server.info.connection.webhookURL() { webhookURL = url } else { seal.resolve(.rejected(ServerConnectionError.noActiveURL(server.info.name))) return } } var urlRequest = try URLRequest(url: webhookURL, method: .post) urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") let jsonObject = Mapper(context: WebhookRequestContext.server(server)).toJSON(request) let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.sortedKeys]) // httpBody is ignored by URLSession but is made available in tests urlRequest.httpBody = data seal.fulfill((urlRequest, data)) } } private func handle(result: WebhookResponseHandlerResult) { if let notification = result.notification { UNUserNotificationCenter.current().add(notification) { error in if let error { Current.Log.error("failed to add notification for result \(result): \(error)") } } } } private func responseInfo(from task: URLSessionTask) -> (WebhookResponseHandler.Type, WebhookPersisted)? { guard let persisted = task.webhookPersisted else { Current.Log.error("no persisted info for \(task) \(task.taskDescription ?? "(nil)")") return nil } guard let handlerType = responseHandlers[persisted.identifier] else { Current.Log.error("unknown response identifier \(persisted.identifier) for \(task)") return nil } return (handlerType, persisted) } } extension WebhookManager: URLSessionDelegate { public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { Current.Log.notify("event delivery ended") sessionInfo(for: session).fireDidFinish() } } extension WebhookManager: URLSessionDataDelegate, URLSessionTaskDelegate { private func server(for persisted: WebhookPersisted) -> Server? { serverCache[persisted.server] ?? Current.servers.server(for: persisted.server) } public func urlSession( _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { let potentialServer: Server? if let (_, persisted) = responseInfo(from: task), let server = server(for: persisted) { potentialServer = server } else { let taskKey = TaskKey(sessionInfo: sessionInfo(for: session), task: task) potentialServer = serverForEphemeralTask[taskKey] } if let server = potentialServer { let result = server.info.connection.evaluate(challenge) completionHandler(result.0, result.1) } else { Current.Log.error("couldn't locate server for \(task)") completionHandler(.performDefaultHandling, nil) } } public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { let taskKey = TaskKey(sessionInfo: sessionInfo(for: session), task: dataTask) pendingDataForTask[taskKey, default: Data()].append(data) } public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { let sessionInfo = sessionInfo(for: session) let taskKey = TaskKey(sessionInfo: sessionInfo, task: task) let statusCode = (task.response as? HTTPURLResponse)?.statusCode guard error?.isCancelled != true else { Current.Log.info("ignoring cancelled task \(taskKey)") pendingDataForTask.removeValue(forKey: taskKey) return } // dispatch if let (handlerType, persisted) = responseInfo(from: task), let server = server(for: persisted) { let result = Promise { seal in let data = self.pendingDataForTask[taskKey] self.pendingDataForTask.removeValue(forKey: taskKey) seal.resolve(error, data) }.webhookJson( on: DispatchQueue.global(qos: .utility), server: server, statusCode: statusCode, requestURL: task.response?.url, secretGetter: { server.info.connection.webhookSecretBytes(version: server.info.version) } ) // logging result.done(on: dataQueue) { body in Current.Log.info { let values = [ "\(taskKey)", "type(\(handlerType))", "server(\(server.identifier))", "request(\(persisted.request))", "statusCode(\(statusCode.flatMap { String(describing: $0) } ?? "none"))", "body(\(body))", ] return "got response: " + values.joined(separator: ", ") } }.catch { error in Current.Log.error("failed request to \(server.identifier) for \(handlerType): \(error)") } invoke( sessionInfo: sessionInfo, handler: handlerType, server: server, request: persisted.request, result: result, resolver: resolverForTask[taskKey] ) resolverForTask.removeValue(forKey: taskKey) } else { Current.Log.notify("no handler for background task") Current.Log.error("couldn't find appropriate handler for \(task)") } } private func invoke( sessionInfo: WebhookSessionInfo, handler handlerType: WebhookResponseHandler.Type, server: Server, request: WebhookRequest, result: Promise, resolver: Resolver? ) { Current.Log.notify("starting \(request.type) to \(server.identifier) (\(handlerType))") sessionInfo.eventGroup.enter() Current.backgroundTask(withName: BackgroundTask.webhookInvoke.rawValue) { _ -> Promise in guard let api = Current.api(for: server) else { return .init(error: HomeAssistantAPI.APIError.noAPIAvailable) } let handler = handlerType.init(api: api) let handlerPromise = firstly { handler.handle(request: .value(request), result: result) }.done { [weak self] result in // keep the handler around until it finishes withExtendedLifetime(handler) { self?.handle(result: result) } } return firstly { when(fulfilled: [handlerPromise.asVoid(), result.asVoid()]) }.tap { resolver?.resolve($0) }.ensure { Current.Log.notify("finished \(request.type) to \(server.identifier) \(handlerType)") sessionInfo.eventGroup.leave() } }.cauterize() } } class WebhookSessionInfo: CustomStringConvertible, Hashable { let identifier: String let eventGroup: DispatchGroup let session: URLSession let isBackground: Bool private var pendingDidFinishHandler: (() -> Void)? private var didFinishWithoutPendingHandler = false var description: String { "sessionInfo(identifier: \(identifier))" } func setDidFinish(_ block: @escaping () -> Void) { pendingDidFinishHandler?() pendingDidFinishHandler = block if didFinishWithoutPendingHandler { // finish already occurred. this likely means we were already in memory when the system informed us. // the app/extension delegate methods asking us to complete may have occurred _after_ since they jump queues fireDidFinish() } } func fireDidFinish() { if let existingHandler = pendingDidFinishHandler { existingHandler() pendingDidFinishHandler = nil didFinishWithoutPendingHandler = false } else { didFinishWithoutPendingHandler = true } } init( identifier: String, delegate: URLSessionDelegate, delegateQueue: OperationQueue, background: Bool ) { let configuration: URLSessionConfiguration = { let configuration: URLSessionConfiguration if NSClassFromString("XCTest") != nil { // ^ cannot reference Current here because we're being created inside Current as it is made // we cannot mock http requests in a background session, so this code path has to differ configuration = .ephemeral } else if background { configuration = .background(withIdentifier: identifier) } else { configuration = .ephemeral } return with(configuration) { $0.sharedContainerIdentifier = AppConstants.AppGroupID $0.httpCookieStorage = nil $0.httpCookieAcceptPolicy = .never $0.httpShouldSetCookies = false $0.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData $0.httpAdditionalHeaders = [ "User-Agent": HomeAssistantAPI.userAgent, ] // how long should this request be retried in the background? // default is 7days, but our background requests do not need to live that long let timeout = Measurement(value: 2, unit: .hours) $0.timeoutIntervalForResource = timeout.converted(to: .seconds).value } }() self.isBackground = background self.identifier = identifier self.session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) self.eventGroup = DispatchGroup() session.getAllTasks { tasks in Current.Log.info("\(identifier) initial tasks: \(tasks.map(\.taskIdentifier))") } } static func == (lhs: WebhookSessionInfo, rhs: WebhookSessionInfo) -> Bool { lhs.identifier == rhs.identifier } func hash(into hasher: inout Hasher) { hasher.combine(identifier) } } private struct TaskKey: Hashable, CustomStringConvertible { private let sessionIdentifier: String private let taskIdentifier: Int init(sessionInfo: WebhookSessionInfo, task: URLSessionTask) { self.sessionIdentifier = sessionInfo.identifier self.taskIdentifier = task.taskIdentifier } var description: String { "taskKey(session: \(sessionIdentifier), task: \(taskIdentifier))" } }