Files
iOS/Sources/App/WebView/WebViewController.swift
Zac West 77c72785ff Update a few more housekeeping things in the project (#1142)
- Combines all .entitlements into either: App-iOS, App-catalyst, WatchApp, Extension-iOS or Extension-catalyst.
- Cleans up and renames all the schemes to match target names
- Moves around several folders and deletes some old files.
- Converts Podfile to be hierarchical, rather than calling shared methods.
- Always runs MaterialDesignIcons script; aborts early if it's up-to-date.
- Updates all dependencies.
2020-10-03 16:05:19 -07:00

869 lines
36 KiB
Swift

//
// WebViewController.swift
// HomeAssistant
//
// Created by Robert Trencheny on 4/10/17.
// Copyright © 2017 Robbie Trencheny. All rights reserved.
//
import UIKit
import Alamofire
import WebKit
import KeychainAccess
import PromiseKit
import SwiftMessages
import Shared
import AVFoundation
import AVKit
import CoreLocation
// swiftlint:disable:next type_body_length
class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UIViewControllerRestoration {
var webView: WKWebView!
var urlObserver: NSKeyValueObservation?
let refreshControl = UIRefreshControl()
// var remotePlayer = RemoteMediaPlayer()
var keepAliveTimer: Timer?
private var initialURL: URL?
static func viewController(
withRestorationIdentifierPath identifierComponents: [String],
coder: NSCoder
) -> UIViewController? {
if #available(iOS 13, *) {
return nil
} else {
let webViewController = WebViewController(restorationActivity: nil)
// although the system is also going to call through this restoration method, it's going to do it _too late_
webViewController.decodeRestorableState(with: coder)
return webViewController
}
}
let settingsButton: UIButton! = {
let button = UIButton()
button.setImage(MaterialDesignIcons.cogIcon.image(ofSize: CGSize(width: 36, height: 36), color: .white),
for: .normal)
button.accessibilityLabel = L10n.Settings.NavigationBar.title
button.contentEdgeInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
button.setBackgroundImage(
UIImage(size: CGSize(width: 1, height: 1), color: UIColor(red: 1.00, green: 0.60, blue: 0.00, alpha: 1.0)),
for: .normal
)
// size isn't affected by any trait changes, so we can grab the height once and not worry about it changing
let desiredSize = button.systemLayoutSizeFitting(.zero)
button.layer.cornerRadius = ceil(desiredSize.height / 2.0)
button.layer.masksToBounds = true
button.translatesAutoresizingMaskIntoConstraints = false
if Current.appConfiguration == .FastlaneSnapshot {
button.alpha = 0
}
return button
}()
enum RestorableStateKey: String {
case lastURL
}
override func encodeRestorableState(with coder: NSCoder) {
if #available(iOS 13, *) {
} else {
super.encodeRestorableState(with: coder)
coder.encode(webView.url as NSURL?, forKey: RestorableStateKey.lastURL.rawValue)
}
}
override func decodeRestorableState(with coder: NSCoder) {
if #available(iOS 13, *) {
} else {
guard !isViewLoaded else {
// this is state decoding late in the cycle, not our initial one; ignore.
return
}
initialURL = coder.decodeObject(of: NSURL.self, forKey: RestorableStateKey.lastURL.rawValue) as URL?
super.decodeRestorableState(with: coder)
}
}
// swiftlint:disable:next function_body_length
override func viewDidLoad() {
super.viewDidLoad()
if #available(iOS 13, *) {
} else {
restorationClass = Self.self
restorationIdentifier = String(describing: Self.self)
}
self.becomeFirstResponder()
NotificationCenter.default.addObserver(self,
selector: #selector(WebViewController.loadActiveURLIfNeeded),
name: HomeAssistantAPI.didConnectNotification,
object: nil)
let statusBarView = UIView()
statusBarView.tag = 111
view.addSubview(statusBarView)
statusBarView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
statusBarView.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
statusBarView.rightAnchor.constraint(equalTo: self.view.rightAnchor).isActive = true
statusBarView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor).isActive = true
statusBarView.translatesAutoresizingMaskIntoConstraints = false
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
let userContentController = WKUserContentController()
userContentController.add(self, name: "getExternalAuth")
userContentController.add(self, name: "revokeExternalAuth")
userContentController.add(self, name: "externalBus")
userContentController.add(self, name: "updateThemeColors")
userContentController.add(self, name: "currentUser")
userContentController.add(self, name: "mediaPlayerCommand")
if let webhookID = Current.settingsStore.connectionInfo?.webhookID {
let webhookGlobal = "window.webhookID = '\(webhookID)';"
userContentController.addUserScript(WKUserScript(source: webhookGlobal, injectionTime: .atDocumentStart,
forMainFrameOnly: false))
}
guard let wsBridgeJSPath = Bundle.main.path(forResource: "WebSocketBridge", ofType: "js"),
let wsBridgeJS = try? String(contentsOfFile: wsBridgeJSPath) else {
fatalError("Couldn't load WebSocketBridge.js for injection to WKWebView!")
}
userContentController.addUserScript(WKUserScript(source: wsBridgeJS, injectionTime: .atDocumentEnd,
forMainFrameOnly: false))
if Current.appConfiguration == .FastlaneSnapshot {
let hideDemoCardScript = WKUserScript(source: "localStorage.setItem('hide_demo_card', '1');",
injectionTime: .atDocumentStart,
forMainFrameOnly: false)
userContentController.addUserScript(hideDemoCardScript)
if let langCode = Locale.current.languageCode {
// swiftlint:disable:next line_length
let setLanguageScript = WKUserScript(source: "localStorage.setItem('selectedLanguage', '\"\(langCode)\"');",
injectionTime: .atDocumentStart,
forMainFrameOnly: false)
userContentController.addUserScript(setLanguageScript)
}
SettingsViewController.showMapContentExtension()
}
config.userContentController = userContentController
// "Mobile/BUILD_NUMBER" is what CodeMirror sniffs for to decide iOS or not; other things likely look for Safari
config.applicationNameForUserAgent = HomeAssistantAPI.userAgent + " Mobile/HomeAssistant, like Safari"
self.webView = WKWebView(frame: self.view!.frame, configuration: config)
self.webView.isOpaque = false
self.view!.addSubview(webView)
urlObserver = self.webView.observe(\.url) { [weak self] (webView, _) in
guard let self = self else { return }
guard let currentURL = webView.url?.absoluteString.replacingOccurrences(of: "?external_auth=1", with: ""),
let cleanURL = URL(string: currentURL), let scheme = cleanURL.scheme
else {
return
}
guard scheme.hasPrefix("http") else {
Current.Log.warning("Was going to provide invalid URL to NSUserActivity! \(currentURL)")
return
}
self.userActivity?.webpageURL = cleanURL
self.userActivity?.userInfo = [RestorableStateKey.lastURL.rawValue: cleanURL]
self.userActivity?.becomeCurrent()
}
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
self.webView.translatesAutoresizingMaskIntoConstraints = false
self.webView.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
self.webView.rightAnchor.constraint(equalTo: self.view.rightAnchor).isActive = true
self.webView.topAnchor.constraint(equalTo: statusBarView.bottomAnchor).isActive = true
self.webView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
if !Current.isCatalyst {
// refreshing is handled by menu/keyboard shortcuts
refreshControl.addTarget(self, action: #selector(self.pullToRefresh(_:)), for: .valueChanged)
webView.scrollView.addSubview(refreshControl)
webView.scrollView.bounces = true
}
self.settingsButton.addTarget(self, action: #selector(self.openSettingsView(_:)), for: .touchDown)
self.view.addSubview(self.settingsButton)
self.view.bottomAnchor.constraint(equalTo: self.settingsButton.bottomAnchor, constant: 16.0).isActive = true
self.view.rightAnchor.constraint(equalTo: self.settingsButton.rightAnchor, constant: 16.0).isActive = true
NotificationCenter.default.addObserver(
self,
selector: #selector(updateWebViewSettings),
name: SettingsStore.webViewRelatedSettingDidChange,
object: nil
)
updateWebViewSettings()
styleUI()
}
public func showSettingsViewController() {
if #available(iOS 13, *), Current.sceneManager.supportsMultipleScenes {
Current.sceneManager.activateAnyScene(for: .settings)
} else {
let settingsView = SettingsViewController()
settingsView.hidesBottomBarWhenPushed = true
let navController = UINavigationController(rootViewController: settingsView)
self.navigationController?.present(navController, animated: true, completion: nil)
}
}
// Workaround for webview rotation issues: https://github.com/Telerik-Verified-Plugins/WKWebView/pull/263
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
self.webView?.setNeedsLayout()
self.webView?.layoutIfNeeded()
}, completion: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: false)
// if we aren't showing a url or it's an incorrect url, update it -- otherwise, leave it alone
if let connectionInfo = Current.settingsStore.connectionInfo,
let webviewURL = connectionInfo.webviewURL(),
webView.url == nil || webView.url?.baseIsEqual(to: webviewURL) == false {
let myRequest: URLRequest
if Current.settingsStore.restoreLastURL,
let initialURL = initialURL, initialURL.baseIsEqual(to: webviewURL) {
Current.Log.info("restoring initial url")
myRequest = URLRequest(url: initialURL)
} else {
Current.Log.info("loading default url")
myRequest = URLRequest(url: webviewURL)
}
self.webView.load(myRequest)
}
}
override func viewWillDisappear(_ animated: Bool) {
self.navigationController?.setNavigationBarHidden(false, animated: false)
self.userActivity?.resignCurrent()
}
init(restorationActivity: NSUserActivity?) {
super.init(nibName: nil, bundle: nil)
userActivity = with(NSUserActivity(activityType: "\(Constants.BundleID).frontend")) {
$0.isEligibleForHandoff = true
}
if let url = restorationActivity?.userInfo?[RestorableStateKey.lastURL.rawValue] as? URL {
initialURL = url
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.urlObserver = nil
}
private func styleUI() {
precondition(isViewLoaded && webView != nil)
let cachedColors = ThemeColors.cachedThemeColors(for: traitCollection)
self.webView?.backgroundColor = cachedColors[.primaryBackgroundColor]
self.webView?.scrollView.backgroundColor = cachedColors[.primaryBackgroundColor]
if let statusBarView = self.view.viewWithTag(111) {
statusBarView.backgroundColor = cachedColors[.appHeaderBackgroundColor]
}
self.refreshControl.tintColor = cachedColors[.primaryColor]
let headerBackgroundIsLight = cachedColors[.appHeaderBackgroundColor].isLight
if #available(iOS 13, *) {
self.underlyingPreferredStatusBarStyle = headerBackgroundIsLight ? .darkContent : .lightContent
} else {
self.underlyingPreferredStatusBarStyle = headerBackgroundIsLight ? .default : .lightContent
}
setNeedsStatusBarAppearanceUpdate()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if #available(iOS 13, *), traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
webView.evaluateJavaScript("notifyThemeColors()", completionHandler: nil)
}
}
public func open(inline url: URL) {
loadViewIfNeeded()
webView.load(URLRequest(url: url))
}
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if navigationAction.targetFrame == nil {
openURLInBrowser(navigationAction.request.url!, self)
}
return nil
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
self.refreshControl.endRefreshing()
if let err = error as? URLError {
if err.code != .cancelled {
Current.Log.error("Failure during nav: \(err)")
}
if !error.isCancelled {
openSettingsWithError(error: error)
}
}
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
self.refreshControl.endRefreshing()
if let err = error as? URLError {
if err.code != .cancelled {
Current.Log.error("Failure during content load: \(error)")
}
if !error.isCancelled {
openSettingsWithError(error: error)
}
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
self.refreshControl.endRefreshing()
// in case the view appears again, don't reload
initialURL = nil
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
) {
guard navigationResponse.isForMainFrame else {
// we don't need to modify the response if it's for a sub-frame
decisionHandler(.allow)
return
}
guard let httpResponse = navigationResponse.response as? HTTPURLResponse, httpResponse.statusCode >= 400 else {
// not an error response, we don't need to inspect at all
decisionHandler(.allow)
return
}
// error response, let's inspect if it's restoring a page or normal navigation
if navigationResponse.response.url != initialURL {
// just a normal loading error
decisionHandler(.allow)
} else {
// first: clear that saved url, it's bad
initialURL = nil
// it's for the restored page, let's load the default url
if let connectionInfo = Current.settingsStore.connectionInfo,
let webviewURL = connectionInfo.webviewURL() {
decisionHandler(.cancel)
webView.load(URLRequest(url: webviewURL))
} else {
// we don't have anything we can do about this
decisionHandler(.allow)
}
}
}
// WKUIDelegate
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
let style: UIAlertController.Style = {
switch webView.traitCollection.userInterfaceIdiom {
case .carPlay, .phone, .tv:
return .actionSheet
#if compiler(>=5.3)
case .mac:
return.alert
#endif
case .pad, .unspecified:
// without a touch to tell us where, an action sheet in the middle of the screen isn't great
return .alert
@unknown default:
return .alert
}
}()
let alertController = UIAlertController(title: nil, message: message, preferredStyle: style)
alertController.addAction(UIAlertAction(title: L10n.Alerts.Confirm.ok, style: .default, handler: { _ in
completionHandler(true)
}))
alertController.addAction(UIAlertAction(title: L10n.Alerts.Confirm.cancel, style: .cancel, handler: { _ in
completionHandler(false)
}))
present(alertController, animated: true, completion: nil)
}
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
let alertController = UIAlertController(title: nil, message: prompt, preferredStyle: .alert)
alertController.addTextField { (textField) in
textField.text = defaultText
}
alertController.addAction(UIAlertAction(title: L10n.Alerts.Prompt.ok, style: .default, handler: { _ in
if let text = alertController.textFields?.first?.text {
completionHandler(text)
} else {
completionHandler(defaultText)
}
}))
alertController.addAction(UIAlertAction(title: L10n.Alerts.Prompt.cancel, style: .cancel, handler: { _ in
completionHandler(nil)
}))
present(alertController, animated: true, completion: nil)
}
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: L10n.Alerts.Alert.ok, style: .default, handler: { _ in
completionHandler()
}))
alertController.popoverPresentationController?.sourceView = self.webView
present(alertController, animated: true, completion: nil)
}
@objc private func loadActiveURLIfNeeded() {
guard let desiredURL = Current.settingsStore.connectionInfo?.webviewURL() else {
return
}
guard webView.url == nil || webView.url?.baseIsEqual(to: desiredURL) == false else {
Current.Log.info("not changing webview url - we're okay with what we have")
// we also tell the webview -- maybe it failed to connect itself? -- to refresh if needed
webView.evaluateJavaScript("checkForMissingHassConnectionAndReload()", completionHandler: nil)
return
}
Current.Log.verbose("Changing webview to current active URL!")
webView.load(URLRequest(url: desiredURL))
}
@objc private func refresh() {
// called via menu/keyboard shortcut too
if let webviewURL = Current.settingsStore.connectionInfo?.webviewURL() {
if webView.url?.baseIsEqual(to: webviewURL) == true {
webView.reload()
} else {
webView.load(URLRequest(url: webviewURL))
}
}
}
@objc private func updateSensors() {
// called via menu/keyboard shortcut too
firstly {
HomeAssistantAPI.authenticatedAPIPromise
}.then { api -> Promise<Void> in
func updateWithoutLocation() -> Promise<Void> {
return when(fulfilled: [
api.UpdateSensors(trigger: .Manual).asVoid(),
api.updateComplications().asVoid()
])
}
if Current.settingsStore.isLocationEnabled(for: UIApplication.shared.applicationState) {
return Current.backgroundTask(withName: "manual-location-update") { remaining in
return api.GetAndSendLocation(trigger: .Manual, maximumBackgroundTime: remaining)
.recover { error -> Promise<Void> in
if error is CLError {
Current.Log.info("couldn't get location, sending remaining sensor data")
return updateWithoutLocation()
} else {
throw error
}
}
}
} else {
return updateWithoutLocation()
}
}.catch { error in
self.showSwiftMessageError((error as NSError).localizedDescription)
}
}
@objc func pullToRefresh(_ sender: UIRefreshControl) {
refresh()
updateSensors()
}
func showSwiftMessageError(_ body: String, duration: SwiftMessages.Duration = .automatic) {
var config = SwiftMessages.Config()
config.presentationContext = .window(windowLevel: .statusBar)
config.duration = duration
let view = MessageView.viewFromNib(layout: .statusLine)
view.configureTheme(.error)
view.configureContent(body: body)
SwiftMessages.show(config: config, view: view)
}
func openSettingsWithError(error: Error) {
self.showSwiftMessageError(error.localizedDescription, duration: SwiftMessages.Duration.seconds(seconds: 10))
self.showSwiftMessageError(error.localizedDescription)
// let alert = UIAlertController(title: L10n.errorLabel, message: error.localizedDescription,
// preferredStyle: .alert)
// alert.addAction(UIAlertAction(title: L10n.okLabel, style: UIAlertAction.Style.default, handler: nil))
// self.navigationController?.present(alert, animated: true, completion: nil)
// alert.popoverPresentationController?.sourceView = self.webView
/* let settingsView = SettingsViewController()
settingsView.showErrorConnectingMessage = true
settingsView.showErrorConnectingMessageError = error
settingsView.doneButton = true
settingsView.delegate = self
let navController = UINavigationController(rootViewController: settingsView)
self.navigationController?.present(navController, animated: true, completion: nil) */
}
@objc func openSettingsView(_ sender: UIButton) {
self.showSettingsViewController()
}
private var underlyingPreferredStatusBarStyle: UIStatusBarStyle = .lightContent
override var preferredStatusBarStyle: UIStatusBarStyle {
return underlyingPreferredStatusBarStyle
}
@objc private func updateWebViewSettings() {
// iOS 14's `pageZoom` property is almost this, but not quite - it breaks the layout as well
if #available(iOS 12, *) {
// This is quasi-private API that has existed since pre-iOS 10, but the implementation
// changed in iOS 12 to be like the +/- zoom buttons in Safari, which scale content without
// resizing the scrolling viewport.
let viewScale = Current.settingsStore.pageZoom.viewScaleValue
Current.Log.info("setting view scale to \(viewScale)")
webView.setValue(viewScale, forKey: "viewScale")
}
}
}
extension String {
func matchingStrings(regex: String) -> [[String]] {
guard let regex = try? NSRegularExpression(pattern: regex, options: []) else { return [] }
let nsString = self as NSString
let results = regex.matches(in: self, options: [], range: NSRange(location: 0, length: nsString.length))
return results.map { result in
(0..<result.numberOfRanges).map {
result.range(at: $0).location != NSNotFound
? nsString.substring(with: result.range(at: $0))
: ""
}
}
}
}
extension WebViewController: WKScriptMessageHandler {
// swiftlint:disable:next function_body_length
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let messageBody = message.body as? [String: Any] else { return }
// Current.Log.verbose("Received script message \(message.name) \(message.body)")
/* if message.name == "mediaPlayerCommand" {
guard let cmdStr = messageBody["type"] as? String, let cmd = RemotePlayerCommands(rawValue: cmdStr) else {
Current.Log.error("No command type in payload! \(messageBody)")
return
}
self.remotePlayer.handleCommand(cmd, messageBody["data"])
} else */ if message.name == "externalBus" {
self.handleExternalMessage(messageBody)
} else if message.name == "currentUser", let user = AuthenticatedUser(messageBody) {
Current.settingsStore.authenticatedUser = user
} else if message.name == "updateThemeColors" {
self.handleThemeUpdate(messageBody)
} else if message.name == "getExternalAuth", let callbackName = messageBody["callback"] {
let force = messageBody["force"] as? Bool ?? false
if let tokenManager = Current.tokenManager {
Current.Log.verbose("getExternalAuth called, forced: \(force)")
tokenManager.authDictionaryForWebView(forceRefresh: force).done { dictionary in
let jsonData = try? JSONSerialization.data(withJSONObject: dictionary, options: [])
if let jsonString = String(data: jsonData!, encoding: .utf8) {
// swiftlint:disable:next line_length
// Current.Log.verbose("Responding to getExternalAuth with: \(callbackName)(true, \(jsonString))")
let script = "\(callbackName)(true, \(jsonString))"
self.webView.evaluateJavaScript(script, completionHandler: { (result, error) in
if let error = error {
Current.Log.error("Failed to trigger getExternalAuth callback: \(error)")
}
Current.Log.verbose("Success on getExternalAuth callback: \(String(describing: result))")
})
}
}.catch { error in
self.webView.evaluateJavaScript("\(callbackName)(false, 'Token unavailable')")
Current.Log.error("Failed to authenticate webview: \(error)")
}
} else {
self.webView.evaluateJavaScript("\(callbackName)(false, 'Token unavailable')")
Current.Log.error("Failed to authenticate webview. Token Unavailable")
}
} else if message.name == "revokeExternalAuth", let callbackName = messageBody["callback"],
let tokenManager = Current.tokenManager {
Current.Log.warning("Revoking access token")
tokenManager.revokeToken().done { _ in
Current.tokenManager = nil
Current.settingsStore.connectionInfo = nil
Current.settingsStore.tokenInfo = nil
let script = "\(callbackName)(true)"
Current.Log.verbose("Running revoke external auth callback \(script)")
self.webView.evaluateJavaScript(script, completionHandler: { (_, error) in
Current.onboardingObservation.needed(.logout)
if let error = error {
Current.Log.error("Failed calling sign out callback: \(error)")
}
Current.Log.verbose("Successfully informed web client of log out.")
})
}.catch { error in
Current.Log.error("Failed to revoke token: \(error)")
}
}
}
func handleThemeUpdate(_ messageBody: [String: Any]) {
ThemeColors.updateCache(with: messageBody, for: traitCollection)
styleUI()
}
func handleHaptic(_ hapticType: String) {
Current.Log.verbose("Handle haptic type \(hapticType)")
switch hapticType {
case "success":
UINotificationFeedbackGenerator().notificationOccurred(.success)
case "error", "failure":
UINotificationFeedbackGenerator().notificationOccurred(.error)
case "warning":
UINotificationFeedbackGenerator().notificationOccurred(.warning)
case "light":
UIImpactFeedbackGenerator(style: .light).impactOccurred()
case "medium":
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
case "heavy":
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
case "selection":
UISelectionFeedbackGenerator().selectionChanged()
default:
Current.Log.verbose("Unknown haptic type \(hapticType)")
}
}
// swiftlint:disable:next cyclomatic_complexity function_body_length
func handleExternalMessage(_ dictionary: [String: Any]) {
guard let incomingMessage = WebSocketMessage(dictionary) else {
Current.Log.error("Received invalid external message \(dictionary)")
return
}
// Current.Log.verbose("Received external bus message \(incomingMessage)")
var response: Guarantee<WebSocketMessage>?
switch incomingMessage.MessageType {
case "config/get":
response = Guarantee { seal in
DispatchQueue.global(qos: .userInitiated).async {
seal(WebSocketMessage(
id: incomingMessage.ID!,
type: "result",
result: [
"hasSettingsScreen": !Current.isCatalyst,
"canWriteTag": Current.tags.isNFCAvailable
]
))
}
}
case "config_screen/show":
self.showSettingsViewController()
case "haptic":
guard let hapticType = incomingMessage.Payload?["hapticType"] as? String else {
Current.Log.error("Received haptic via bus but hapticType was not string! \(incomingMessage)")
return
}
self.handleHaptic(hapticType)
case "connection-status":
guard let connEvt = incomingMessage.Payload?["event"] as? String else {
Current.Log.error("Received connection-status via bus but event was not string! \(incomingMessage)")
return
}
// Possible values: connected, disconnected, auth-invalid
UIView.animate(withDuration: 1.0, delay: 0, options: .curveEaseInOut, animations: {
self.settingsButton.alpha = connEvt == "connected" ? 0.0 : 1.0
}, completion: nil)
case "tag/read":
response = Current.tags.readNFC().map { tag in
WebSocketMessage(id: incomingMessage.ID!, type: "result", result: [ "success": true, "tag": tag ])
}.recover { _ in
.value(WebSocketMessage(id: incomingMessage.ID!, type: "result", result: [ "success": false ] ))
}
case "tag/write":
let (promise, seal) = Guarantee<Bool>.pending()
response = promise.map { success in
WebSocketMessage(id: incomingMessage.ID!, type: "result", result: [ "success": success ])
}
firstly { () throws -> Promise<(tag: String, name: String?)> in
if let tag = incomingMessage.Payload?["tag"] as? String, tag.isEmpty == false {
return .value((tag: tag, name: incomingMessage.Payload?["name"] as? String))
} else {
throw HomeAssistantAPI.APIError.invalidResponse
}
}.then { tagInfo in
Current.tags.writeNFC(value: tagInfo.tag)
}.done { _ in
Current.Log.info("wrote tag via external bus")
seal(true)
}.catch { error in
Current.Log.error("couldn't write tag via external bus: \(error)")
seal(false)
}
default:
Current.Log.error("Received unknown external message \(incomingMessage)")
return
}
response?.done(on: .main) { outgoing in
// Current.Log.verbose("Sending response to \(outgoing)")
var encodedMsg: Data?
do {
encodedMsg = try JSONEncoder().encode(outgoing)
} catch let error as NSError {
Current.Log.error("Unable to encode outgoing message! \(error)")
return
}
guard let jsonString = String(data: encodedMsg!, encoding: .utf8) else {
Current.Log.error("Could not convert JSON Data to JSON String")
return
}
let script = "window.externalBus(\(jsonString))"
// Current.Log.verbose("Sending message to externalBus \(script)")
self.webView.evaluateJavaScript(script, completionHandler: { (_, error) in
if let error = error {
Current.Log.error("Failed to fire message to externalBus: \(error)")
}
/* if let result = result {
Current.Log.verbose("Success on firing message to externalBus: \(String(describing: result))")
} else {
Current.Log.verbose("Sent message to externalBus")
} */
})
}
}
}
extension WebViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height {
scrollView.contentOffset.y = scrollView.contentSize.height - scrollView.bounds.height
}
}
}
extension ConnectionInfo {
func webviewURL() -> URL? {
if Current.appConfiguration == .FastlaneSnapshot, prefs.object(forKey: "useDemo") != nil {
return URL(string: "https://companion.home-assistant.io/app/ios/demo")!
}
guard var components = URLComponents(url: self.activeURL, resolvingAgainstBaseURL: true) else {
return nil
}
if Current.settingsStore.tokenInfo != nil {
let queryItem = URLQueryItem(name: "external_auth", value: "1")
components.queryItems = [queryItem]
}
return try? components.asURL()
}
func webviewURL(from raw: String) -> URL? {
guard let baseURL = webviewURL() else {
return nil
}
if raw.starts(with: "/") {
return baseURL.appendingPathComponent(raw)
} else if let url = URL(string: raw), url.baseIsEqual(to: baseURL) {
return url
} else {
return nil
}
}
// swiftlint:disable:next file_length
}