iOS/Sources/App/Scenes/WebViewSceneDelegate.swift
Bruno Pantaleão Gonçalves 65316f2602
Check connection state before auto reload webview (#4364)
<!-- 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 -->
Recently it was introduced a feature to reload webview when app has been
backgrounded for more than 5 minutes, this PR adds a layer on top of it
that only reloads if the webview frontend connection state is not
connected.


## 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: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-18 11:13:20 +01:00

234 lines
8.8 KiB
Swift

import Foundation
import PromiseKit
import Shared
import UIKit
import WidgetKit
final class WebViewSceneDelegate: NSObject, UIWindowSceneDelegate {
var window: UIWindow?
var windowController: WebViewWindowController?
var urlHandler: IncomingURLHandler?
private var updateDatabaseTask: Task<Void, Never>?
/// Stores the timestamp when the app enters background, used to determine if auto-refresh is needed on reactivation
var backgroundTimestamp: Date?
/// Time threshold (in seconds) after which WebViewController should refresh when returning from background
private let backgroundRefreshThreshold: TimeInterval = 5 * 60
// swiftlint:disable cyclomatic_complexity
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let scene = scene as? UIWindowScene else { return }
// if it tries to connect for an external display, decline -- it'll mirror instead
guard session.role != .windowExternalDisplay else { return }
ScaleFactorMutator.record(sceneIdentifier: session.persistentIdentifier)
let window = UIWindow(haScene: scene)
let windowController = WebViewWindowController(
window: window,
restorationActivity: session.stateRestorationActivity
)
let urlHandler = IncomingURLHandler(windowController: windowController)
self.window = window
self.windowController = windowController
self.urlHandler = urlHandler
with(scene.sizeRestrictions) {
if scene.traitCollection.userInterfaceIdiom == .mac {
$0?.minimumSize = CGSize(width: 250, height: 250)
} else {
$0?.minimumSize = CGSize(width: 300, height: 300)
}
}
if Current.isCatalyst, Current.settingsStore.macNativeFeaturesOnly {
// This getter does not exist on macOS 10.15, so we need to check that it responds.
// Of course, this is not documented via availability headers, of course.
if connectionOptions.responds(to: #selector(getter: UIScene.ConnectionOptions.shortcutItem)),
let shortcutItem = connectionOptions.shortcutItem {
self.windowScene(scene, performActionFor: shortcutItem, completionHandler: { _ in })
} else if let url = Current.servers.all.first?.info.connection.activeURL() {
URLOpener.shared.open(url, options: [:], completionHandler: nil)
// Close window to avoid empty window left behind
if let scene = window.windowScene {
UIApplication.shared.requestSceneSessionDestruction(scene.session, options: nil, errorHandler: nil)
}
}
} else {
windowController.setup()
// This getter does not exist on macOS 10.15, so we need to check that it responds.
// Of course, this is not documented via availability headers, of course.
if connectionOptions.responds(to: #selector(getter: UIScene.ConnectionOptions.shortcutItem)),
let shortcutItem = connectionOptions.shortcutItem {
self.windowScene(scene, performActionFor: shortcutItem, completionHandler: { _ in })
}
}
#if targetEnvironment(macCatalyst)
if let titlebar = scene.titlebar {
// disabling this also disables the "show tab bar" window tab bar (aka not uitabbar)
titlebar.titleVisibility = .hidden
titlebar.toolbar = nil
}
#endif
if !connectionOptions.urlContexts.isEmpty {
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
}
if !connectionOptions.userActivities.isEmpty {
for activity in connectionOptions.userActivities {
self.scene(scene, continue: activity)
}
}
informManager(from: connectionOptions)
#if targetEnvironment(macCatalyst)
WindowScenesManager.shared.sceneDidBecomeActive(scene)
#endif
}
func sceneWillResignActive(_ scene: UIScene) {
#if targetEnvironment(macCatalyst)
WindowScenesManager.shared.sceneWillResignActive(scene)
#endif
DataWidgetsUpdater.update()
}
func sceneDidDisconnect(_ scene: UIScene) {
windowController?.clearCachedControllers()
windowController = nil
window = nil
urlHandler = nil
#if targetEnvironment(macCatalyst)
WindowScenesManager.shared.didDiscardScene(scene)
#endif
DataWidgetsUpdater.update()
}
func sceneDidEnterBackground(_ scene: UIScene) {
if #available(iOS 17.0, *) {
// if a widget is pending confirmation to execute it's action
// this will reset that and the widget will be restored to default state
_ = ResetAllCustomWidgetConfirmationAppIntent()
}
DataWidgetsUpdater.update()
Current.modelManager.unsubscribe()
Current.appDatabaseUpdater.stop()
// Record timestamp when app enters background
backgroundTimestamp = Current.date()
}
func sceneDidBecomeActive(_ scene: UIScene) {
updateDatabaseTask?.cancel()
updateDatabaseTask = Task {
await updateDatabase()
}
cleanWidgetsCache()
updateLocation()
autoRefreshWebViewRoutine()
}
func windowScene(
_ windowScene: UIWindowScene,
performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void
) {
urlHandler?.handle(shortcutItem: shortcutItem)
.done {
completionHandler(true)
}.catch { _ in
completionHandler(false)
}
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for url in URLContexts.map(\.url) {
urlHandler?.handle(url: url)
}
}
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
windowController?.stateRestorationActivity()
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
urlHandler?.handle(userActivity: userActivity)
}
// MARK: - Private
/// When webview has been inactive for long, when opening the app we reload the webview if it's disconnected
private func autoRefreshWebViewRoutine() {
// Check if app was in background for 5 minutes or more
if let backgroundTimestamp {
let timeInterval = Current.date().timeIntervalSince(backgroundTimestamp)
if timeInterval >= backgroundRefreshThreshold, Current.settingsStore.refreshWebViewAfterInactive {
// Refresh WebViewController if it exists
// Note: webViewControllerPromise is a Guarantee, which cannot fail in PromiseKit
windowController?.webViewControllerPromise.done { webViewController in
webViewController.refreshIfDisconnected()
}
}
// Clear the timestamp
self.backgroundTimestamp = nil
}
}
/// Whenever a custom widget is executed it can create cache files to hold it state,
/// this clears it
private func cleanWidgetsCache() {
let widgetsCacheFile = AppConstants.widgetsCacheURL
// Clean up widgets cache file
do {
try FileManager.default.removeItem(at: widgetsCacheFile)
} catch {
Current.Log.error("Failed to remove widgets cache file: \(error)")
}
}
/// Sets up model manager and update database tables for cached panels and entities
private func updateDatabase() async {
Current.modelManager.cleanup().cauterize()
Current.modelManager.subscribe(isAppInForeground: {
UIApplication.shared.applicationState == .active
})
}
/// Force update location when user opens the app
private func updateLocation() {
Current.location.oneShotLocation(.Launch, nil).pipe { result in
switch result {
case let .fulfilled(location):
for api in Current.apis {
api.SubmitLocation(updateType: .Launch, location: location, zone: nil).pipe { result in
switch result {
case .fulfilled:
break // Submission succeeded, no action needed
case let .rejected(error):
Current.Log.error("Failed to submit location: \(error)")
}
}
}
case let .rejected(error):
Current.Log.error("Failed to get location on sceneDidBecomeActive: \(error)")
}
}
}
}