// // 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 in func updateWithoutLocation() -> Promise { 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 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..? 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.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 }