iOS/Sources/App/WebView/WebViewController.swift
Bruno Pantaleão Gonçalves d1515b8df7
fix: Preserve URL path when base URL changes due to network switch (#4296)
When the user switches between internal and external networks (SSID
change), the app refreshes with a new base URL. Previously, the current
path was lost and the user was redirected to the home page.

This change preserves the current path by using the existing
adapting(url:) method to apply the new base URL while keeping the path
intact.

Example: If user is on /lovelace/kitchen and switches from internal to
external network, they will stay on /lovelace/kitchen instead of being
redirected to /.

<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->

---------

Co-authored-by: Bruno Gonçalves <bgoncal@users.noreply.github.com>
2026-02-02 11:50:20 +01:00

1663 lines
63 KiB
Swift

import AVFoundation
import AVKit
import CoreLocation
import HAKit
import Improv_iOS
import KeychainAccess
import MBProgressHUD
import PromiseKit
import Shared
import SwiftMessages
import SwiftUI
import UIKit
@preconcurrency import WebKit
enum FrontEndConnectionState: String {
case connected
case disconnected
case authInvalid = "auth-invalid"
case unknown
}
final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
private var webView: WKWebView!
let server: Server
private var urlObserver: NSKeyValueObservation?
private var tokens = [HACancellable]()
private let refreshControl = UIRefreshControl()
private let leftEdgePanGestureRecognizer: UIScreenEdgePanGestureRecognizer
private let rightEdgeGestureRecognizer: UIScreenEdgePanGestureRecognizer
private var emptyStateView: UIView?
private let emptyStateTransitionDuration: TimeInterval = 0.3
private var statusBarView: UIView?
private var webViewTopConstraint: NSLayoutConstraint?
private var initialURL: URL?
private var statusBarButtonsStack: UIStackView?
private var lastNavigationWasServerError = false
private var reconnectBackgroundTimer: Timer? {
willSet {
if reconnectBackgroundTimer != newValue {
reconnectBackgroundTimer?.invalidate()
}
}
}
private var loadActiveURLIfNeededInProgress = false
/// Track the timestamp of the last pull-to-refresh action
private var lastPullToRefreshTimestamp: Date?
/// Handler for messages sent from the webview to the app
var webViewExternalMessageHandler: WebViewExternalMessageHandlerProtocol = WebViewExternalMessageHandler(
improvManager: ImprovManager.shared
)
/// Handler for gestures over the webview
private let webViewGestureHandler = WebViewGestureHandler()
/// Handler for script messages sent from the webview to the app
private let webViewScriptMessageHandler = WebViewScriptMessageHandler()
/// Defer showing the empty state until disconnected for 4 seconds (var used in
/// WebViewControllerProtocol+Implementation )
private var emptyStateTimer: Timer?
/// Frontend notifies when connection is established or not
/// Each navigation resets this to false so we can show the empty state
private var isConnected = false
private var underlyingPreferredStatusBarStyle: UIStatusBarStyle = .lightContent
override var prefersStatusBarHidden: Bool {
Current.settingsStore.fullScreen
}
override var prefersHomeIndicatorAutoHidden: Bool {
Current.settingsStore.fullScreen
}
override var preferredStatusBarStyle: UIStatusBarStyle {
underlyingPreferredStatusBarStyle
}
#if targetEnvironment(macCatalyst)
override var keyCommands: [UIKeyCommand]? {
var commands = [
UIKeyCommand(
input: "c",
modifierFlags: [.shift, .command],
action: #selector(copyCurrentSelectedContent)
),
UIKeyCommand(
input: "v",
modifierFlags: [.shift, .command],
action: #selector(pasteContent)
),
UIKeyCommand(
input: "c",
modifierFlags: .command,
action: #selector(copyCurrentSelectedContent)
),
UIKeyCommand(
input: "v",
modifierFlags: .command,
action: #selector(pasteContent)
),
UIKeyCommand(
input: "r",
modifierFlags: .command,
action: #selector(refresh)
),
]
// Add find command for iOS 16+
if #available(iOS 16.0, *) {
commands.append(UIKeyCommand(
input: "f",
modifierFlags: .command,
action: #selector(showFindInteraction)
))
commands.append(UIKeyCommand(
input: "f",
modifierFlags: [.shift, .command],
action: #selector(showFindInteraction)
))
}
return commands
}
#endif
init(server: Server, shouldLoadImmediately: Bool = false) {
self.server = server
self.leftEdgePanGestureRecognizer = with(UIScreenEdgePanGestureRecognizer()) {
$0.edges = .left
}
self.rightEdgeGestureRecognizer = with(UIScreenEdgePanGestureRecognizer()) {
$0.edges = .right
}
super.init(nibName: nil, bundle: nil)
userActivity = with(NSUserActivity(activityType: "\(AppConstants.BundleID).frontend")) {
$0.isEligibleForHandoff = true
}
leftEdgePanGestureRecognizer.addTarget(self, action: #selector(screenEdgeGestureRecognizerAction(_:)))
rightEdgeGestureRecognizer.addTarget(self, action: #selector(screenEdgeGestureRecognizerAction(_:)))
if shouldLoadImmediately {
loadViewIfNeeded()
loadActiveURLIfNeeded()
}
webViewExternalMessageHandler.webViewController = self
webViewGestureHandler.webView = self
webViewScriptMessageHandler.webView = self
}
convenience init?(restoring: WebViewRestorationType?, 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 {
removeEmptyStateObservations()
self.urlObserver = nil
self.tokens.forEach { $0.cancel() }
}
// MARK: - View lifecycle
override func viewDidLoad() {
super.viewDidLoad()
becomeFirstResponder()
observeConnectionNotifications()
let statusBarView = setupStatusBarView()
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
let userContentController = setupUserContentController()
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)
setupGestures(numberOfTouchesRequired: 2)
setupGestures(numberOfTouchesRequired: 3)
setupEgdeGestures()
setupURLObserver()
webView.navigationDelegate = self
webView.uiDelegate = self
setupWebViewConstraints(statusBarView: statusBarView)
setupPullToRefresh()
setupEmptyState()
NotificationCenter.default.addObserver(
self,
selector: #selector(updateWebViewSettingsForNotification),
name: SettingsStore.webViewRelatedSettingDidChange,
object: nil
)
updateWebViewSettings(reason: .initial)
styleUI()
getLatestConfig()
if #available(iOS 16.4, *) {
webView.isInspectable = true
}
// Enable find interaction for iOS 16+
if #available(iOS 16.0, *) {
webView.isFindInteractionEnabled = true
}
postOnboardingNotificationPermission()
emptyStateObservations()
checkForLocalSecurityLevelDecisionNeeded()
}
// 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 viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateDatabaseAndPanels()
}
override func viewWillDisappear(_ animated: Bool) {
userActivity?.resignCurrent()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
webView.evaluateJavaScript("notifyThemeColors()", completionHandler: nil)
}
}
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake {
let action = Current.settingsStore.gestures[.shake] ?? .openDebug
webViewGestureHandler.handleGestureAction(action)
}
}
// MARK: - Private
private func observeConnectionNotifications() {
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()
})
}
private func setupPullToRefresh() {
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
}
}
private func setupEmptyState() {
let emptyState = WebViewEmptyStateWrapperView(server: server) { [weak self] in
self?.hideEmptyState()
self?.refresh()
} settingsAction: { [weak self] in
self?.showSettingsViewController()
} dismissAction: { [weak self] in
self?.hideEmptyState()
}
view.addSubview(emptyState)
emptyState.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
emptyState.leftAnchor.constraint(equalTo: view.leftAnchor),
emptyState.rightAnchor.constraint(equalTo: view.rightAnchor),
emptyState.topAnchor.constraint(equalTo: view.topAnchor),
emptyState.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
emptyState.alpha = 0
emptyStateView = emptyState
}
/// If user has not chosen 'Most secure' or 'Less secure' local access yet, this triggers a screen for decision
private func checkForLocalSecurityLevelDecisionNeeded() {
if Current.location.permissionStatus == .notDetermined, server.info.connection.hasNonHTTPSURLOption {
Current.Log.verbose("User has not decided location permission yet")
showOnboardingPermissions(steps: OnboardingPermissionsNavigationViewModel.StepID.updateLocationPermission)
} else if server.info.connection.connectionAccessSecurityLevel == .undefined {
Current.Log.verbose("User has not decided local access security level yet")
showOnboardingPermissions(
steps: OnboardingPermissionsNavigationViewModel.StepID
.updateLocalAccessSecurityLevelPreference
)
} else {
Current.Log
.verbose(
"User decided \(server.info.connection.connectionAccessSecurityLevel) for local access security level"
)
}
}
private func showOnboardingPermissions(steps: [OnboardingPermissionsNavigationViewModel.StepID]) {
let controller = NavigationView {
OnboardingPermissionsNavigationView(onboardingServer: server, steps: steps)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
CloseButton { [weak self] in
self?.dismiss(animated: true)
}
}
}
.onDisappear { [weak self] in
self?.refresh()
}
}.navigationViewStyle(.stack).embeddedInHostingController()
// Prevent controller on being dismissed on swipe down
controller.isModalInPresentation = true
controller.view.tag = WebViewControllerOverlayedViewTags.onboardingPermissions.rawValue
presentOverlayController(controller: controller, animated: true)
}
private func emptyStateObservations() {
// Hide empty state when enter background
NotificationCenter.default.addObserver(
self,
selector: #selector(hideEmptyState),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
// Show empty state again if after entering foreground it is not connected
NotificationCenter.default.addObserver(
self,
selector: #selector(resetEmptyStateTimerWithLatestConnectedState),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
private func removeEmptyStateObservations() {
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.removeObserver(
self,
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
func showEmptyState() {
UIView.animate(withDuration: emptyStateTransitionDuration, delay: 0, options: .curveEaseInOut, animations: {
self.emptyStateView?.alpha = 1
}, completion: nil)
}
@objc func hideEmptyState() {
UIView.animate(withDuration: emptyStateTransitionDuration, delay: 0, options: .curveEaseInOut, animations: {
self.emptyStateView?.alpha = 0
}, completion: nil)
}
// To avoid keeping the empty state on screen when user is disconnected in background
// due to innectivity, we reset the empty state timer
@objc func resetEmptyStateTimerWithLatestConnectedState() {
let state: FrontEndConnectionState = isConnected ? .connected : .disconnected
updateFrontendConnectionState(state: state.rawValue)
}
private func setupWebViewConstraints(statusBarView: UIView) {
webView.translatesAutoresizingMaskIntoConstraints = false
webView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
webView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
// Create the top constraint based on edge-to-edge setting
// On iOS (not Catalyst), edge-to-edge mode pins the webview to the top of the view
// On Catalyst, we always show the status bar buttons, so we pin to statusBarView
// Also use edge-to-edge behavior when fullScreen is enabled (status bar hidden)
let edgeToEdge = (Current.settingsStore.edgeToEdge || Current.settingsStore.fullScreen) && !Current.isCatalyst
if edgeToEdge {
webViewTopConstraint = webView.topAnchor.constraint(equalTo: view.topAnchor)
statusBarView.isHidden = true
} else {
webViewTopConstraint = webView.topAnchor.constraint(equalTo: statusBarView.bottomAnchor)
statusBarView.isHidden = false
}
webViewTopConstraint?.isActive = true
}
private func setupURLObserver() {
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()
}
}
private func setupGestures(numberOfTouchesRequired: Int) {
let gestures = [.left, .right, .up, .down].map { (direction: UISwipeGestureRecognizer.Direction) in
let gesture = UISwipeGestureRecognizer()
gesture.numberOfTouchesRequired = numberOfTouchesRequired
gesture.direction = direction
gesture.addTarget(self, action: #selector(swipe(_:)))
gesture.delegate = self
return gesture
}
for gesture in gestures {
view.addGestureRecognizer(gesture)
}
}
private func setupEgdeGestures() {
webView.addGestureRecognizer(leftEdgePanGestureRecognizer)
webView.addGestureRecognizer(rightEdgeGestureRecognizer)
}
private func setupUserContentController() -> WKUserContentController {
let userContentController = WKUserContentController()
let safeScriptMessageHandler = SafeScriptMessageHandler(delegate: webViewScriptMessageHandler)
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")
return userContentController
}
private func setupStatusBarView() -> UIView {
let statusBarView = UIView()
statusBarView.tag = 111
self.statusBarView = statusBarView
view.addSubview(statusBarView)
statusBarView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
statusBarView.topAnchor.constraint(equalTo: view.topAnchor),
statusBarView.leftAnchor.constraint(equalTo: view.leftAnchor),
statusBarView.rightAnchor.constraint(equalTo: view.rightAnchor),
statusBarView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
])
if Current.isCatalyst {
setupStatusBarButtons(in: statusBarView)
}
return statusBarView
}
private func setupStatusBarButtons(in statusBarView: UIView) {
// Remove existing stack if present
if let statusBarButtonsStack {
statusBarButtonsStack.removeFromSuperview()
self.statusBarButtonsStack = nil
}
let configuration = StatusBarButtonsConfigurator.Configuration(
server: server,
servers: Current.servers.all,
actions: .init(
refresh: { [weak self] in
self?.refresh()
},
openServer: { [weak self] server in
self?.openServer(server)
},
openInSafari: { [weak self] in
self?.openServerInSafari()
},
goBack: { [weak self] in
self?.goBack()
},
goForward: { [weak self] in
self?.goForward()
},
copy: { [weak self] in
self?.copyCurrentSelectedContent()
},
paste: { [weak self] in
self?.pasteContent()
}
)
)
statusBarButtonsStack = StatusBarButtonsConfigurator.setupButtons(
in: statusBarView,
configuration: configuration
)
}
private func setupStatusBarButtons(statusBarView: UIView) {
let picker = UIButton(type: .system)
picker.setTitle(server.info.name, for: .normal)
picker.translatesAutoresizingMaskIntoConstraints = false
let menuActions = Current.servers.all.map { server in
UIAction(title: server.info.name, handler: { [weak self] _ in
self?.openServer(server)
})
}
// Using UIMenu since UIPickerView is not available on Catalyst
picker.menu = UIMenu(title: L10n.WebView.ServerSelection.title, children: menuActions)
picker.showsMenuAsPrimaryAction = true
if let statusBarButtonsStack {
statusBarButtonsStack.removeFromSuperview()
self.statusBarButtonsStack = nil
}
let reloadButton = UIButton(type: .custom)
reloadButton.setImage(UIImage(systemSymbol: .arrowClockwise), for: .normal)
reloadButton.addTarget(self, action: #selector(refresh), for: .touchUpInside)
// Wrap reload button in a circle view with padding
let circleContainer = UIView()
circleContainer.backgroundColor = UIColor.systemGray5
circleContainer.layer.cornerRadius = 14 // Adjust size as needed
circleContainer.translatesAutoresizingMaskIntoConstraints = false
circleContainer.addSubview(reloadButton)
reloadButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
circleContainer.widthAnchor.constraint(equalToConstant: 28),
circleContainer.heightAnchor.constraint(equalToConstant: 28),
reloadButton.centerXAnchor.constraint(equalTo: circleContainer.centerXAnchor),
reloadButton.centerYAnchor.constraint(equalTo: circleContainer.centerYAnchor),
reloadButton.widthAnchor.constraint(equalToConstant: 20),
reloadButton.heightAnchor.constraint(equalToConstant: 20),
])
let arrangedSubviews: [UIView] = Current.servers.all.count > 1 ? [circleContainer, picker] : [circleContainer]
let stackView = UIStackView(arrangedSubviews: arrangedSubviews)
stackView.axis = .horizontal
stackView.spacing = DesignSystem.Spaces.one
statusBarView.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
let openInSafariButton = WebViewControllerButtons.openInSafariButton
openInSafariButton.addTarget(self, action: #selector(openServerInSafari), for: .touchUpInside)
openInSafariButton.translatesAutoresizingMaskIntoConstraints = false
let backButton = WebViewControllerButtons.backButton
backButton.addTarget(self, action: #selector(goBack), for: .touchUpInside)
backButton.translatesAutoresizingMaskIntoConstraints = false
let forwardButton = WebViewControllerButtons.forwardButton
forwardButton.addTarget(self, action: #selector(goForward), for: .touchUpInside)
forwardButton.translatesAutoresizingMaskIntoConstraints = false
let buttonStack = UIStackView(arrangedSubviews: [openInSafariButton, backButton, forwardButton])
buttonStack.axis = .horizontal
buttonStack.spacing = DesignSystem.Spaces.one
buttonStack.translatesAutoresizingMaskIntoConstraints = false
buttonStack.alignment = .center
statusBarView.addSubview(buttonStack)
NSLayoutConstraint.activate([
stackView.rightAnchor.constraint(equalTo: statusBarView.rightAnchor, constant: -DesignSystem.Spaces.half),
stackView.topAnchor.constraint(equalTo: statusBarView.topAnchor, constant: DesignSystem.Spaces.half),
buttonStack.topAnchor.constraint(equalTo: statusBarView.topAnchor),
openInSafariButton.widthAnchor.constraint(equalToConstant: 11),
openInSafariButton.heightAnchor.constraint(equalToConstant: 11),
])
// Magic numbers to position it nicely in macOS bar
if #available(macOS 26.0, *) {
NSLayoutConstraint.activate([
buttonStack.leftAnchor.constraint(equalTo: statusBarView.leftAnchor, constant: 78),
buttonStack.heightAnchor.constraint(equalToConstant: 30),
])
} else {
NSLayoutConstraint.activate([
buttonStack.leftAnchor.constraint(equalTo: statusBarView.leftAnchor, constant: 68),
buttonStack.heightAnchor.constraint(equalToConstant: 27),
])
}
statusBarButtonsStack = stackView
}
private func openServer(_ server: Server) {
Current.sceneManager.webViewWindowControllerPromise.done { controller in
controller.open(server: server)
}
}
func styleUI() {
precondition(isViewLoaded && webView != nil)
let cachedColors = ThemeColors.cachedThemeColors(for: traitCollection)
view.backgroundColor = cachedColors[.primaryBackgroundColor]
webView?.backgroundColor = cachedColors[.primaryBackgroundColor]
webView?.scrollView.backgroundColor = cachedColors[.primaryBackgroundColor]
// Use the stored reference instead of searching by tag
if let statusBarView {
let backgroundColor = server.info.version < .canUseAppThemeForStatusBar
? cachedColors[.appHeaderBackgroundColor]
: cachedColors[.appThemeColor]
statusBarView.backgroundColor = backgroundColor
statusBarView.isOpaque = true
}
refreshControl.tintColor = cachedColors[.primaryColor]
let headerBackgroundIsLight = cachedColors[.appThemeColor].isLight
underlyingPreferredStatusBarStyle = headerBackgroundIsLight ? .darkContent : .lightContent
setNeedsStatusBarAppearanceUpdate()
}
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
}
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)
}
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()
updateEdgeToEdgeLayout()
}
}
private func updateEdgeToEdgeLayout() {
guard let statusBarView else { return }
// Edge-to-edge mode only applies to iOS (not Catalyst)
// Also use edge-to-edge behavior when fullScreen is enabled (status bar hidden)
let edgeToEdge = (Current.settingsStore.edgeToEdge || Current.settingsStore.fullScreen) && !Current.isCatalyst
// Deactivate the current constraint
webViewTopConstraint?.isActive = false
// Create the new constraint based on edge-to-edge setting
if edgeToEdge {
webViewTopConstraint = webView.topAnchor.constraint(equalTo: view.topAnchor)
statusBarView.isHidden = true
} else {
webViewTopConstraint = webView.topAnchor.constraint(equalTo: statusBarView.bottomAnchor)
statusBarView.isHidden = false
}
webViewTopConstraint?.isActive = true
// Force layout update
view.setNeedsLayout()
view.layoutIfNeeded()
// Refresh styling to ensure statusBarView has proper background color
styleUI()
// Animate the layout change
UIView.animate(withDuration: 0.25) {
self.view.layoutIfNeeded()
}
}
// MARK: - @objc
@objc private func connectionInfoDidChange() {
DispatchQueue.main.async { [self] in
loadActiveURLIfNeeded()
}
}
/// Updates the app database and panels for the current server
/// Called after view appears and on pull to refresh to avoid blocking app launch
private func updateDatabaseAndPanels() {
// Update runs in background automatically, returns immediately
Current.appDatabaseUpdater.update(server: server, forceUpdate: false)
Current.panelsUpdater.update()
}
private func showNoActiveURLError() {
// Load about:blank in webview to prevent any current connections
load(request: URLRequest(url: URL(string: "about:blank")!))
Current.Log.info("Loading about:blank in webview due to no activeURL")
// Alert the user that there's no URL that the App can use
let controller = ConnectionSecurityLevelBlockView(server: server).embeddedInHostingController()
controller.modalPresentationStyle = .fullScreen
controller.isModalInPresentation = true
controller.view.tag = WebViewControllerOverlayedViewTags.noActiveURLError.rawValue
controller.modalTransitionStyle = .crossDissolve
guard ![
WebViewControllerOverlayedViewTags.noActiveURLError.rawValue,
WebViewControllerOverlayedViewTags.settingsView.rawValue,
WebViewControllerOverlayedViewTags.onboardingPermissions.rawValue,
].contains(presentedViewController?.view.tag ?? -1) else {
Current.Log.info("'No active URL' screen was not presented because of high priority view already visible")
return
}
presentOverlayController(controller: controller, animated: true)
}
private func hideNoActiveURLError() {
if presentedViewController?.view.tag == WebViewControllerOverlayedViewTags.noActiveURLError.rawValue {
presentedViewController?.dismiss(animated: true)
}
}
@objc private func loadActiveURLIfNeeded() {
guard !loadActiveURLIfNeededInProgress else {
Current.Log.info("loadActiveURLIfNeeded already in progress, skipping")
return
}
loadActiveURLIfNeededInProgress = true
Current.Log.info("loadActiveURLIfNeeded called")
Current.connectivity.syncNetworkInformation { [weak self] in
defer {
self?.loadActiveURLIfNeededInProgress = false
}
guard let self else { return }
guard let webviewURL = server.info.connection.webviewURL() else {
Current.Log.info("not loading, no url")
showNoActiveURLError()
return
}
hideNoActiveURLError()
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 if let currentURL = webView.url, currentURL.path.count > 1 {
// Preserve the current path when the base URL changes (e.g., switching between internal/external)
var components = URLComponents(url: webviewURL, resolvingAgainstBaseURL: true)
components?.path = currentURL.path
if let query = currentURL.query {
// Preserve external_auth if present, add other query items
var queryItems = components?.queryItems ?? []
let currentQueryItems = URLComponents(url: currentURL, resolvingAgainstBaseURL: false)?
.queryItems ?? []
for item in currentQueryItems where item.name != "external_auth" {
queryItems.append(item)
}
components?.queryItems = queryItems
}
components?.fragment = currentURL.fragment
let newURL = components?.url ?? webviewURL
Current.Log.info("preserving current path on base URL change: \(newURL.path)")
request = URLRequest(url: newURL)
} else {
Current.Log.info("loading default url path: \(webviewURL.path)")
request = URLRequest(url: webviewURL)
}
load(request: request)
}
}
@objc private func swipe(_ gesture: UISwipeGestureRecognizer) {
guard gesture.state == .ended else {
return
}
let action = Current.settingsStore.gestures.getAction(for: gesture, numberOfTouches: gesture.numberOfTouches)
webViewGestureHandler.handleGestureAction(action)
}
@objc private func screenEdgeGestureRecognizerAction(_ gesture: UIScreenEdgePanGestureRecognizer) {
guard gesture.state == .ended else {
return
}
let gesture: AppGesture = gesture.edges == .left ? .swipeRight : .swipeLeft
let action = Current.settingsStore.gestures[gesture] ?? .none
webViewGestureHandler.handleGestureAction(action)
}
@objc private func updateSensors() {
// called via menu/keyboard shortcut too
firstly {
HomeAssistantAPI.manuallyUpdate(
applicationState: UIApplication.shared.applicationState,
type: .userRequested
)
}.catch { error in
Current.Log.error("Error when updating sensors from WKWebView reload: \(error)")
}
}
@objc func pullToRefresh(_ sender: UIRefreshControl) {
let now = Current.date()
// Check if this is a consecutive pull-to-refresh within 10 seconds
if let lastTimestamp = lastPullToRefreshTimestamp,
now.timeIntervalSince(lastTimestamp) < 10 {
// Second pull-to-refresh within 10 seconds - reset frontend cache
Current.Log.info("Consecutive pull-to-refresh detected within 10 seconds, resetting frontend cache")
Current.impactFeedback.impactOccurred(style: .medium)
// Reset the cache
Current.websiteDataStoreHandler.cleanCache { [weak self] in
Current.Log.info("Frontend cache reset after consecutive pull-to-refresh")
self?.pullToRefreshActions()
}
// Set the timestamp to now after cache reset to ensure proper timing for next pull
// This prevents immediate re-triggering while still tracking for future pulls
lastPullToRefreshTimestamp = now
} else {
// First pull-to-refresh or outside the 10-second window
lastPullToRefreshTimestamp = now
pullToRefreshActions()
}
}
private func pullToRefreshActions() {
refresh()
updateSensors()
}
@objc func openSettingsView(_ sender: UIButton) {
showSettingsViewController()
}
@objc private func openServerInSafari() {
if let url = webView.url {
guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return
}
// Remove external_auth=1 query item from URL
urlComponents.queryItems = urlComponents.queryItems?.filter { $0.name != "external_auth" }
if let url = urlComponents.url {
URLOpener.shared.open(url, options: [:], completionHandler: nil)
}
}
}
@objc private func copyCurrentSelectedContent() {
// Get selected text from the web view
webView.evaluateJavaScript("window.getSelection().toString();") { result, error in
Current.Log
.error(
"Copy selected content result: \(String(describing: result)), error: \(String(describing: error))"
)
if let selectedText = result as? String, !selectedText.isEmpty {
// Copy to clipboard
UIPasteboard.general.string = selectedText
}
}
}
@objc private func pasteContent() {
// Programmatically trigger the standard iOS paste action by calling the paste: selector
// This mimics the user selecting "Paste" from the context menu and allows paste to work properly
if webView.responds(to: #selector(paste(_:))) {
webView.perform(#selector(paste(_:)), with: nil)
}
}
@available(iOS 16.0, *)
@objc private func showFindInteraction() {
// Present the find interaction UI
if let findInteraction = webView.findInteraction {
findInteraction.presentFindNavigator(showingReplace: false)
}
}
@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()
}
}
)
}
@objc private func updateWebViewSettingsForNotification() {
updateWebViewSettings(reason: .settingChange)
}
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
URLOpener.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)
var config = swiftMessagesConfig()
config.duration = duration
config.dimMode = .none
let view = MessageView.viewFromNib(layout: .cardView)
view.configureContent(
title: L10n.Connection.Error.genericTitle,
body: nil,
iconImage: nil,
iconText: nil,
buttonImage: MaterialDesignIcons.helpCircleIcon.image(
ofSize: .init(width: 35, height: 35),
color: .haPrimary
),
buttonTitle: nil,
buttonTapHandler: { [weak self] _ in
SwiftMessages.hide()
guard let self else { return }
presentOverlayController(
controller: UIHostingController(rootView: ConnectionErrorDetailsView(server: server, error: error)),
animated: true
)
}
)
view.titleLabel?.numberOfLines = 0
view.bodyLabel?.numberOfLines = 0
SwiftMessages.show(config: config, view: view)
}
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: .haPrimary
),
buttonTitle: nil,
buttonTapHandler: { [weak self] _ in
self?.showSettingsViewController()
}
)
view.titleLabel?.numberOfLines = 0
view.bodyLabel?.numberOfLines = 0
// Avoid retrying from Home Assistant UI since this is a dead end
load(request: URLRequest(url: URL(string: "about:blank")!))
showEmptyState()
SwiftMessages.show(config: config, view: view)
}
func openDebug() {
let controller = UIHostingController(rootView: AnyView(
NavigationView {
VStack {
HStack(spacing: DesignSystem.Spaces.half) {
Text(verbatim: L10n.Settings.Debugging.ShakeDisclaimerOptional.title)
Toggle(isOn: .init(get: {
Current.settingsStore.gestures[.shake] == .openDebug
}, set: { newValue in
Current.settingsStore.gestures[.shake] = newValue ? .openDebug : HAGestureAction.none
}), label: { EmptyView() })
}
.padding()
.background(Color.haPrimary.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: DesignSystem.CornerRadius.oneAndHalf))
.padding(DesignSystem.Spaces.one)
DebugView()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
CloseButton { [weak self] in
self?.dismissOverlayController(animated: true, completion: nil)
}
}
}
}
}
))
presentOverlayController(controller: controller, animated: true)
}
// MARK: - Public
/// avoidUnecessaryReload Avoids reloading when the URL is the same as the current one
public func open(inline url: URL, avoidUnecessaryReload: Bool = false) {
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) }) {
if avoidUnecessaryReload, webView.url?.isEqualIgnoringQueryParams(to: url) == true {
Current.Log
.info(
"Not reloading WebView when open(inline) was requested, URL is the same as current and avoidUnecessaryReload is true"
)
return
}
load(request: URLRequest(url: url))
} else {
openURLInBrowser(url, self)
}
}
/// Used for OpenPage intent
public func openPanel(_ url: URL) {
loadViewIfNeeded()
guard url.queryItems?[AppConstants.QueryItems.openMoreInfoDialog.rawValue] == nil || server.info
.version >= .canNavigateMoreInfoDialogThroughFrontend else {
load(request: URLRequest(url: url))
Current.Log.verbose("Opening more-info dialog for URL: \(url)")
return
}
let urlPathIncludingQueryParams = {
// If the URL has query parameters, we need to include them in the path to ensure proper navigation
if let query = url.query, !query.isEmpty {
return "\(url.path)?\(query)"
}
return url.path
}()
navigate(path: urlPathIncludingQueryParams) { [weak self] success in
if !success {
Current.Log.warning("Failed to navigate through frontend for URL: \(url)")
// Fallback to loading the URL directly if navigation fails
self?.load(request: URLRequest(url: url))
}
}
}
/// Uses external bus to navigate through frontend instead of loading the page from scratch using the web view
/// Returns true if the navigation was successful
private func navigate(path: String, completion: @escaping (Bool) -> Void) {
guard server.info.version >= .canNavigateThroughFrontend else {
Current.Log.warning("Cannot navigate through frontend, core version is too low")
completion(false)
return
}
Current.Log.verbose("Requesting navigation using external bus to path: \(path)")
webViewExternalMessageHandler.sendExternalBus(message: .init(
command: WebViewExternalBusOutgoingMessage.navigate.rawValue,
payload: [
"path": path,
]
)).pipe { result in
switch result {
case .fulfilled:
completion(true)
case .rejected:
completion(false)
}
}
}
/// Manual reload does not take care of internal/external URL changes, prefer using `refresh()`
private func reload() {
Current.Log.verbose("Reload webView requested")
webView.reload()
}
public func showSettingsViewController() {
getLatestConfig()
if Current.sceneManager.supportsMultipleScenes, Current.isCatalyst {
Current.sceneManager.activateAnyScene(for: .settings)
} else {
// Use SwiftUI SettingsView wrapped in hosting controller
let settingsView = SettingsView().embeddedInHostingController()
settingsView.view.tag = WebViewControllerOverlayedViewTags.settingsView.rawValue
presentOverlayController(controller: settingsView, animated: true)
}
}
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,
],
],
],
],
]
))
}
}
// MARK: - WebView
extension WebViewController {
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
updateFrontendConnectionState(state: FrontEndConnectionState.disconnected.rawValue)
webViewExternalMessageHandler.stopImprovScanIfNeeded()
}
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 {
showEmptyState()
showSwiftMessage(error: error)
}
}
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
refreshControl.endRefreshing()
let nsError = error as NSError
let shouldShowError: Bool
// Handle URLError
if let urlError = error as? URLError {
shouldShowError = urlError.code != .cancelled
if shouldShowError {
Current.Log.error("Failure during content load: \(error)")
}
}
// Handle WebKitErrorDomain errors (e.g., Code 101 - invalid URL)
else if nsError.domain == "WebKitErrorDomain" {
shouldShowError = !nsError.isCancelled
Current.Log.error("WebKit error during content load: \(error)")
} else {
shouldShowError = !error.isCancelled
if shouldShowError {
Current.Log.error("Failure during content load: \(error)")
}
}
if shouldShowError {
showEmptyState()
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)
load(request: 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)
}
}
extension WebViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// Prevent scrollView from scrolling past the top or bottom
if scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height {
scrollView.contentOffset.y = scrollView.contentSize.height - scrollView.bounds.height
}
}
}
extension WebViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
}
extension WebViewController: WebViewControllerProtocol {
var canGoBack: Bool {
webView.canGoBack
}
var canGoForward: Bool {
webView.canGoForward
}
@objc func goBack() {
webView.goBack()
}
@objc func goForward() {
webView.goForward()
}
var overlayedController: UIViewController? {
presentedViewController
}
func presentOverlayController(controller: UIViewController, animated: Bool) {
DispatchQueue.main.async { [weak self] in
self?.dismissOverlayController(animated: false, completion: { [weak self] in
self?.present(controller, animated: animated, completion: nil)
})
}
}
func presentAlertController(controller: UIViewController, animated: Bool) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if let overlayedController {
overlayedController.present(controller, animated: animated, completion: nil)
} else {
present(controller, animated: animated, completion: nil)
}
}
}
func evaluateJavaScript(_ script: String, completion: ((Any?, (any Error)?) -> Void)?) {
webView.evaluateJavaScript(script, completionHandler: completion)
}
func dismissOverlayController(animated: Bool, completion: (() -> Void)?) {
dismissAllViewControllersAbove(completion: completion)
}
func dismissControllerAboveOverlayController() {
overlayedController?.dismissAllViewControllersAbove()
}
func updateFrontendConnectionState(state: String) {
emptyStateTimer?.invalidate()
emptyStateTimer = nil
let state = FrontEndConnectionState(rawValue: state) ?? .unknown
isConnected = state == .connected
// Possible values: connected, disconnected, auth-invalid
if state == .connected {
hideEmptyState()
} else {
// Start a 10-second timer. If not interrupted by a 'connected' state, set alpha to 1.
emptyStateTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { [weak self] _ in
self?.showEmptyState()
}
}
}
func navigateToPath(path: String) {
if let activeURL = server.info.connection.activeURL(), let url = URL(string: activeURL.absoluteString + path) {
load(request: URLRequest(url: url))
}
}
func load(request: URLRequest) {
Current.Log.verbose("Requesting webView navigation to \(String(describing: request.url?.absoluteString))")
webView.load(request)
}
@objc func refresh() {
Current.connectivity.syncNetworkInformation { [weak self] in
guard let self else { return }
// called via menu/keyboard shortcut too
if let webviewURL = server.info.connection.webviewURL() {
if webView.url?.baseIsEqual(to: webviewURL) == true, !lastNavigationWasServerError {
reload()
} else {
load(request: URLRequest(url: webviewURL))
}
hideNoActiveURLError()
} else {
showNoActiveURLError()
}
}
updateDatabaseAndPanels()
}
}