import Alamofire import AVFoundation import AVKit import CoreLocation import HAKit import KeychainAccess import MBProgressHUD import PromiseKit import Shared import SwiftMessages import SwiftUI import UIKit @preconcurrency import WebKit protocol WebViewControllerProtocol: AnyObject { var server: Server { get } var overlayAppController: UIViewController? { get set } func presentOverlayController(controller: UIViewController, animated: Bool) func presentController(_ controller: UIViewController, animated: Bool) func evaluateJavaScript(_ script: String, completion: ((Any?, (any Error)?) -> Void)?) func dismissOverlayController(animated: Bool, completion: (() -> Void)?) func dismissControllerAboveOverlayController() func updateSettingsButton(state: String) func navigateToPath(path: String) func reload() } final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate { var webView: WKWebView! let server: Server private var urlObserver: NSKeyValueObservation? private var tokens = [HACancellable]() private let refreshControl = UIRefreshControl() private let sidebarGestureRecognizer: UIScreenEdgePanGestureRecognizer let webViewExternalMessageHandler = WebViewExternalMessageHandler.build() private var initialURL: URL? /// A view controller presented by a request from the webview var overlayAppController: UIViewController? enum RestorableStateKey: String { case lastURL case server } override var prefersStatusBarHidden: Bool { Current.settingsStore.fullScreen } override var prefersHomeIndicatorAutoHidden: Bool { Current.settingsStore.fullScreen } override func viewDidLoad() { super.viewDidLoad() webViewExternalMessageHandler.webViewController = self becomeFirstResponder() for name: Notification.Name in [ HomeAssistantAPI.didConnectNotification, UIApplication.didBecomeActiveNotification, ] { NotificationCenter.default.addObserver( self, selector: #selector(connectionInfoDidChange), name: name, object: nil ) } NotificationCenter.default.addObserver( self, selector: #selector(scheduleReconnectBackgroundTimer), name: UIApplication.didEnterBackgroundNotification, object: nil ) tokens.append(server.observe { [weak self] _ in self?.connectionInfoDidChange() }) let statusBarView = UIView() statusBarView.tag = 111 view.addSubview(statusBarView) statusBarView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true statusBarView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true statusBarView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true statusBarView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true statusBarView.translatesAutoresizingMaskIntoConstraints = false let config = WKWebViewConfiguration() config.allowsInlineMediaPlayback = true config.mediaTypesRequiringUserActionForPlayback = [] let userContentController = WKUserContentController() let safeScriptMessageHandler = SafeScriptMessageHandler(delegate: self) userContentController.add(safeScriptMessageHandler, name: "getExternalAuth") userContentController.add(safeScriptMessageHandler, name: "revokeExternalAuth") userContentController.add(safeScriptMessageHandler, name: "externalBus") userContentController.add(safeScriptMessageHandler, name: "updateThemeColors") userContentController.add(safeScriptMessageHandler, name: "logError") 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 )) userContentController.addUserScript(.init( source: """ window.addEventListener("error", (e) => { window.webkit.messageHandlers.logError.postMessage({ "message": JSON.stringify(e.message), "filename": JSON.stringify(e.filename), "lineno": JSON.stringify(e.lineno), "colno": JSON.stringify(e.colno), }); }); """, injectionTime: .atDocumentStart, forMainFrameOnly: false )) config.userContentController = userContentController config.applicationNameForUserAgent = HomeAssistantAPI.applicationNameForUserAgent config.defaultWebpagePreferences.preferredContentMode = Current.isCatalyst ? .desktop : .mobile webView = WKWebView(frame: view!.frame, configuration: config) webView.isOpaque = false view!.addSubview(webView) for direction: UISwipeGestureRecognizer.Direction in [.left, .right] { webView.addGestureRecognizer(with(UISwipeGestureRecognizer(target: self, action: #selector(swipe(_:)))) { $0.numberOfTouchesRequired = 2 $0.direction = direction }) } webView.addGestureRecognizer(sidebarGestureRecognizer) urlObserver = webView.observe(\.url) { [weak self] webView, _ in guard let 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 ["http", "https"].contains(scheme) else { Current.Log.warning("Was going to provide invalid URL to NSUserActivity! \(currentURL)") return } userActivity?.webpageURL = cleanURL userActivity?.userInfo = [ RestorableStateKey.lastURL.rawValue: cleanURL, RestorableStateKey.server.rawValue: server.identifier.rawValue, ] userActivity?.becomeCurrent() } webView.navigationDelegate = self webView.uiDelegate = self webView.translatesAutoresizingMaskIntoConstraints = false webView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true webView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true webView.topAnchor.constraint(equalTo: statusBarView.bottomAnchor).isActive = true webView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] if !Current.isCatalyst { // refreshing is handled by menu/keyboard shortcuts refreshControl.addTarget(self, action: #selector(pullToRefresh(_:)), for: .valueChanged) webView.scrollView.addSubview(refreshControl) webView.scrollView.bounces = true } WebViewAccessoryViews.settingsButton.addTarget(self, action: #selector(openSettingsView(_:)), for: .touchDown) view.addSubview(WebViewAccessoryViews.settingsButton) NSLayoutConstraint.activate([ view.bottomAnchor.constraint(equalTo: WebViewAccessoryViews.settingsButton.bottomAnchor, constant: 16.0), view.rightAnchor.constraint(equalTo: WebViewAccessoryViews.settingsButton.rightAnchor, constant: 16.0), ]) NotificationCenter.default.addObserver( self, selector: #selector(updateWebViewSettingsForNotification), name: SettingsStore.webViewRelatedSettingDidChange, object: nil ) updateWebViewSettings(reason: .initial) styleUI() updateWebViewForServerValues() getLatestConfig() if #available(iOS 16.4, *) { webView.isInspectable = true } } public func showSettingsViewController() { getLatestConfig() if Current.sceneManager.supportsMultipleScenes, Current.isCatalyst { Current.sceneManager.activateAnyScene(for: .settings) } else { let settingsView = SettingsViewController() settingsView.hidesBottomBarWhenPushed = true let navController = UINavigationController(rootViewController: settingsView) presentOverlayController(controller: navController, animated: true) } } // 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) loadActiveURLIfNeeded() } override func viewWillDisappear(_ animated: Bool) { userActivity?.resignCurrent() } enum RestorationType { case userActivity(NSUserActivity) case coder(NSCoder) case server(Server) init?(_ userActivity: NSUserActivity?) { if let userActivity { self = .userActivity(userActivity) } else { return nil } } var initialURL: URL? { switch self { case let .userActivity(userActivity): return userActivity.userInfo?[RestorableStateKey.lastURL.rawValue] as? URL case let .coder(coder): return coder.decodeObject(of: NSURL.self, forKey: RestorableStateKey.lastURL.rawValue) as URL? case .server: return nil } } var server: Server? { let serverRawValue: String? switch self { case let .userActivity(userActivity): serverRawValue = userActivity.userInfo?[RestorableStateKey.server.rawValue] as? String case let .coder(coder): serverRawValue = coder.decodeObject( of: NSString.self, forKey: RestorableStateKey.server.rawValue ) as String? case let .server(server): return server } return Current.servers.server(forServerIdentifier: serverRawValue) } } init(server: Server, shouldLoadImmediately: Bool = false) { self.server = server self.sidebarGestureRecognizer = with(UIScreenEdgePanGestureRecognizer()) { $0.edges = .left } super.init(nibName: nil, bundle: nil) userActivity = with(NSUserActivity(activityType: "\(AppConstants.BundleID).frontend")) { $0.isEligibleForHandoff = true } sidebarGestureRecognizer.addTarget(self, action: #selector(showSidebar(_:))) if shouldLoadImmediately { loadViewIfNeeded() loadActiveURLIfNeeded() } } convenience init?(restoring: RestorationType?, shouldLoadImmediately: Bool = false) { if let server = restoring?.server ?? Current.servers.all.first { self.init(server: server) } else { return nil } self.initialURL = restoring?.initialURL } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.urlObserver = nil self.tokens.forEach { $0.cancel() } } private func styleUI() { precondition(isViewLoaded && webView != nil) let cachedColors = ThemeColors.cachedThemeColors(for: traitCollection) webView?.backgroundColor = cachedColors[.primaryBackgroundColor] webView?.scrollView.backgroundColor = cachedColors[.primaryBackgroundColor] if let statusBarView = view.viewWithTag(111) { if server.info.version < .canUseAppThemeForStatusBar { statusBarView.backgroundColor = cachedColors[.appHeaderBackgroundColor] } else { statusBarView.backgroundColor = cachedColors[.appThemeColor] } } refreshControl.tintColor = cachedColors[.primaryColor] let headerBackgroundIsLight = cachedColors[.appThemeColor].isLight underlyingPreferredStatusBarStyle = headerBackgroundIsLight ? .darkContent : .lightContent setNeedsStatusBarAppearanceUpdate() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { webView.evaluateJavaScript("notifyThemeColors()", completionHandler: nil) } } public func open(inline url: URL) { loadViewIfNeeded() // these paths do not show frontend pages, and so we don't want to display them in our webview // otherwise the user will get stuck. e.g. /api is loaded by frigate to show video clips and images let ignoredPaths = [ "/api", "/static", "/hacsfiles", "/local", ] if ignoredPaths.allSatisfy({ !url.path.hasPrefix($0) }) { webView.load(URLRequest(url: url)) } else { openURLInBrowser(url, self) } } private var lastNavigationWasServerError = false func webView( _ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { let result = server.info.connection.evaluate(challenge) completionHandler(result.0, result.1) } 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) { refreshControl.endRefreshing() if let err = error as? URLError { if err.code != .cancelled { Current.Log.error("Failure during nav: \(err)") } if !error.isCancelled { showSwiftMessage(error: error) } } } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { refreshControl.endRefreshing() if let err = error as? URLError { if err.code != .cancelled { Current.Log.error("Failure during content load: \(error)") } if !error.isCancelled { showSwiftMessage(error: error) } } } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { refreshControl.endRefreshing() // in case the view appears again, don't reload initialURL = nil updateWebViewSettings(reason: .load) } func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { if #available(iOS 17.0, *) { let viewModel = DownloadManagerViewModel() let downloadManager = DownloadManagerView(viewModel: viewModel) let downloadController = UIHostingController(rootView: downloadManager) presentOverlayController(controller: downloadController, animated: true) download.delegate = viewModel } } func webView( _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void ) { lastNavigationWasServerError = false 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 } lastNavigationWasServerError = true // 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 webviewURL = server.info.connection.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 case .mac: return .alert case .pad, .unspecified, .vision: // 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) })) if presentedViewController != nil { Current.Log.error("attempted to present an alert when already presenting, bailing") completionHandler(false) } else { 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) })) if presentedViewController != nil { Current.Log.error("attempted to present an alert when already presenting, bailing") completionHandler(nil) } else { 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 if presentedViewController != nil { Current.Log.error("attempted to present an alert when already presenting, bailing") completionHandler() } else { present(alertController, animated: true, completion: nil) } } func webView( _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void ) { decisionHandler(.grant) } private func updateWebViewForServerValues() { sidebarGestureRecognizer.isEnabled = server.info.version >= .externalBusCommandSidebar } @objc private func connectionInfoDidChange() { DispatchQueue.main.async { [self] in loadActiveURLIfNeeded() updateWebViewForServerValues() } } @objc private func loadActiveURLIfNeeded() { guard let webviewURL = server.info.connection.webviewURL() else { Current.Log.info("not loading, no url") return } guard webView.url == nil || webView.url?.baseIsEqual(to: webviewURL) == false else { // we also tell the webview -- maybe it failed to connect itself? -- to refresh if needed webView.evaluateJavaScript("checkForMissingHassConnectionAndReload()", completionHandler: nil) return } guard UIApplication.shared.applicationState != .background else { Current.Log.info("not loading, in background") return } // if we aren't showing a url or it's an incorrect url, update it -- otherwise, leave it alone let request: URLRequest if Current.settingsStore.restoreLastURL, let initialURL, initialURL.baseIsEqual(to: webviewURL) { Current.Log.info("restoring initial url path: \(initialURL.path)") request = URLRequest(url: initialURL) } else { Current.Log.info("loading default url path: \(webviewURL.path)") request = URLRequest(url: webviewURL) } webView.load(request) } @objc private func refresh() { // called via menu/keyboard shortcut too if let webviewURL = server.info.connection.webviewURL() { if webView.url?.baseIsEqual(to: webviewURL) == true, !lastNavigationWasServerError { webView.reload() } else { webView.load(URLRequest(url: webviewURL)) } } } @objc private func swipe(_ gesture: UISwipeGestureRecognizer) { let icon: MaterialDesignIcons if gesture.direction == .left, webView.canGoForward { _ = webView.goForward() icon = .arrowRightIcon } else if gesture.direction == .right, webView.canGoBack { _ = webView.goBack() icon = .arrowLeftIcon } else { // the returned WKNavigation doesn't appear to be nil/non-nil based on whether forward/back occurred return } let hud = MBProgressHUD.showAdded(to: view, animated: true) hud.isUserInteractionEnabled = false hud.customView = with(IconImageView(frame: CGRect(x: 0, y: 0, width: 37, height: 37))) { $0.iconDrawable = icon } hud.mode = .customView hud.hide(animated: true, afterDelay: 1.0) } @objc private func showSidebar(_ gesture: UIScreenEdgePanGestureRecognizer) { switch gesture.state { case .began: webViewExternalMessageHandler.sendExternalBus(message: .init(command: "sidebar/show")) default: break } } @objc private func updateSensors() { // called via menu/keyboard shortcut too firstly { HomeAssistantAPI.manuallyUpdate( applicationState: UIApplication.shared.applicationState, type: .userRequested ) }.catch { [weak self] error in self?.showSwiftMessage(error: error) } } @objc func pullToRefresh(_ sender: UIRefreshControl) { refresh() updateSensors() } private func swiftMessagesConfig() -> SwiftMessages.Config { var config = SwiftMessages.Config() config.presentationContext = .viewController(self) config.duration = .forever config.presentationStyle = .bottom config.dimMode = .gray(interactive: true) config.dimModeAccessibilityLabel = L10n.cancelLabel return config } func show(alert: ServerAlert) { Current.Log.info("showing alert \(alert)") var config = swiftMessagesConfig() config.eventListeners.append({ event in switch event { case .didHide: Current.serverAlerter.markHandled(alert: alert) default: break } }) let view = MessageView.viewFromNib(layout: .messageView) view.configureTheme( backgroundColor: UIColor(red: 1.000, green: 0.596, blue: 0.000, alpha: 1.0), foregroundColor: .white ) view.configureContent( title: nil, body: alert.message, iconImage: nil, iconText: nil, buttonImage: nil, buttonTitle: L10n.openLabel, buttonTapHandler: { _ in UIApplication.shared.open(alert.url, options: [:], completionHandler: nil) SwiftMessages.hide() } ) SwiftMessages.show(config: config, view: view) } func showSwiftMessage(error: Error, duration: SwiftMessages.Duration = .seconds(seconds: 15)) { Current.Log.error(error) let nsError = error as NSError var config = swiftMessagesConfig() config.duration = duration let view = MessageView.viewFromNib(layout: .messageView) view.configureTheme(.error) view.configureContent( title: error.localizedDescription, body: "\(nsError.domain) \(nsError.code)", iconImage: nil, iconText: nil, buttonImage: nil, buttonTitle: L10n.okLabel, buttonTapHandler: { _ in SwiftMessages.hide() } ) view.titleLabel?.numberOfLines = 0 view.bodyLabel?.numberOfLines = 0 SwiftMessages.show(config: config, view: view) } @objc func openSettingsView(_ sender: UIButton) { showSettingsViewController() } private var underlyingPreferredStatusBarStyle: UIStatusBarStyle = .lightContent override var preferredStatusBarStyle: UIStatusBarStyle { underlyingPreferredStatusBarStyle } @objc private func updateWebViewSettingsForNotification() { updateWebViewSettings(reason: .settingChange) } private enum WebViewSettingsUpdateReason { case initial case settingChange case load } private func updateWebViewSettings(reason: WebViewSettingsUpdateReason) { Current.Log.info("updating web view settings for \(reason)") // iOS 14's `pageZoom` property is almost this, but not quite - it breaks the layout as well // 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") if !Current.isCatalyst { let zoomValue = Current.settingsStore.pinchToZoom ? "true" : "false" webView.evaluateJavaScript("setOverrideZoomEnabled(\(zoomValue))", completionHandler: nil) } if reason == .settingChange { setNeedsStatusBarAppearanceUpdate() setNeedsUpdateOfHomeIndicatorAutoHidden() } } private var reconnectBackgroundTimer: Timer? { willSet { if reconnectBackgroundTimer != newValue { reconnectBackgroundTimer?.invalidate() } } } @objc private func scheduleReconnectBackgroundTimer() { precondition(Thread.isMainThread) guard isViewLoaded, server.info.version >= .externalBusCommandRestart else { return } // On iOS 15, Apple switched to using NSURLSession's WebSocket implementation, which is pretty bad at detecting // any kind of networking failure. Even more troubling, it doesn't realize there's a failure due to background // so it spends dozens of seconds waiting for a connection reset externally. // // We work around this by detecting being in the background for long enough that it's likely the connection will // need to reconnect, anyway (similar to how we do it in HAKit). When this happens, we ask the frontend to // reset its WebSocket connection, thus eliminating the wait. // // It's likely this doesn't apply before iOS 15, but it may improve the reconnect timing there anyhow. reconnectBackgroundTimer = Timer.scheduledTimer( withTimeInterval: 5.0, repeats: true, block: { [weak self] timer in if let self, Current.date().timeIntervalSince(timer.fireDate) > 30.0 { webViewExternalMessageHandler.sendExternalBus(message: .init(command: "restart")) } if UIApplication.shared.applicationState == .active { timer.invalidate() } } ) } public func openActionAutomationEditor(actionId: String) { guard server.info.version >= .externalBusCommandAutomationEditor else { showActionAutomationEditorNotAvailable() return } webViewExternalMessageHandler.sendExternalBus(message: .init( command: WebViewExternalBusOutgoingMessage.showAutomationEditor.rawValue, payload: [ "config": [ "trigger": [ [ "platform": "event", "event_type": "ios.action_fired", "event_data": [ "actionID": actionId, ], ], ], ], ] )) } private func getLatestConfig() { _ = Current.api(for: server)?.getConfig() } private func showActionAutomationEditorNotAvailable() { let alert = UIAlertController( title: L10n.Alerts.ActionAutomationEditor.Unavailable.title, message: L10n.Alerts.ActionAutomationEditor.Unavailable.body, preferredStyle: .alert ) alert.addAction(.init(title: L10n.okLabel, style: .default)) present(alert, animated: true) } func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { webViewExternalMessageHandler.stopImprovScanIfNeeded() } func showReAuthPopup(serverId: String, code: Int) { guard serverId == server.identifier.rawValue else { return } var config = swiftMessagesConfig() config.duration = .forever let view = MessageView.viewFromNib(layout: .messageView) view.configureTheme(.warning) view.configureContent( title: L10n.Unauthenticated.Message.title, body: L10n.Unauthenticated.Message.body, iconImage: nil, iconText: nil, buttonImage: MaterialDesignIcons.cogIcon.image( ofSize: CGSize(width: 24, height: 24), color: Asset.Colors.haPrimary.color ), buttonTitle: nil, buttonTapHandler: { [weak self] _ in self?.showSettingsViewController() } ) view.titleLabel?.numberOfLines = 0 view.bodyLabel?.numberOfLines = 0 SwiftMessages.show(config: config, view: view) } } extension String { func matchingStrings(regex: String) -> [[String]] { guard let regex = try? NSRegularExpression(pattern: regex) else { return [] } let nsString = self as NSString let results = regex.matches(in: self, 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 { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let messageBody = message.body as? [String: Any] else { Current.Log.error("received message for \(message.name) but of type: \(type(of: message.body))") return } Current.Log.verbose("message \(message.body)".replacingOccurrences(of: "\n", with: " ")) switch message.name { case "externalBus": webViewExternalMessageHandler.handleExternalMessage(messageBody) case "updateThemeColors": handleThemeUpdate(messageBody) case "getExternalAuth": guard let callbackName = messageBody["callback"] else { return } let force = messageBody["force"] as? Bool ?? false Current.Log.verbose("getExternalAuth called, forced: \(force)") firstly { Current.api(for: server)?.tokenManager .authDictionaryForWebView(forceRefresh: force) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) }.done { dictionary in let jsonData = try? JSONSerialization.data(withJSONObject: dictionary) if let jsonString = String(data: jsonData!, encoding: .utf8) { // 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 { 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)") } case "revokeExternalAuth": guard let callbackName = messageBody["callback"] else { return } Current.Log.warning("Revoking access token") firstly { Current.api(for: server)?.tokenManager .revokeToken() ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) }.done { [server] _ in Current.servers.remove(identifier: server.identifier) 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 { 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)") } case "logError": Current.Log.error("WebView error: \(messageBody.description.replacingOccurrences(of: "\n", with: " "))") default: Current.Log.error("unknown message: \(message.name)") } } func handleThemeUpdate(_ messageBody: [String: Any]) { ThemeColors.updateCache(with: messageBody, for: traitCollection) styleUI() } } 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 { mutating func webviewURLComponents() -> URLComponents? { if Current.appConfiguration == .fastlaneSnapshot, prefs.object(forKey: "useDemo") != nil { return URLComponents(string: "https://companion.home-assistant.io/app/ios/demo")! } guard let activeURL = activeURL() else { Current.Log.error("No activeURL available while webviewURLComponents was called") return nil } guard var components = URLComponents(url: activeURL, resolvingAgainstBaseURL: true) else { return nil } let queryItem = URLQueryItem(name: "external_auth", value: "1") components.queryItems = [queryItem] return components } mutating func webviewURL() -> URL? { webviewURLComponents()?.url } mutating func webviewURL(from raw: String) -> URL? { guard let baseURLComponents = webviewURLComponents(), let baseURL = baseURLComponents.url else { return nil } if raw.starts(with: "/") { if let rawComponents = URLComponents(string: raw) { var components = baseURLComponents components.path.append(rawComponents.path) components.fragment = rawComponents.fragment if let items = rawComponents.queryItems { var queryItems = components.queryItems ?? [] queryItems.append(contentsOf: items) components.queryItems = queryItems } return components.url } else { return baseURL.appendingPathComponent(raw) } } else if let url = URL(string: raw), url.baseIsEqual(to: baseURL) { return url } else { return nil } } } extension WebViewController: WebViewControllerProtocol { func presentOverlayController(controller: UIViewController, animated: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } overlayAppController?.dismiss(animated: false, completion: nil) overlayAppController = controller present(controller, animated: animated, completion: nil) } } func evaluateJavaScript(_ script: String, completion: ((Any?, (any Error)?) -> Void)?) { webView.evaluateJavaScript(script, completionHandler: completion) } func presentController(_ controller: UIViewController, animated: Bool) { DispatchQueue.main.async { [weak self] in guard let self else { return } if let overlayAppController { overlayAppController.dismiss(animated: false) } present(controller, animated: animated) } } func dismissOverlayController(animated: Bool, completion: (() -> Void)?) { if let overlayAppController { overlayAppController.dismiss(animated: animated, completion: completion) } else { completion?() } } func dismissControllerAboveOverlayController() { overlayAppController?.dismissAllViewControllersAbove() } func updateSettingsButton(state: String) { // Possible values: connected, disconnected, auth-invalid UIView.animate(withDuration: 1.0, delay: 0, options: .curveEaseInOut, animations: { WebViewAccessoryViews.settingsButton.alpha = state == "connected" ? 0 : 1 }, completion: nil) } func navigateToPath(path: String) { if let activeURL = server.info.connection.activeURL(), let url = URL(string: activeURL.absoluteString + path) { webView.load(URLRequest(url: url)) } } func reload() { webView.reload() } }