mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-24 20:17:30 -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 --> ## 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. -->
356 lines
13 KiB
Swift
356 lines
13 KiB
Swift
import Foundation
|
|
import MBProgressHUD
|
|
import PromiseKit
|
|
import Shared
|
|
import UIKit
|
|
|
|
// TODO: can i combine this with the enum?
|
|
|
|
struct SceneQuery<DelegateType: UIWindowSceneDelegate> {
|
|
let activity: SceneActivity
|
|
}
|
|
|
|
extension UIWindowSceneDelegate {
|
|
func informManager(from connectionOptions: UIScene.ConnectionOptions) {
|
|
let pendingResolver: (Self) -> Void = Current.sceneManager
|
|
.pendingResolver(from: connectionOptions.userActivities)
|
|
|
|
pendingResolver(self)
|
|
}
|
|
}
|
|
|
|
/// The app-level coordinator for the primary web-view window. Implemented by `HomeAssistantView`'s
|
|
/// coordinator as the web view migrates off `WebViewWindowController`; reached via `SceneManager.appCoordinator`.
|
|
///
|
|
/// Not `@MainActor` — like the `WebViewWindowController` it replaces, it's called from PromiseKit `.done`
|
|
/// closures (which run on the main queue) across non-isolated contexts.
|
|
/// Where a URL-open request originated, used for the confirmation prompt copy.
|
|
enum OpenSource {
|
|
case notification
|
|
case deeplink
|
|
|
|
func message(with urlString: String) -> String {
|
|
switch self {
|
|
case .notification: return L10n.Alerts.OpenUrlFromNotification.message(urlString)
|
|
case .deeplink: return L10n.Alerts.OpenUrlFromDeepLink.message(urlString)
|
|
}
|
|
}
|
|
}
|
|
|
|
protocol AppCoordinator: AnyObject {
|
|
var presentedViewController: UIViewController? { get }
|
|
var window: UIWindow? { get }
|
|
func present(_ viewController: UIViewController, animated: Bool, completion: (() -> Void)?)
|
|
func show(alert: ServerAlert)
|
|
func showSettings()
|
|
func showAssistSettings()
|
|
func showDownloadManager(_ viewModel: DownloadManagerViewModel)
|
|
func showOnboardingPermissions(server: Server, steps: [OnboardingPermissionsNavigationViewModel.StepID])
|
|
@discardableResult func open(server: Server) -> Guarantee<any WebFrontend>
|
|
func selectServer(prompt: String?, includeSettings: Bool, completion: @escaping (Server) -> Void)
|
|
func presentInvitation(url: URL?)
|
|
func setup()
|
|
func open(
|
|
from: OpenSource,
|
|
server: Server,
|
|
urlString: String,
|
|
skipConfirm: Bool,
|
|
avoidUnnecessaryReload: Bool,
|
|
isComingFromAppIntent: Bool
|
|
)
|
|
func openSelectingServer(
|
|
from: OpenSource,
|
|
urlString: String,
|
|
skipConfirm: Bool,
|
|
queryParameters: [URLQueryItem]?,
|
|
isComingFromAppIntent: Bool
|
|
)
|
|
}
|
|
|
|
extension AppCoordinator {
|
|
func present(_ viewController: UIViewController) {
|
|
present(viewController, animated: true, completion: nil)
|
|
}
|
|
|
|
/// Convenience matching the old default arguments (`skipConfirm`/`avoidUnnecessaryReload` = false).
|
|
func open(from: OpenSource, server: Server, urlString: String, isComingFromAppIntent: Bool) {
|
|
open(
|
|
from: from,
|
|
server: server,
|
|
urlString: urlString,
|
|
skipConfirm: false,
|
|
avoidUnnecessaryReload: false,
|
|
isComingFromAppIntent: isComingFromAppIntent
|
|
)
|
|
}
|
|
|
|
/// Convenience with default `avoidUnnecessaryReload` = false.
|
|
func open(from: OpenSource, server: Server, urlString: String, skipConfirm: Bool, isComingFromAppIntent: Bool) {
|
|
open(
|
|
from: from,
|
|
server: server,
|
|
urlString: urlString,
|
|
skipConfirm: skipConfirm,
|
|
avoidUnnecessaryReload: false,
|
|
isComingFromAppIntent: isComingFromAppIntent
|
|
)
|
|
}
|
|
|
|
/// Convenience with default `queryParameters` = nil.
|
|
func openSelectingServer(from: OpenSource, urlString: String, skipConfirm: Bool, isComingFromAppIntent: Bool) {
|
|
openSelectingServer(
|
|
from: from,
|
|
urlString: urlString,
|
|
skipConfirm: skipConfirm,
|
|
queryParameters: nil,
|
|
isComingFromAppIntent: isComingFromAppIntent
|
|
)
|
|
}
|
|
}
|
|
|
|
final class SceneManager {
|
|
// types too hard here
|
|
fileprivate static let activityUserInfoKeyResolver = "resolver"
|
|
|
|
private struct PendingResolver {
|
|
private var handleBlock: (Any) -> Void
|
|
init<T>(resolver: @escaping (T) -> Void) {
|
|
self.handleBlock = { value in
|
|
if let value = value as? T {
|
|
resolver(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func resolve(with possible: some Any) {
|
|
handleBlock(possible)
|
|
}
|
|
}
|
|
|
|
private var pendingResolvers: [String: PendingResolver] = [:]
|
|
|
|
/// The current foreground `WebViewController`, published by `HomeAssistantView` (the SwiftUI web-frontend
|
|
/// host) as the web view migrates off `WebViewWindowController`. Consumers that only need the web view
|
|
/// read this instead of `webViewWindowControllerPromise.then(\.webViewControllerPromise)`.
|
|
private(set) var webViewControllerPromise: Guarantee<WebViewController>
|
|
private var webViewControllerSeal: (WebViewController) -> Void
|
|
|
|
/// Called by `HomeAssistantView` whenever it creates or replaces its `WebViewController`.
|
|
func setWebViewController(_ controller: WebViewController) {
|
|
if webViewControllerPromise.isFulfilled {
|
|
webViewControllerPromise = .value(controller)
|
|
} else {
|
|
webViewControllerSeal(controller)
|
|
}
|
|
}
|
|
|
|
private var appCoordinatorPromise: Guarantee<AppCoordinator>
|
|
private var appCoordinatorSeal: (AppCoordinator) -> Void
|
|
|
|
/// The primary web-view coordinator (`HomeAssistantView`), replacing `webViewWindowControllerPromise`.
|
|
var appCoordinator: Guarantee<AppCoordinator> { appCoordinatorPromise }
|
|
|
|
/// Called by `HomeAssistantView` once its coordinator exists.
|
|
func registerAppCoordinator(_ coordinator: AppCoordinator) {
|
|
if appCoordinatorPromise.isFulfilled {
|
|
appCoordinatorPromise = .value(coordinator)
|
|
} else {
|
|
appCoordinatorSeal(coordinator)
|
|
}
|
|
}
|
|
|
|
init() {
|
|
(self.webViewControllerPromise, self.webViewControllerSeal) = Guarantee<WebViewController>.pending()
|
|
(self.appCoordinatorPromise, self.appCoordinatorSeal) = Guarantee<AppCoordinator>.pending()
|
|
|
|
// swiftlint:disable prohibit_environment_assignment
|
|
Current.realmFatalPresentation = { [weak self] viewController in
|
|
guard let self else { return }
|
|
|
|
let under = UIViewController()
|
|
under.view.backgroundColor = .black
|
|
under.modalPresentationStyle = .fullScreen
|
|
|
|
appCoordinator.done { parent in
|
|
parent.present(under, animated: false, completion: {
|
|
under.present(viewController, animated: true, completion: nil)
|
|
})
|
|
}
|
|
}
|
|
// swiftlint:enable prohibit_environment_assignment
|
|
}
|
|
|
|
fileprivate func pendingResolver<T>(from activities: Set<NSUserActivity>) -> (T) -> Void {
|
|
let (promise, outerResolver) = Guarantee<T>.pending()
|
|
|
|
if supportsMultipleScenes {
|
|
activities.compactMap { activity in
|
|
activity.userInfo?[Self.activityUserInfoKeyResolver] as? String
|
|
}.compactMap { token in
|
|
pendingResolvers[token]
|
|
}.forEach { resolver in
|
|
promise.done { resolver.resolve(with: $0) }
|
|
}
|
|
} else {
|
|
pendingResolvers
|
|
.values
|
|
.forEach { resolver in promise.done { resolver.resolve(with: $0) } }
|
|
}
|
|
|
|
return outerResolver
|
|
}
|
|
|
|
private func existingScenes(for activity: SceneActivity) -> [UIScene] {
|
|
UIApplication.shared.connectedScenes.filter { scene in
|
|
// Filter out scenes that are in the background or unattached state
|
|
// as they may be in the process of being destroyed
|
|
guard scene.activationState != .unattached else {
|
|
return false
|
|
}
|
|
return scene.session.configuration.name.flatMap(SceneActivity.init(configurationName:)) == activity
|
|
}.sorted { a, b in
|
|
switch (a.activationState, b.activationState) {
|
|
case (.unattached, .unattached): return true
|
|
case (.unattached, _): return false
|
|
case (_, .unattached): return true
|
|
case (.foregroundActive, _): return true
|
|
case (_, .foregroundActive): return false
|
|
case (.foregroundInactive, _): return true
|
|
case (_, .foregroundInactive): return false
|
|
case (_, _): return true
|
|
}
|
|
}
|
|
}
|
|
|
|
public var supportsMultipleScenes: Bool {
|
|
UIApplication.shared.supportsMultipleScenes
|
|
}
|
|
|
|
public func activateAnyScene(for activity: SceneActivity) {
|
|
UIApplication.shared.requestSceneSessionActivation(
|
|
existingScenes(for: activity).first?.session,
|
|
userActivity: activity.activity,
|
|
options: nil
|
|
) { error in
|
|
Current.Log.error(error)
|
|
}
|
|
bringAppToFrontIfNeeded()
|
|
}
|
|
|
|
public func activateAnyScene(for activity: SceneActivity, with userInfo: [AnyHashable: Any]) {
|
|
UIApplication.shared.requestSceneSessionActivation(
|
|
existingScenes(for: activity).first?.session,
|
|
userActivity: activity.activity(with: userInfo),
|
|
options: nil
|
|
) { error in
|
|
Current.Log.error(error)
|
|
}
|
|
bringAppToFrontIfNeeded()
|
|
}
|
|
|
|
private func bringAppToFrontIfNeeded() {
|
|
#if targetEnvironment(macCatalyst)
|
|
Current.macBridge.activateApp()
|
|
#endif
|
|
}
|
|
|
|
public func scene<DelegateType: UIWindowSceneDelegate>(
|
|
for query: SceneQuery<DelegateType>
|
|
) -> Guarantee<DelegateType> {
|
|
if let active = existingScenes(for: query.activity).first,
|
|
let delegate = active.delegate as? DelegateType {
|
|
Current.Log.verbose("Ready to activate scene \(active.session.persistentIdentifier)")
|
|
|
|
let options = UIScene.ActivationRequestOptions()
|
|
options.requestingScene = active
|
|
|
|
// Only activate scene if not activated already
|
|
guard active.activationState != .foregroundActive else {
|
|
Current.Log
|
|
.verbose("Did not activate scene \(active.session.persistentIdentifier), it was already active")
|
|
return .value(delegate)
|
|
}
|
|
|
|
// Only activate scene if the app is already in foreground or transitioning to foreground
|
|
// This prevents widgets, notifications, or background tasks from unexpectedly bringing the app to
|
|
// foreground
|
|
let shouldActivate = UIApplication.shared.applicationState == .active ||
|
|
active.activationState == .foregroundInactive
|
|
|
|
if shouldActivate {
|
|
Current.Log.verbose("Activating scene \(active.session.persistentIdentifier)")
|
|
|
|
// Guarantee it runs on main thread when coming from widgets
|
|
DispatchQueue.main.async {
|
|
if #available(iOS 17.0, *) {
|
|
UIApplication.shared.activateSceneSession(for: .init(session: active.session, options: options))
|
|
} else {
|
|
UIApplication.shared.requestSceneSessionActivation(
|
|
active.session,
|
|
userActivity: nil,
|
|
options: options,
|
|
errorHandler: nil
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
Current.Log
|
|
.verbose(
|
|
"Skipping scene activation for \(active.session.persistentIdentifier) - app is in background"
|
|
)
|
|
}
|
|
|
|
return .value(delegate)
|
|
}
|
|
|
|
assert(
|
|
supportsMultipleScenes || query.activity == .webView,
|
|
"if we don't support multiple scenes, how are we running without one besides at immediate startup?"
|
|
)
|
|
|
|
let (promise, resolver) = Guarantee<DelegateType>.pending()
|
|
|
|
let token = UUID().uuidString
|
|
pendingResolvers[token] = PendingResolver(resolver: resolver)
|
|
|
|
if supportsMultipleScenes {
|
|
Current.Log.verbose("Ready to request new scene activation for \(query.activity)")
|
|
|
|
let activity = query.activity.activity
|
|
activity.userInfo = [
|
|
Self.activityUserInfoKeyResolver: token,
|
|
]
|
|
|
|
UIApplication.shared.requestSceneSessionActivation(
|
|
nil,
|
|
userActivity: activity,
|
|
options: nil,
|
|
errorHandler: { error in
|
|
// error is called in most cases, even when no error occurs, so we silently swallow it
|
|
// TODO: does this actually happen in normal circumstances?
|
|
Current.Log.error("scene activation error: \(error)")
|
|
}
|
|
)
|
|
}
|
|
|
|
return promise
|
|
}
|
|
|
|
public func showFullScreenConfirm(
|
|
icon: MaterialDesignIcons,
|
|
text: String,
|
|
onto window: Promise<UIWindow>
|
|
) {
|
|
window.done { window in
|
|
let hud = MBProgressHUD.showAdded(to: window, animated: true)
|
|
hud.mode = .customView
|
|
hud.backgroundView.style = .blur
|
|
hud.customView = with(IconImageView(frame: .init(x: 0, y: 0, width: 64, height: 64))) {
|
|
$0.iconDrawable = icon
|
|
}
|
|
hud.label.text = text
|
|
hud.hide(animated: true, afterDelay: 3)
|
|
}.cauterize()
|
|
}
|
|
}
|