Files
iOS/Sources/App/Frontend/WebView/WebViewController+URLLoading.swift
Bruno Pantaleão Gonçalves 12c8d5ccec Reworked the foundation for Kiosk Mode (#4805)
<!-- 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 -->
This PR removes the previous foundation for kiosk mode and add a
simplified version fully made for SwiftUI.

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


https://github.com/user-attachments/assets/d6f1ba1e-6806-4a72-9fb4-c326f3479ad4


## 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#1357

## 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: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:57:42 +02:00

197 lines
8.1 KiB
Swift

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()
}
}