Multi-server (#1906)

## Summary
Most, but not all, of the changes necessary to support multi-server throughout the app and all its features.

## Screenshots
| Light | Dark |
| ----- | ---- |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 24](https://user-images.githubusercontent.com/74188/143670011-9b9905ac-1b5b-4a82-b9f3-1490465c4ec5.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 26](https://user-images.githubusercontent.com/74188/143670012-0080230a-8f68-4f34-9691-db9f5e825a83.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 30](https://user-images.githubusercontent.com/74188/143670015-ceeac558-e039-4639-a186-b5001ab418b8.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 29](https://user-images.githubusercontent.com/74188/143670016-d72bb69d-83f5-4197-a742-59d208467258.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 47](https://user-images.githubusercontent.com/74188/143670021-6c90c40f-c2f1-4a33-aad9-da6626e99d9d.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 52 45](https://user-images.githubusercontent.com/74188/143670024-e99de69d-61d8-4e12-be73-a172242806a0.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 05](https://user-images.githubusercontent.com/74188/143670033-1a41ac7e-d4d1-458b-974e-2efdaf8e2288.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 03](https://user-images.githubusercontent.com/74188/143670049-baf4db64-64db-4bfb-88cf-4930f9e5661b.png) |
| ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 21](https://user-images.githubusercontent.com/74188/143670053-7ec794f1-857c-4ef6-a92a-5318e90ac6b6.png) | ![Simulator Screen Shot - iPhone 13 Pro - 2021-11-26 at 21 53 19](https://user-images.githubusercontent.com/74188/143670056-a6a5207c-3bba-49fc-b5c6-fc6fa8141f9c.png) |

## Any other notes
- Encapsulates all connectivity, token & server-specific knowledge in a Server model object which gets passed around.
- Updates various places throughout the app to know about and use Server rather than accessing said information through non-server-specific methods.
- Visually requests/notes server in places where it's ambiguous. For example, the Open Page widget will gain a subtitle if multiple servers are set up.
- Allows switching which server is shown in the WebViews. Note that this doesn't take into account multi-window support on iPad/macOS yet.

Most things will migrate successfully however adding an additional server causes things like Shortcuts to start erroring requiring you specify which to use in the particular Shortcut.

Future work necessary:
- Model objects currently clobber each other if their identifiers match. For example, both servers having a zone named `home` means one of them wins the fight for which is known to the app.
- Being remotely logged out on any account causes the app to require onboarding again, when instead it should only do that if the last known server is logged out.
This commit is contained in:
Zac West
2021-11-27 12:33:46 -08:00
committed by GitHub
parent 3ad727934d
commit 5c104f76e9
169 changed files with 5556 additions and 2428 deletions

View File

@@ -4,7 +4,6 @@ import PromiseKit
import UserNotifications
internal enum WebhookError: LocalizedError, Equatable, CancellableError {
case noApi
case unregisteredIdentifier(handler: String)
case unexpectedType(given: String, desire: String)
case unacceptableStatusCode(Int)
@@ -20,8 +19,6 @@ internal enum WebhookError: LocalizedError, Equatable, CancellableError {
var errorDescription: String? {
switch self {
case .noApi:
return L10n.HaApi.ApiError.notConfigured
case .unregisteredIdentifier:
return L10n.HaApi.ApiError.unknown
case let .unexpectedType(given, desire):
@@ -80,6 +77,8 @@ public class WebhookManager: NSObject {
private var responseHandlers = [WebhookResponseIdentifier: WebhookResponseHandler.Type]()
internal var serverCache = [Identifier<Server>: Server]()
// MARK: - Lifecycle
override internal init() {
@@ -176,13 +175,16 @@ public class WebhookManager: NSObject {
// MARK: - Sending Ephemeral
public func sendEphemeral(request: WebhookRequest) -> Promise<Void> {
let promise: Promise<Any> = sendEphemeral(request: request)
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>(request: WebhookRequest) -> Promise<MappableResult> {
let promise: Promise<Any> = sendEphemeral(request: request)
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
@@ -192,8 +194,11 @@ public class WebhookManager: NSObject {
}
}
public func sendEphemeral<MappableResult: BaseMappable>(request: WebhookRequest) -> Promise<[MappableResult]> {
let promise: Promise<Any> = sendEphemeral(request: request)
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
@@ -203,13 +208,13 @@ public class WebhookManager: NSObject {
}
}
public func sendEphemeral<ResponseType>(request: WebhookRequest) -> Promise<ResponseType> {
public func sendEphemeral<ResponseType>(server: Server, request: WebhookRequest) -> Promise<ResponseType> {
Current.backgroundTask(withName: "webhook-send-ephemeral") { [self, dataQueue] _ in
attemptNetworking {
firstly {
Self.urlRequest(for: request)
Self.urlRequest(for: request, server: server)
}.get { _, _ in
Current.Log.info("sending: \(request)")
Current.Log.info("sending to \(server.identifier): \(request)")
}.then(on: dataQueue) { urlRequest, data in
self.currentRegularSessionInfo.session.uploadTask(.promise, with: urlRequest, from: data)
}
@@ -217,7 +222,8 @@ public class WebhookManager: NSObject {
}.then { data, response in
Promise.value(data).webhookJson(
on: DispatchQueue.global(qos: .utility),
statusCode: (response as? HTTPURLResponse)?.statusCode
statusCode: (response as? HTTPURLResponse)?.statusCode,
secretGetter: { server.info.connection.webhookSecret }
)
}.map { possible in
if let value = possible as? ResponseType {
@@ -231,9 +237,9 @@ public class WebhookManager: NSObject {
}.tap { result in
switch result {
case let .fulfilled(response):
Current.Log.info("got successful response for \(request.type): \(response)")
Current.Log.info("got successful response from \(server.identifier) for \(request.type): \(response)")
case let .rejected(error):
Current.Log.error("got failure for \(request.type): \(error)")
Current.Log.error("got failure from \(server.identifier) for \(request.type): \(error)")
}
}
}
@@ -242,17 +248,30 @@ public class WebhookManager: NSObject {
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, identifier: identifier, request: request, waitForResponse: true)
send(
on: currentRegularSessionInfo,
server: server,
identifier: identifier,
request: request,
waitForResponse: true
)
}
let sendBackground: () -> Promise<Void> = { [self] in
send(on: currentBackgroundSessionInfo, identifier: identifier, request: request, waitForResponse: true)
send(
on: currentBackgroundSessionInfo,
server: server,
identifier: identifier,
request: request,
waitForResponse: true
)
}
let promise: Promise<Void>
@@ -280,13 +299,20 @@ public class WebhookManager: NSObject {
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, identifier: identifier, request: request, waitForResponse: false)
.pipe(to: seal.resolve)
send(
on: currentBackgroundSessionInfo,
server: server,
identifier: identifier,
request: request,
waitForResponse: false
)
.pipe(to: seal.resolve)
}
return promise
@@ -294,6 +320,7 @@ public class WebhookManager: NSObject {
private func send(
on sessionInfo: WebhookSessionInfo,
server: Server,
identifier: WebhookResponseIdentifier,
request: WebhookRequest,
waitForResponse: Bool
@@ -305,13 +332,16 @@ public class WebhookManager: NSObject {
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)
Self.urlRequest(for: request, server: server)
}.done(on: dataQueue) { urlRequest, data in
let task: URLSessionUploadTask
let filesToRemove: [URL]
@@ -332,7 +362,7 @@ public class WebhookManager: NSObject {
filesToRemove = []
}
let persisted = WebhookPersisted(request: request, identifier: identifier)
let persisted = WebhookPersisted(server: server.identifier, request: request, identifier: identifier)
task.webhookPersisted = persisted
let taskKey = TaskKey(sessionInfo: sessionInfo, task: task)
@@ -349,6 +379,7 @@ public class WebhookManager: NSObject {
Current.Log.info {
let values = [
"\(taskKey)",
"server(\(server.identifier))",
"type(\(handlerType))",
"request(\(persisted.request))",
]
@@ -363,6 +394,7 @@ public class WebhookManager: NSObject {
self.invoke(
sessionInfo: sessionInfo,
handler: handlerType,
server: server,
request: request,
result: .init(error: error),
resolver: seal
@@ -378,10 +410,11 @@ public class WebhookManager: NSObject {
// MARK: - Testing Connection Info
public func sendTest(baseURL: URL) -> Promise<Void> {
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
@@ -389,7 +422,8 @@ public class WebhookManager: NSObject {
}.then { data, response in
Promise.value(data).webhookJson(
on: DispatchQueue.global(qos: .utility),
statusCode: (response as? HTTPURLResponse)?.statusCode
statusCode: (response as? HTTPURLResponse)?.statusCode,
secretGetter: { server.info.connection.webhookSecret }
)
}.asVoid()
}
@@ -415,7 +449,7 @@ public class WebhookManager: NSObject {
return false
}
if thisType == newType, thisTask != newTask {
if thisType == newType, thisTask != newTask, newPersisted.server == thisPersisted.server {
return newType.shouldReplace(request: newPersisted.request, with: thisPersisted.request)
} else {
return false
@@ -443,26 +477,22 @@ public class WebhookManager: NSObject {
private static func urlRequest(
for request: WebhookRequest,
server: Server,
baseURL: URL? = nil
) -> Promise<(URLRequest, Data)> {
Promise { seal in
guard let connectionInfo = Current.settingsStore.connectionInfo else {
seal.reject(WebhookError.noApi)
return
}
let webhookURL: URL
if let baseURL = baseURL {
webhookURL = baseURL.appendingPathComponent(connectionInfo.webhookPath, isDirectory: false)
webhookURL = baseURL.appendingPathComponent(server.info.connection.webhookPath, isDirectory: false)
} else {
webhookURL = connectionInfo.webhookURL
webhookURL = server.info.connection.webhookURL()
}
var urlRequest = try URLRequest(url: webhookURL, method: .post)
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
let jsonObject = Mapper<WebhookRequest>(context: WebhookRequestContext.server).toJSON(request)
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
@@ -521,23 +551,26 @@ extension WebhookManager: URLSessionDataDelegate {
return
}
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
)
// dispatch
if let (handlerType, persisted) = responseInfo(from: task) {
if let (handlerType, persisted) = responseInfo(from: task),
let server = serverCache[persisted.server] ?? Current.servers.server(for: persisted.server) {
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,
secretGetter: { server.info.connection.webhookSecret }
)
// 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))",
@@ -546,12 +579,13 @@ extension WebhookManager: URLSessionDataDelegate {
return "got response: " + values.joined(separator: ", ")
}
}.catch { error in
Current.Log.error("failed request for \(handlerType): \(error)")
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]
@@ -567,30 +601,32 @@ extension WebhookManager: URLSessionDataDelegate {
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) (\(handlerType))")
Current.Log.notify("starting \(request.type) to \(server.identifier) (\(handlerType))")
sessionInfo.eventGroup.enter()
Current.backgroundTask(withName: "webhook-invoke") { _ in
Current.api.then(on: nil) { (api: HomeAssistantAPI) -> Promise<Void> in
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)
}
Current.backgroundTask(withName: "webhook-invoke") { _ -> Promise<Void> in
let api = Current.api(for: server)
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 when(fulfilled: [handlerPromise.asVoid(), result.asVoid()])
return firstly {
when(fulfilled: [handlerPromise.asVoid(), result.asVoid()])
}.tap {
resolver?.resolve($0)
}.ensure {
Current.Log.notify("finished \(request.type) \(handlerType)")
Current.Log.notify("finished \(request.type) to \(server.identifier) \(handlerType)")
sessionInfo.eventGroup.leave()
}
}.cauterize()