iOS/Sources/Shared/API/Webhook/Networking/WebhookManager.swift

820 lines
31 KiB
Swift

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 "<replaced>"
}
}
}
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<WebhookSessionInfo>()
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<Bool>
// 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<Void>] = [:] {
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>: Server]()
// MARK: - Lifecycle
override init() {
let specificKey = DispatchSpecificKey<Bool>()
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<Void> {
let promise: Promise<Any> = sendEphemeral(server: server, request: request)
return promise.asVoid()
}
public func sendEphemeral<MappableResult: BaseMappable>(
server: Server,
request: WebhookRequest
) -> Promise<MappableResult> {
let promise: Promise<Any> = sendEphemeral(server: server, request: request)
return promise.map {
if let result = Mapper<MappableResult>().map(JSONObject: $0) {
return result
} else {
throw WebhookError.unmappableValue
}
}
}
public func sendEphemeral<MappableResult: BaseMappable>(
server: Server,
request: WebhookRequest
) -> Promise<[MappableResult]> {
let promise: Promise<Any> = sendEphemeral(server: server, request: request)
return promise.map {
if let result = Mapper<MappableResult>(shouldIncludeNilValues: false).mapArray(JSONObject: $0) {
return result
} else {
throw WebhookError.unmappableValue
}
}
}
public func sendEphemeral<ResponseType>(
server: Server,
request: WebhookRequest,
overrideURL: URL? = nil
) -> Promise<ResponseType> {
Current.backgroundTask(withName: "webhook-send-ephemeral") { [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),
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).cauterize()
let promise: Promise<Any> = sendEphemeral(
server: server,
request: request,
overrideURL: activeURL
)
promise.cauterize()
}
}
}
}
// MARK: - Sending Persistent
public func send(
identifier: WebhookResponseIdentifier = .unhandled,
server: Server,
request: WebhookRequest
) -> Promise<Void> {
let (promise, seal) = Promise<Void>.pending()
dataQueue.async { [dataQueue] in
let sendRegular: () -> Promise<Void> = { [self] in
send(
on: currentRegularSessionInfo,
server: server,
identifier: identifier,
request: request,
waitForResponse: true
)
}
let sendBackground: () -> Promise<Void> = { [self] in
send(
on: currentBackgroundSessionInfo,
server: server,
identifier: identifier,
request: request,
waitForResponse: true
)
}
let promise: Promise<Void>
if Current.isBackgroundRequestsImmediate() {
promise = sendBackground()
} else {
Current.Log.info("in background, choosing to not use background session")
promise = sendRegular().recover(on: dataQueue) { error -> Promise<Void> 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<Void> {
let (promise, seal) = Promise<Void>.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<Void> {
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<Void>.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: "webhook-send") { _ 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<Void> {
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),
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<Void>
) {
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))
return
}
}
var urlRequest = try URLRequest(url: webhookURL, method: .post)
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
let jsonObject = Mapper<WebhookRequest>(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<Data?> { seal in
let data = self.pendingDataForTask[taskKey]
self.pendingDataForTask.removeValue(forKey: taskKey)
seal.resolve(error, data)
}.webhookJson(
on: DispatchQueue.global(qos: .utility),
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<Any>,
resolver: Resolver<Void>?
) {
Current.Log.notify("starting \(request.type) to \(server.identifier) (\(handlerType))")
sessionInfo.eventGroup.enter()
Current.backgroundTask(withName: "webhook-invoke") { _ -> Promise<Void> 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<UnitDuration>(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))"
}
}