import HAKit import Shared import SwiftUI import UIKit @preconcurrency import WebKit // MARK: - URL Loading & Connection Lifecycle extension WebViewController { 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() }) } @objc func connectionInfoDidChange() { DispatchQueue.main.async { [self] in loadActiveURLIfNeeded() } } @objc func loadActiveURLIfNeeded() { guard !loadActiveURLIfNeededInProgress else { Current.Log.info("loadActiveURLIfNeeded already in progress, skipping") return } loadActiveURLIfNeededInProgress = true Current.Log.info("loadActiveURLIfNeeded called") let loadBlock: () -> Void = { [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 load(request: URLRequest(url: resolvedLoadURL(for: webviewURL))) } if Current.isCatalyst { loadBlock() } else { Current.connectivity.syncNetworkInformation { loadBlock() } } } /// Determines which URL to load for the active server: the kiosk dashboard (when applicable), the /// restored last URL, the preserved current path on a base-URL change, or the server default. private func resolvedLoadURL(for webviewURL: URL) -> URL { if let kioskURL = kioskDashboardURL(for: webviewURL) { // In kiosk mode the configured dashboard takes precedence over restore/last-path behavior. Current.Log.info("loading kiosk dashboard path: \(kioskURL.path)") return kioskURL } if Current.settingsStore.restoreLastURL, let initialURL, initialURL.baseIsEqual(to: webviewURL) { Current.Log.info("restoring initial url path: \(initialURL.path)") return initialURL } 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 currentURL.query != nil { // 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)") return newURL } Current.Log.info("loading default url path: \(webviewURL.path)") return webviewURL } /// The URL of the kiosk-configured dashboard for this server, or `nil` when kiosk mode is off, this /// isn't the kiosk server, or no specific dashboard was chosen (in which case the server default loads). private func kioskDashboardURL(for webviewURL: URL) -> URL? { let kiosk = Current.kioskSettings guard kiosk.enabled, kiosk.serverId == nil || kiosk.serverId == server.identifier.rawValue, let dashboard = kiosk.dashboard, !dashboard.isEmpty else { return nil } let path = dashboard.hasPrefix("/") ? dashboard : "/" + dashboard guard let url = server.info.connection.webviewURL(from: path), url.baseIsEqual(to: webviewURL) else { return nil } return url } 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") // Cancel any disconnected empty-state the about:blank load may have scheduled — the no-active-URL // overlay is the correct screen here, and the two are mutually exclusive. emptyStateTimer?.invalidate() emptyStateTimer = nil hideEmptyState() // Drive the SwiftUI no-active-URL overlay in `HomeAssistantView` instead of presenting a UIKit modal, // so an app-level Settings sheet can float over it without tearing it down. overlayState?.showsNoActiveURL = true } func hideNoActiveURLError() { overlayState?.showsNoActiveURL = false } @objc 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() } } ) } /// Updates the app database and panels for the current server /// Called after view appears and on pull to refresh to avoid blocking app launch func updateDatabaseAndPanels() { // Update runs in background automatically, returns immediately Current.appDatabaseUpdater.update(server: server, forceUpdate: false) Current.panelsUpdater.update() } }