On iOS 26, `UIWindowScene.keyWindow` can be nil during scene connection until the scene becomes active. The implicitly-unwrapped `mainWindow` then crashes on the next line. Fall back to `scene.windows.first`, which is the SwiftUI WindowGroup's window and is already attached to the scene at this point.
300 lines
11 KiB
Swift
300 lines
11 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2023-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
class WindowManager: SecureWindowManagerProtocol {
|
|
private let appDelegate: AppDelegate
|
|
weak var mainScene: UIWindowScene?
|
|
weak var mainSession: UISceneSession?
|
|
weak var delegate: SecureWindowManagerDelegate?
|
|
|
|
private(set) var mainWindow: UIWindow!
|
|
private(set) var overlayWindow: UIWindow!
|
|
private(set) var globalSearchWindow: UIWindow!
|
|
private(set) var alternateWindow: UIWindow!
|
|
|
|
private(set) var openWindowAction: OpenWindowAction!
|
|
private(set) var dismissWindowAction: DismissWindowAction!
|
|
|
|
var secondaryWindowsEnabled = true {
|
|
didSet {
|
|
if secondaryWindowsEnabled == false {
|
|
closeAllSecondaryWindows()
|
|
}
|
|
}
|
|
}
|
|
|
|
var windows: [UIWindow] {
|
|
[mainWindow, overlayWindow, globalSearchWindow, alternateWindow]
|
|
}
|
|
|
|
// periphery:ignore - auto cancels when reassigned
|
|
/// The task used to switch windows, so that we don't get stuck in the wrong state with a quick switch.
|
|
@CancellableTask private var switchTask: Task<Void, Error>?
|
|
/// A duration that allows window switching to wait a couple of frames to avoid a transition through black.
|
|
private let windowHideDelay = Duration.milliseconds(33)
|
|
|
|
private var coordinators: [SecondaryWindowType: (coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?)] = [:]
|
|
|
|
init(appDelegate: AppDelegate) {
|
|
self.appDelegate = appDelegate
|
|
}
|
|
|
|
func configure(withScene scene: UIWindowScene, session: UISceneSession) {
|
|
// This gets called for all opened windows, we're only interested in the main window.
|
|
guard let userInfo = session.userInfo, userInfo[SceneDelegate.sceneIDKey] as? String == SceneDelegate.mainSceneID else {
|
|
scene.windows.forEach { $0.tintColor = .compound.textActionPrimary } // SecondaryWindow tinting.
|
|
return
|
|
}
|
|
|
|
// Don't allow more than 1 main window to be presented.
|
|
if mainScene != nil {
|
|
// The window will be presented momentarily, so lets leave it blank.
|
|
scene.keyWindow?.rootViewController = UIHostingController(rootView: Color.clear)
|
|
UIApplication.shared.requestSceneSessionDestruction(session, options: nil)
|
|
return
|
|
}
|
|
|
|
mainScene = scene
|
|
mainSession = session
|
|
|
|
// Restore the previous window size on macOS as this isn't automatic.
|
|
if let previousSize = mainWindow?.frame.size {
|
|
scene.resizeWindowOnMac(to: previousSize)
|
|
}
|
|
|
|
// `keyWindow` can be nil on iOS 26 until the scene becomes active, but the
|
|
// SwiftUI WindowGroup's window is already attached to the scene by then.
|
|
mainWindow = scene.keyWindow ?? scene.windows.first
|
|
mainWindow.tintColor = .compound.textActionPrimary
|
|
|
|
overlayWindow = PassthroughWindow(windowScene: scene)
|
|
overlayWindow.tintColor = .compound.textActionPrimary
|
|
overlayWindow.backgroundColor = .clear
|
|
overlayWindow.isHidden = false
|
|
|
|
globalSearchWindow = UIWindow(windowScene: scene)
|
|
globalSearchWindow.tintColor = .compound.textActionPrimary
|
|
globalSearchWindow.backgroundColor = .clear
|
|
globalSearchWindow.isHidden = true
|
|
|
|
alternateWindow = UIWindow(windowScene: scene)
|
|
alternateWindow.tintColor = .compound.textActionPrimary
|
|
|
|
delegate?.windowManagerDidConfigureWindows(self)
|
|
}
|
|
|
|
func configure(withOpenWindowAction openWindowAction: OpenWindowAction,
|
|
dismissWindowAction: DismissWindowAction) {
|
|
self.openWindowAction = openWindowAction
|
|
self.dismissWindowAction = dismissWindowAction
|
|
}
|
|
|
|
func handleSceneDisconnection(_ scene: UIWindowScene) {
|
|
if scene == mainScene {
|
|
mainScene = nil
|
|
mainSession = nil
|
|
// Leave the mainWindow so we can reapply it's size on macOS.
|
|
}
|
|
}
|
|
|
|
func handleRoute(_ appRoute: AppRoute, windowType: SecondaryWindowType) {
|
|
MXLog.info("Handling app route: \(appRoute) for window type: \(windowType)")
|
|
|
|
guard let flowCoordinator = coordinators[windowType]?.flowCoordinator else {
|
|
MXLog.error("Invalid flow coordinator")
|
|
return
|
|
}
|
|
|
|
flowCoordinator.handleAppRoute(appRoute, animated: true)
|
|
}
|
|
|
|
func switchToMain() {
|
|
mainWindow.isHidden = false
|
|
overlayWindow.isHidden = false
|
|
|
|
mainWindow.makeKey()
|
|
|
|
switchTask = Task {
|
|
// Delay hiding to make sure the main windows are visible.
|
|
try await Task.sleep(for: windowHideDelay)
|
|
|
|
alternateWindow.isHidden = true
|
|
}
|
|
}
|
|
|
|
func switchToAlternate() {
|
|
alternateWindow.isHidden = false
|
|
|
|
// We don't know what route the app will use when returning back
|
|
// to the main window, so end any editing operation now to avoid
|
|
// e.g. the keyboard being displayed on top of a call sheet.
|
|
mainWindow.endEditing(true)
|
|
|
|
hideGlobalSearch()
|
|
|
|
// alternateWindow.isHidden = false cannot got inside the Task otherwise the timing
|
|
// is poor when you lock the phone - you briefly see the main window for a few
|
|
// frames after you've unlocked the phone and then the placeholder animates in.
|
|
switchTask = Task {
|
|
// Delay hiding to make sure the alternate window is visible.
|
|
try await Task.sleep(for: windowHideDelay)
|
|
|
|
mainWindow.isHidden = true
|
|
overlayWindow.isHidden = true
|
|
globalSearchWindow.isHidden = true
|
|
}
|
|
}
|
|
|
|
func showGlobalSearch() {
|
|
MXLog.info("Received global search presentation request.")
|
|
|
|
guard alternateWindow.isHidden else {
|
|
MXLog.info("The alternate window is visible, ignoring.")
|
|
return
|
|
}
|
|
|
|
if let mainSession {
|
|
let request = UISceneSessionActivationRequest(session: mainSession)
|
|
UIApplication.shared.activateSceneSession(for: request) { error in
|
|
MXLog.error("Failed to focus window with error: \(error)")
|
|
}
|
|
}
|
|
|
|
globalSearchWindow.isHidden = false
|
|
globalSearchWindow.makeKeyAndVisible()
|
|
}
|
|
|
|
func hideGlobalSearch() {
|
|
MXLog.info("Received global search dismissal request.")
|
|
|
|
guard alternateWindow.isHidden else {
|
|
MXLog.info("The alternate window is visible, ignoring.")
|
|
return
|
|
}
|
|
|
|
globalSearchWindow.isHidden = true
|
|
mainWindow.makeKey()
|
|
}
|
|
|
|
// MARK: - OrientationManager
|
|
|
|
func setOrientation(_ orientation: UIInterfaceOrientationMask) {
|
|
mainScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
|
|
}
|
|
|
|
func lockOrientation(_ orientation: UIInterfaceOrientationMask) {
|
|
appDelegate.orientationLock = orientation
|
|
}
|
|
|
|
// MARK: - Secondary window support
|
|
|
|
func windowForType(_ type: SecondaryWindowType) -> AnyView {
|
|
MXLog.info("Requesting window for type: \(type)")
|
|
|
|
guard let coordinator = coordinators[type]?.coordinator else {
|
|
MXLog.error("Invalid coordinator for window type: \(type)")
|
|
return AnyView(InstantlyDismissingWindow())
|
|
}
|
|
|
|
// This behaves strangely and gets called late but cleans up enough
|
|
// and is self contained enough to be just good .. enough
|
|
return AnyView(coordinator.toPresentable().onDisappear { [weak self] in
|
|
self?.coordinators[type] = nil
|
|
})
|
|
}
|
|
|
|
func registerCoordinator(_ coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?, forWindowType type: SecondaryWindowType) {
|
|
if secondaryWindowsEnabled == false {
|
|
MXLog.error("Cannot register coordinator, secondary windows are disabled.")
|
|
return
|
|
}
|
|
|
|
coordinators[type] = (coordinator, flowCoordinator)
|
|
openWindowAction(value: type)
|
|
}
|
|
|
|
func closeSecondaryWindow(forType type: SecondaryWindowType) {
|
|
dismissWindowAction(value: type)
|
|
}
|
|
|
|
func closeAllSecondaryWindows() {
|
|
for key in coordinators.keys {
|
|
dismissWindowAction(value: key)
|
|
}
|
|
|
|
coordinators.removeAll()
|
|
}
|
|
}
|
|
|
|
private class PassthroughWindow: UIWindow {
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
if #available(iOS 26, *) {
|
|
// Passthrough UIWindow using SwiftUI in iOS 26
|
|
// https://stackoverflow.com/a/79835964/730924
|
|
guard let rootView = rootViewController?.view else {
|
|
return nil
|
|
}
|
|
|
|
// Special handling for glass buttons
|
|
// ".glass has a layer name of "@1" and and .glassProminent has a layer name of "@2""
|
|
guard let name = rootView.layer.hitTest(point)?.name, !name.starts(with: "@") else {
|
|
return rootView
|
|
}
|
|
|
|
return nil
|
|
} else {
|
|
guard let hitView = super.hitTest(point, with: event) else {
|
|
return nil
|
|
}
|
|
|
|
guard let rootViewController else {
|
|
return nil
|
|
}
|
|
|
|
guard hitView != self else {
|
|
return nil
|
|
}
|
|
|
|
// If the returned view is the `UIHostingController`'s view, ignore.
|
|
return rootViewController.view == hitView ? nil : hitView
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whenever restoring an app SwiftUI tries to restore its windows as well
|
|
/// which we're generally not prepared for so use this to just close them instead
|
|
private struct InstantlyDismissingWindow: View {
|
|
@Environment(\.dismissWindow) var dismissWindow
|
|
|
|
var body: some View {
|
|
Rectangle()
|
|
.task {
|
|
dismissWindow()
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension UIWindowScene {
|
|
func resizeWindowOnMac(to size: CGSize) {
|
|
// Hackity hack 🔨
|
|
guard ProcessInfo.processInfo.isiOSAppOnMac, let sizeRestrictions else { return }
|
|
|
|
self.sizeRestrictions?.minimumSize = size
|
|
self.sizeRestrictions?.maximumSize = size
|
|
|
|
Task {
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
self.sizeRestrictions?.minimumSize = sizeRestrictions.minimumSize
|
|
self.sizeRestrictions?.maximumSize = sizeRestrictions.maximumSize
|
|
}
|
|
}
|
|
}
|