mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-12 14:30:51 -05:00
<!-- 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 --> Introduce keyboard handling for WKWebView: add WebViewKeyboardAvoidance helpers for animation metrics and overlap calculation, a WKWebView extension to run a JS command that scrolls the focused editable element into view, and a managed bottom constraint for web views so the view is animated above the keyboard. Wire up keyboard observers and DispatchWorkItem scheduling/cleanup in WebViewController and OnboardingAuthLoginViewController, and add the scrollFocusedElementIntoView JavaScript to WebViewJavascriptCommands. Add unit tests verifying the bottom constraint creation and JS contents. ## 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. -->
335 lines
11 KiB
Swift
335 lines
11 KiB
Swift
import AVFoundation
|
|
import AVKit
|
|
import Combine
|
|
import CoreLocation
|
|
import HAKit
|
|
import Improv_iOS
|
|
import KeychainAccess
|
|
import MBProgressHUD
|
|
import PromiseKit
|
|
import Shared
|
|
import SwiftMessages
|
|
import SwiftUI
|
|
import UIKit
|
|
@preconcurrency import WebKit
|
|
|
|
final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
|
|
var webView: WKWebView!
|
|
let server: Server
|
|
|
|
var urlObserver: NSKeyValueObservation?
|
|
var tokens = [HACancellable]()
|
|
|
|
let refreshControl = UIRefreshControl()
|
|
let leftEdgePanGestureRecognizer: UIScreenEdgePanGestureRecognizer
|
|
let rightEdgeGestureRecognizer: UIScreenEdgePanGestureRecognizer
|
|
|
|
var emptyStateView: UIView?
|
|
let emptyStateTransitionDuration: TimeInterval = 0.3
|
|
|
|
var statusBarView: UIView?
|
|
var webViewTopConstraint: NSLayoutConstraint?
|
|
var webViewBottomConstraint: NSLayoutConstraint?
|
|
var keyboardFocusedElementScrollWorkItem: DispatchWorkItem?
|
|
|
|
var initialURL: URL?
|
|
var statusBarButtonsStack: UIStackView?
|
|
var lastNavigationWasServerError = false
|
|
var reconnectBackgroundTimer: Timer? {
|
|
willSet {
|
|
if reconnectBackgroundTimer != newValue {
|
|
reconnectBackgroundTimer?.invalidate()
|
|
}
|
|
}
|
|
}
|
|
|
|
var connectionState: FrontEndConnectionState = .unknown
|
|
|
|
var loadActiveURLIfNeededInProgress = false
|
|
|
|
/// Track the timestamp of the last pull-to-refresh action
|
|
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
|
|
let webViewGestureHandler = WebViewGestureHandler()
|
|
|
|
/// Handler for script messages sent from the webview to the app
|
|
let webViewScriptMessageHandler = WebViewScriptMessageHandler()
|
|
|
|
/// Defer showing the empty state until disconnected for 10 seconds (used by
|
|
/// updateFrontendConnectionState in WebViewController+ProtocolConformance.swift)
|
|
var emptyStateTimer: Timer?
|
|
|
|
/// Frontend notifies when connection is established or not
|
|
/// Each navigation resets this to false so we can show the empty state
|
|
var isConnected = false
|
|
|
|
var underlyingPreferredStatusBarStyle: UIStatusBarStyle = .lightContent
|
|
|
|
override var prefersStatusBarHidden: Bool {
|
|
Current.settingsStore.fullScreen || kioskPrefersStatusBarHidden
|
|
}
|
|
|
|
override var prefersHomeIndicatorAutoHidden: Bool {
|
|
Current.settingsStore.fullScreen || kioskPrefersHomeIndicatorAutoHidden
|
|
}
|
|
|
|
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
|
|
|
|
// MARK: - Initialization
|
|
|
|
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 {
|
|
keyboardFocusedElementScrollWorkItem?.cancel()
|
|
NotificationCenter.default.removeObserver(
|
|
self,
|
|
name: UIResponder.keyboardWillChangeFrameNotification,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.removeObserver(
|
|
self,
|
|
name: UIResponder.keyboardDidChangeFrameNotification,
|
|
object: nil
|
|
)
|
|
removeEmptyStateObservations()
|
|
self.urlObserver = nil
|
|
self.tokens.forEach { $0.cancel() }
|
|
}
|
|
|
|
static func makeWebViewConfiguration() -> WKWebViewConfiguration {
|
|
let config = WKWebViewConfiguration()
|
|
config.allowsInlineMediaPlayback = true
|
|
// Avoid interrupting background audio when the frontend loads media-capable elements.
|
|
config.mediaTypesRequiringUserActionForPlayback = .audio
|
|
return config
|
|
}
|
|
|
|
// MARK: - View Lifecycle
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
becomeFirstResponder()
|
|
|
|
observeConnectionNotifications()
|
|
|
|
let statusBarView = setupStatusBarView()
|
|
|
|
let config = Self.makeWebViewConfiguration()
|
|
|
|
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: true
|
|
))
|
|
|
|
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: true
|
|
))
|
|
|
|
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)
|
|
setupEdgeGestures()
|
|
setupURLObserver()
|
|
|
|
webView.navigationDelegate = self
|
|
webView.uiDelegate = self
|
|
webView.scrollView.delegate = self
|
|
|
|
setupWebViewConstraints(statusBarView: statusBarView)
|
|
setupKeyboardAvoidance()
|
|
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()
|
|
setupKioskMode()
|
|
}
|
|
|
|
// 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()
|
|
|
|
// Refresh kiosk status bar state when view appears (e.g., after settings modal dismisses)
|
|
if KioskModeManager.shared.isKioskModeActive {
|
|
setNeedsStatusBarAppearanceUpdate()
|
|
navigationController?.setNeedsStatusBarAppearanceUpdate()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|