mirror of
https://github.com/home-assistant/iOS.git
synced 2026-02-04 02:46:35 -06:00
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>
1663 lines
63 KiB
Swift
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()
|
|
}
|
|
}
|