558 lines
21 KiB
Swift
558 lines
21 KiB
Swift
import CallbackURLKit
|
|
import Foundation
|
|
import PromiseKit
|
|
import SafariServices
|
|
import Shared
|
|
|
|
class IncomingURLHandler {
|
|
private(set) weak var windowController: WebViewWindowController!
|
|
|
|
init(windowController: WebViewWindowController) {
|
|
self.windowController = windowController
|
|
registerCallbackURLKitHandlers()
|
|
}
|
|
|
|
@discardableResult
|
|
func handle(url: URL) -> Bool {
|
|
Current.Log.verbose("Received URL: \(url)")
|
|
var serviceData: [String: String] = [:]
|
|
if let queryItems = url.queryItems {
|
|
serviceData = queryItems
|
|
}
|
|
guard let host = url.host else { return true }
|
|
switch host.lowercased() {
|
|
case "x-callback-url":
|
|
return Manager.shared.handleOpen(url: url)
|
|
case "call_service":
|
|
confirmAction(
|
|
title: L10n.UrlHandler.CallService.Confirm.title,
|
|
message: L10n.UrlHandler.CallService.Confirm.message(url.pathComponents[1]),
|
|
handler: { self.callServiceURLHandler(url, serviceData) }
|
|
)
|
|
case "fire_event":
|
|
confirmAction(
|
|
title: L10n.UrlHandler.FireEvent.Confirm.title,
|
|
message: L10n.UrlHandler.FireEvent.Confirm.message(url.pathComponents[1]),
|
|
handler: { self.fireEventURLHandler(url, serviceData) }
|
|
)
|
|
case "send_location":
|
|
confirmAction(
|
|
title: L10n.UrlHandler.SendLocation.Confirm.title,
|
|
message: L10n.UrlHandler.SendLocation.Confirm.message,
|
|
handler: { self.sendLocationURLHandler() }
|
|
)
|
|
case "perform_action":
|
|
performActionURLHandler(url, serviceData: serviceData)
|
|
case "navigate": // homeassistant://navigate/lovelace/dashboard
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
return false
|
|
}
|
|
|
|
components.scheme = nil
|
|
components.host = nil
|
|
|
|
let queryParameters = components.queryItems
|
|
let isFromWidget = components.popWidgetAuthenticity()
|
|
let server = components.popWidgetServer(isFromWidget: isFromWidget)
|
|
|
|
guard let rawURL = components.url?.absoluteString else {
|
|
return false
|
|
}
|
|
|
|
if
|
|
let presenting = windowController.presentedViewController,
|
|
presenting is SFSafariViewController {
|
|
// Dismiss my.* controller if it's on top - we don't get any other indication
|
|
presenting.dismiss(animated: true, completion: { [windowController] in
|
|
windowController?.openSelectingServer(
|
|
from: .deeplink,
|
|
urlString: rawURL,
|
|
skipConfirm: true,
|
|
queryParameters: queryParameters
|
|
)
|
|
})
|
|
} else if let server {
|
|
windowController.open(from: .deeplink, server: server, urlString: rawURL, skipConfirm: isFromWidget)
|
|
} else {
|
|
windowController.openSelectingServer(
|
|
from: .deeplink,
|
|
urlString: rawURL,
|
|
skipConfirm: isFromWidget,
|
|
queryParameters: queryParameters
|
|
)
|
|
}
|
|
default:
|
|
Current.Log.warning("Can't route incoming URL: \(url)")
|
|
showAlert(title: L10n.errorLabel, message: L10n.UrlHandler.NoService.message(url.host!))
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func handle(userActivity: NSUserActivity) -> Bool {
|
|
Current.Log.info(userActivity)
|
|
|
|
if let assistInAppIntent = userActivity.interaction?.intent as? AssistInAppIntent {
|
|
guard let server = Current.servers.server(for: assistInAppIntent) ?? Current.servers.all.first else { return false }
|
|
let pipeline = assistInAppIntent.pipeline
|
|
let autoStartRecording = Bool(exactly: assistInAppIntent.withVoice ?? 0) ?? false
|
|
|
|
windowController.webViewControllerPromise.pipe { result in
|
|
switch result {
|
|
case let .fulfilled(webView):
|
|
webView.webViewExternalMessageHandler.showAssist(
|
|
server: server,
|
|
pipeline: pipeline?.identifier ?? "",
|
|
autoStartRecording: autoStartRecording,
|
|
animated: false
|
|
)
|
|
case let .rejected(error):
|
|
Current.Log.error("Failed to obtain webview to open Assist In App: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
switch Current.tags.handle(userActivity: userActivity) {
|
|
case let .handled(type):
|
|
let (icon, text) = { () -> (MaterialDesignIcons, String) in
|
|
switch type {
|
|
case .nfc:
|
|
return (.nfcVariantIcon, L10n.Nfc.tagRead)
|
|
case .generic:
|
|
return (.qrcodeIcon, L10n.Nfc.genericTagRead)
|
|
}
|
|
}()
|
|
|
|
Current.sceneManager.showFullScreenConfirm(
|
|
icon: icon,
|
|
text: text,
|
|
onto: .value(windowController.window)
|
|
)
|
|
return true
|
|
case let .open(url):
|
|
// NFC-based URL
|
|
return handle(url: url)
|
|
case .unhandled:
|
|
// not a tag
|
|
if let url = userActivity.webpageURL, url.host?.lowercased() == "my.home-assistant.io" {
|
|
return showMy(for: url)
|
|
} else if let interaction = userActivity.interaction {
|
|
if
|
|
let intent = interaction.intent as? OpenPageIntent,
|
|
let panel = intent.page, let path = panel.identifier {
|
|
Current.Log.info("launching from shortcuts with panel \(panel)")
|
|
|
|
let urlString = "/" + path
|
|
if let server = Current.servers.server(for: panel) {
|
|
windowController.open(
|
|
from: .deeplink,
|
|
server: server,
|
|
urlString: urlString,
|
|
skipConfirm: true
|
|
)
|
|
} else {
|
|
windowController.openSelectingServer(
|
|
from: .deeplink,
|
|
urlString: urlString,
|
|
skipConfirm: true
|
|
)
|
|
}
|
|
return true
|
|
}
|
|
|
|
return false
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
func handle(shortcutItem: UIApplicationShortcutItem) -> Promise<Void> {
|
|
Current.backgroundTask(withName: "shortcut-item") { remaining -> Promise<Void> in
|
|
if shortcutItem.type == "sendLocation" {
|
|
return firstly {
|
|
Current.location.oneShotLocation(.AppShortcut, remaining)
|
|
}.then { location in
|
|
when(fulfilled: Current.apis.map { api in
|
|
api.SubmitLocation(updateType: .AppShortcut, location: location, zone: nil)
|
|
})
|
|
}.asVoid()
|
|
} else {
|
|
if
|
|
let action = Current.realm().object(ofType: Action.self, forPrimaryKey: shortcutItem.type),
|
|
let server = Current.servers.server(for: action) {
|
|
Current.sceneManager.showFullScreenConfirm(
|
|
icon: MaterialDesignIcons(named: action.IconName),
|
|
text: action.Text,
|
|
onto: .value(windowController.window)
|
|
)
|
|
|
|
return Current.api(for: server)?
|
|
.HandleAction(actionID: shortcutItem.type, source: .AppShortcut) ??
|
|
.init(error: HomeAssistantAPI.APIError.noAPIAvailable)
|
|
} else {
|
|
return .init(error: HomeAssistantAPI.APIError.notConfigured)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func confirmAction(
|
|
title: String,
|
|
message: String,
|
|
handler: @escaping () -> Void,
|
|
cancelHandler: (() -> Void)? = nil
|
|
) {
|
|
let alert = UIAlertController(
|
|
title: title,
|
|
message: message,
|
|
preferredStyle: UIAlertController.Style.alert
|
|
)
|
|
|
|
alert.addAction(UIAlertAction(
|
|
title: L10n.cancelLabel,
|
|
style: .cancel,
|
|
handler: { _ in
|
|
cancelHandler?()
|
|
}
|
|
))
|
|
|
|
alert.addAction(UIAlertAction(
|
|
title: L10n.yesLabel,
|
|
style: .default,
|
|
handler: { _ in
|
|
handler()
|
|
}
|
|
))
|
|
|
|
windowController?.webViewControllerPromise.done {
|
|
$0.present(alert, animated: true, completion: nil)
|
|
}
|
|
}
|
|
|
|
private func showAlert(title: String, message: String) {
|
|
let alert = UIAlertController(
|
|
title: title,
|
|
message: message,
|
|
preferredStyle: .alert
|
|
)
|
|
alert.addAction(UIAlertAction(title: L10n.okLabel, style: .default, handler: nil))
|
|
windowController?.webViewControllerPromise.done {
|
|
$0.present(alert, animated: true, completion: nil)
|
|
}
|
|
}
|
|
|
|
private func showMy(for url: URL) -> Bool {
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
Current.Log.info("couldn't create url components out of \(url)")
|
|
return false
|
|
}
|
|
|
|
var queryItems = components.queryItems ?? []
|
|
queryItems.append(.init(name: "mobile", value: "1"))
|
|
components.queryItems = queryItems
|
|
|
|
guard let updatedURL = components.url else {
|
|
return false
|
|
}
|
|
|
|
// not animated in because it looks weird during the app launch animation
|
|
windowController?.present(SFSafariViewController(url: updatedURL), animated: false, completion: nil)
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
extension IncomingURLHandler {
|
|
enum XCallbackError: FailureCallbackError {
|
|
case generalError
|
|
case eventNameMissing
|
|
case serviceMissing
|
|
case templateMissing
|
|
|
|
var code: Int {
|
|
switch self {
|
|
case .generalError:
|
|
return 0
|
|
case .eventNameMissing:
|
|
return 1
|
|
case .serviceMissing:
|
|
return 2
|
|
case .templateMissing:
|
|
return 2
|
|
}
|
|
}
|
|
|
|
var message: String {
|
|
switch self {
|
|
case .generalError:
|
|
return L10n.UrlHandler.XCallbackUrl.Error.general
|
|
case .eventNameMissing:
|
|
return L10n.UrlHandler.XCallbackUrl.Error.eventNameMissing
|
|
case .serviceMissing:
|
|
return L10n.UrlHandler.XCallbackUrl.Error.serviceMissing
|
|
case .templateMissing:
|
|
return L10n.UrlHandler.XCallbackUrl.Error.templateMissing
|
|
}
|
|
}
|
|
}
|
|
|
|
private func registerCallbackURLKitHandlers() {
|
|
Manager.shared.callbackURLScheme = Manager.urlSchemes?.first
|
|
|
|
Manager.shared["fire_event"] = { parameters, success, failure, cancel in
|
|
guard let eventName = parameters["eventName"] else {
|
|
failure(XCallbackError.eventNameMissing)
|
|
return
|
|
}
|
|
|
|
var cleanParamters = parameters
|
|
cleanParamters.removeValue(forKey: "eventName")
|
|
let eventData = cleanParamters
|
|
|
|
self.confirmAction(
|
|
title: L10n.UrlHandler.FireEvent.Confirm.title,
|
|
message: L10n.UrlHandler.FireEvent.Confirm.message(eventName),
|
|
handler: {
|
|
firstly { () -> Promise<Void> in
|
|
if let api = Current.apis.first {
|
|
return api.CreateEvent(eventType: eventName, eventData: eventData)
|
|
} else {
|
|
throw XCallbackError.generalError
|
|
}
|
|
}.done {
|
|
success(nil)
|
|
}.catch { error in
|
|
Current.Log.error("Received error from createEvent during X-Callback-URL call: \(error)")
|
|
failure(XCallbackError.generalError)
|
|
}
|
|
},
|
|
cancelHandler: {
|
|
cancel()
|
|
}
|
|
)
|
|
}
|
|
|
|
Manager.shared["call_service"] = { parameters, success, failure, cancel in
|
|
guard let service = parameters["service"] else {
|
|
failure(XCallbackError.serviceMissing)
|
|
return
|
|
}
|
|
|
|
let splitService = service.components(separatedBy: ".")
|
|
let serviceDomain = splitService[0]
|
|
let serviceName = splitService[1]
|
|
|
|
var cleanParamters = parameters
|
|
cleanParamters.removeValue(forKey: "service")
|
|
let serviceData = cleanParamters
|
|
|
|
self.confirmAction(
|
|
title: L10n.UrlHandler.CallService.Confirm.title,
|
|
message: L10n.UrlHandler.CallService.Confirm.message(service),
|
|
handler: {
|
|
firstly { () -> Promise<Void> in
|
|
if let api = Current.apis.first {
|
|
return api.CallService(
|
|
domain: serviceDomain,
|
|
service: serviceName,
|
|
serviceData: serviceData
|
|
)
|
|
} else {
|
|
throw XCallbackError.generalError
|
|
}
|
|
}.done {
|
|
success(nil)
|
|
}.catch { error in
|
|
Current.Log.error("Received error from callService during X-Callback-URL call: \(error)")
|
|
failure(XCallbackError.generalError)
|
|
}
|
|
},
|
|
cancelHandler: {
|
|
cancel()
|
|
}
|
|
)
|
|
}
|
|
|
|
Manager.shared["send_location"] = { _, success, failure, cancel in
|
|
|
|
self.confirmAction(
|
|
title: L10n.UrlHandler.SendLocation.Confirm.title,
|
|
message: L10n.UrlHandler.SendLocation.Confirm.message,
|
|
handler: {
|
|
firstly {
|
|
Current.location.oneShotLocation(.XCallbackURL, nil)
|
|
}.then { location in
|
|
when(fulfilled: Current.apis.map { api in
|
|
api.SubmitLocation(updateType: .XCallbackURL, location: location, zone: nil)
|
|
})
|
|
}.done { _ in
|
|
success(nil)
|
|
}.catch { error in
|
|
Current.Log.error("Received error from getAndSendLocation during X-Callback-URL call: \(error)")
|
|
failure(XCallbackError.generalError)
|
|
}
|
|
},
|
|
cancelHandler: {
|
|
cancel()
|
|
}
|
|
)
|
|
}
|
|
|
|
Manager.shared["render_template"] = { parameters, success, failure, cancel in
|
|
guard let template = parameters["template"] else {
|
|
failure(XCallbackError.templateMissing)
|
|
return
|
|
}
|
|
|
|
var cleanParamters = parameters
|
|
cleanParamters.removeValue(forKey: "template")
|
|
let variablesDict = cleanParamters
|
|
|
|
self.confirmAction(
|
|
title: L10n.UrlHandler.RenderTemplate.Confirm.title,
|
|
message: L10n.UrlHandler.RenderTemplate.Confirm.message(template),
|
|
handler: {
|
|
if let api = Current.apis.first, let connection = api.connection {
|
|
connection.subscribe(
|
|
to: .renderTemplate(template, variables: variablesDict),
|
|
initiated: { result in
|
|
if case let .failure(error) = result {
|
|
Current.Log
|
|
.error(
|
|
"Received error from RenderTemplate during X-Callback-URL call: \(error)"
|
|
)
|
|
failure(XCallbackError.generalError)
|
|
}
|
|
}, handler: { token, data in
|
|
token.cancel()
|
|
success(["rendered": String(describing: data.result)])
|
|
}
|
|
)
|
|
} else {
|
|
failure(XCallbackError.generalError)
|
|
}
|
|
},
|
|
cancelHandler: {
|
|
cancel()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private func fireEventURLHandler(_ url: URL, _ serviceData: [String: String]) {
|
|
// homeassistant://fire_event/custom_event?entity_id=device_tracker.entity
|
|
|
|
firstly { () -> Promise<Void> in
|
|
if let api = Current.apis.first {
|
|
return api.CreateEvent(eventType: url.pathComponents[1], eventData: serviceData)
|
|
} else {
|
|
throw HomeAssistantAPI.APIError.notConfigured
|
|
}
|
|
}.done {
|
|
self.showAlert(
|
|
title: L10n.UrlHandler.FireEvent.Success.title,
|
|
message: L10n.UrlHandler.FireEvent.Success.message(url.pathComponents[1])
|
|
)
|
|
}.catch { error in
|
|
self.showAlert(
|
|
title: L10n.errorLabel,
|
|
message: L10n.UrlHandler.FireEvent.Error.message(
|
|
url.pathComponents[1],
|
|
error.localizedDescription
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func callServiceURLHandler(_ url: URL, _ serviceData: [String: String]) {
|
|
// homeassistant://call_service/device_tracker.see?entity_id=device_tracker.entity
|
|
let domain = url.pathComponents[1].components(separatedBy: ".")[0]
|
|
let service = url.pathComponents[1].components(separatedBy: ".")[1]
|
|
|
|
firstly { () -> Promise<Void> in
|
|
if let api = Current.apis.first {
|
|
return api.CallService(domain: domain, service: service, serviceData: serviceData)
|
|
} else {
|
|
throw HomeAssistantAPI.APIError.notConfigured
|
|
}
|
|
}.done { _ in
|
|
self.showAlert(
|
|
title: L10n.UrlHandler.CallService.Success.title,
|
|
message: L10n.UrlHandler.CallService.Success.message(url.pathComponents[1])
|
|
)
|
|
}.catch { error in
|
|
self.showAlert(
|
|
title: L10n.errorLabel,
|
|
message: L10n.UrlHandler.CallService.Error.message(
|
|
url.pathComponents[1],
|
|
error.localizedDescription
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func sendLocationURLHandler() {
|
|
// homeassistant://send_location/
|
|
firstly {
|
|
Current.location.oneShotLocation(.URLScheme, nil)
|
|
}.then { location in
|
|
when(fulfilled: Current.apis.map { api in
|
|
api.SubmitLocation(updateType: .URLScheme, location: location, zone: nil)
|
|
})
|
|
}.done { _ in
|
|
self.showAlert(
|
|
title: L10n.UrlHandler.SendLocation.Success.title,
|
|
message: L10n.UrlHandler.SendLocation.Success.message
|
|
)
|
|
}.catch { error in
|
|
self.showAlert(
|
|
title: L10n.errorLabel,
|
|
message: L10n.UrlHandler.SendLocation.Error.message(error.localizedDescription)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func performActionURLHandler(_ url: URL, serviceData: [String: String]) {
|
|
let pathComponents = url.pathComponents
|
|
guard pathComponents.count > 1 else {
|
|
Current.Log.error("not enough path components for perform action handler")
|
|
return
|
|
}
|
|
|
|
let source: HomeAssistantAPI.ActionSource = {
|
|
if
|
|
let sourceString = serviceData["source"],
|
|
let source = HomeAssistantAPI.ActionSource(rawValue: sourceString) {
|
|
return source
|
|
} else {
|
|
return .URLHandler
|
|
}
|
|
}()
|
|
|
|
let actionID = url.pathComponents[1]
|
|
|
|
guard
|
|
let action = Current.realm().object(ofType: Action.self, forPrimaryKey: actionID),
|
|
let server = Current.servers.server(for: action),
|
|
let api = Current.api(for: server) else {
|
|
Current.sceneManager.showFullScreenConfirm(
|
|
icon: .alertCircleIcon,
|
|
text: L10n.UrlHandler.Error.actionNotFound,
|
|
onto: .value(windowController.window)
|
|
)
|
|
return
|
|
}
|
|
|
|
Current.sceneManager.showFullScreenConfirm(
|
|
icon: MaterialDesignIcons(named: action.IconName),
|
|
text: action.Text,
|
|
onto: .value(windowController.window)
|
|
)
|
|
|
|
api.HandleAction(actionID: actionID, source: source).cauterize()
|
|
}
|
|
}
|