Introduce support for multiple windows on mac and iPad OS

Once the app starts the WindowManager is configured with SwiftUI's environment OpenWindowAction. It can then be used to register coordinators (that provide the toPresentable view) and an optional flow coordinator (as most of the screens are part of a flow, especially rooms).
Once a coordinator is registed, the WindowManager invokes the OpenWindowAction which in turn makes the Application call its newly introduced WindowManagerWindowType WindowGroup's block to instantiate a new visual window rooting that view.

The WindowManager is also responsible for wrapping the presentable in a disappearance block and clean up the coordinator stack.

# Conflicts:
#	ElementX/Sources/Application/AppCoordinator.swift
This commit is contained in:
Stefan Ceriu
2026-03-26 20:28:50 +02:00
committed by Stefan Ceriu
parent 91a10f4d51
commit f1ef968975
16 changed files with 231 additions and 63 deletions
@@ -176,7 +176,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
.sink { [weak self] action in
switch action {
case .startCall(let roomID, let isVoiceCall):
self?.handleAppRoute(.call(roomID: roomID, isVoiceCall: isVoiceCall))
self?.handleAppRoute(.call(roomID: roomID, isVoiceCall: isVoiceCall), windowType: nil)
case .receivedIncomingCallRequest:
// When reporting a VoIP call through the CXProvider's `reportNewIncomingVoIPPushPayload`
// the UIApplication states don't change and syncing is neither started nor ran on
@@ -233,14 +233,41 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
secondaryButton: .init(title: L10n.actionContinue) { openURLAction(confirmationParameters.internalURL) })
return true
}
func handleAppRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType?) {
if let windowType {
windowManager.handleRoute(appRoute, windowType: windowType)
return
}
var handled = false
switch appRoute {
case .accountProvisioningLink:
if let authenticationFlowCoordinator {
authenticationFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
handled = true
}
default:
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
handled = true
}
}
if !handled {
storedAppRoute = appRoute
}
}
func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool {
func handleDeepLink(_ url: URL, isExternalURL: Bool, windowType: WindowManagerWindowType?) -> Bool {
// Parse into an AppRoute to redirect these in a type safe way.
if let route = appRouteURLParser.route(from: url) {
switch route {
case .accountProvisioningLink:
handleAppRoute(route)
handleAppRoute(route,
windowType: windowType)
case .genericCallLink(let url):
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
@@ -249,33 +276,43 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
case .userProfile(let userID):
if isExternalURL {
handleAppRoute(route)
handleAppRoute(route,
windowType: windowType)
} else {
handleAppRoute(.roomMemberDetails(userID: userID))
handleAppRoute(.roomMemberDetails(userID: userID),
windowType: windowType)
}
case .room(let roomID, let via):
if isExternalURL {
handleAppRoute(route)
handleAppRoute(route,
windowType: windowType)
} else {
handleAppRoute(.childRoom(roomID: roomID, via: via))
handleAppRoute(.childRoom(roomID: roomID, via: via),
windowType: windowType)
}
case .roomAlias(let alias):
if isExternalURL {
handleAppRoute(route)
handleAppRoute(route,
windowType: windowType)
} else {
handleAppRoute(.childRoomAlias(alias))
handleAppRoute(.childRoomAlias(alias),
windowType: windowType)
}
case .event(let eventID, let roomID, let via):
if isExternalURL {
handleAppRoute(route)
handleAppRoute(route,
windowType: windowType)
} else {
handleAppRoute(.childEvent(eventID: eventID, roomID: roomID, via: via))
handleAppRoute(.childEvent(eventID: eventID, roomID: roomID, via: via),
windowType: windowType)
}
case .eventOnRoomAlias(let eventID, let alias):
if isExternalURL {
handleAppRoute(route)
handleAppRoute(route,
windowType: windowType)
} else {
handleAppRoute(.childEventOnRoomAlias(eventID: eventID, alias: alias))
handleAppRoute(.childEventOnRoomAlias(eventID: eventID, alias: alias),
windowType: windowType)
}
case .share(let payload):
guard isExternalURL else {
@@ -284,7 +321,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
do {
try handleAppRoute(.share(payload.withDefaultTemporaryDirectory()))
try handleAppRoute(.share(payload.withDefaultTemporaryDirectory()),
windowType: windowType)
} catch {
MXLog.error("Failed moving payload out of the app group container: \(error)")
}
@@ -309,7 +347,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
MXLog.info("Starting call in room: \(roomIdentifier)")
handleAppRoute(AppRoute.call(roomID: roomIdentifier, isVoiceCall: false))
handleAppRoute(AppRoute.call(roomID: roomIdentifier, isVoiceCall: false), windowType: nil)
}
// MARK: - AuthenticationFlowCoordinatorDelegate
@@ -363,15 +401,15 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
} else {
storedRoomsToAwait = [roomID]
}
handleAppRoute(.room(roomID: roomID, via: []))
handleAppRoute(.room(roomID: roomID, via: []), windowType: nil)
} else if appSettings.threadsEnabled, let threadRootEventID = content.threadRootEventID {
handleAppRoute(.thread(roomID: roomID, threadRootEventID: threadRootEventID, focusEventID: eventID))
handleAppRoute(.thread(roomID: roomID, threadRootEventID: threadRootEventID, focusEventID: eventID), windowType: nil)
} else if let eventID {
// Only track main timeline event deeplinking
ServiceLocator.shared.analytics.signpost.startTransaction(.notificationToMessage)
handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []))
handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []), windowType: nil)
} else {
handleAppRoute(.room(roomID: roomID, via: []))
handleAppRoute(.room(roomID: roomID, via: []), windowType: nil)
}
}
@@ -906,27 +944,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
.store(in: &cancellables)
}
private func handleAppRoute(_ appRoute: AppRoute) {
var handled = false
switch appRoute {
case .accountProvisioningLink:
if let authenticationFlowCoordinator {
authenticationFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
handled = true
}
default:
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
handled = true
}
}
if !handled {
storedAppRoute = appRoute
}
}
private func clearCache() {
guard let userSession else {
fatalError("User session not setup")
@@ -12,7 +12,9 @@ import Foundation
protocol AppCoordinatorProtocol: CoordinatorProtocol {
var windowManager: SecureWindowManagerProtocol { get }
@discardableResult func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool
@discardableResult func handleDeepLink(_ url: URL, isExternalURL: Bool, windowType: WindowManagerWindowType?) -> Bool
func handleAppRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType?)
func handlePotentialPhishingAttempt(url: URL, openURLAction: @escaping (URL) -> Void) -> Bool
+31 -14
View File
@@ -12,6 +12,7 @@ import SwiftUI
struct Application: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@Environment(\.openURL) private var openURL
@Environment(\.openWindow) private var openWindow
private var appCoordinator: AppCoordinatorProtocol!
@@ -39,19 +40,7 @@ struct Application: App {
Divider().ignoresSafeArea()
}
}
.environment(\.openURL, OpenURLAction { url in
if appCoordinator.handleDeepLink(url, isExternalURL: false) {
return .handled
}
if appCoordinator.handlePotentialPhishingAttempt(url: url, openURLAction: { url in
openURL(url, isExternalURL: false)
}) {
return .handled
}
return .systemAction
})
.environment(\.openURL, openURLAction(appCoordinator: appCoordinator, windowType: nil))
.onOpenURL { url in
openURL(url, isExternalURL: true)
}
@@ -62,14 +51,42 @@ struct Application: App {
}
.task {
appCoordinator.start()
appCoordinator.windowManager.configure(with: openWindow)
}
}
.handlesExternalEvents(matching: ["*"])
// This is invoked in response of the WindowManager receiving a register
// coordinator request and invoking the `OpenWindowAction` with which
// it's configured in the task above.
WindowGroup(for: WindowManagerWindowType.self) { $type in
if let type {
appCoordinator.windowManager.windowForType(type)
.environment(\.openURL, openURLAction(appCoordinator: appCoordinator, windowType: type))
}
}
}
private func openURLAction(appCoordinator: AppCoordinatorProtocol, windowType: WindowManagerWindowType?) -> OpenURLAction {
.init { url in
if appCoordinator.handleDeepLink(url, isExternalURL: false, windowType: windowType) {
return .handled
}
if appCoordinator.handlePotentialPhishingAttempt(url: url, openURLAction: { url in
openURL(url, isExternalURL: false)
}) {
return .handled
}
return .systemAction
}
}
// MARK: - Private
private func openURL(_ url: URL, isExternalURL: Bool) {
if !appCoordinator.handleDeepLink(url, isExternalURL: isExternalURL) {
if !appCoordinator.handleDeepLink(url, isExternalURL: isExternalURL, windowType: nil) {
openURLInSystemBrowser(url)
}
}
@@ -18,6 +18,8 @@ class WindowManager: SecureWindowManagerProtocol {
private(set) var overlayWindow: UIWindow!
private(set) var globalSearchWindow: UIWindow!
private(set) var alternateWindow: UIWindow!
private(set) var openWindowAction: OpenWindowAction!
var windows: [UIWindow] {
[mainWindow, overlayWindow, globalSearchWindow, alternateWindow]
@@ -29,6 +31,8 @@ class WindowManager: SecureWindowManagerProtocol {
/// 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: [WindowManagerWindowType: (coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?)] = [:]
init(appDelegate: AppDelegate) {
self.appDelegate = appDelegate
}
@@ -54,6 +58,29 @@ class WindowManager: SecureWindowManagerProtocol {
delegate?.windowManagerDidConfigureWindows(self)
}
func configure(with openWindowAction: OpenWindowAction) {
self.openWindowAction = openWindowAction
}
func registerCoordinator(_ coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?, forWindowType type: WindowManagerWindowType) {
coordinators[type] = (coordinator, flowCoordinator)
openWindowAction(value: type)
}
func windowForType(_ type: WindowManagerWindowType) -> AnyView? {
guard let coordinator = coordinators[type]?.coordinator else {
return nil
}
return coordinator.toPresentable()
}
func handleRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType) {
if let flowCoordinator = coordinators[windowType]?.flowCoordinator {
flowCoordinator.handleAppRoute(appRoute, animated: true)
}
}
func switchToMain() {
mainWindow.isHidden = false
overlayWindow.isHidden = false
@@ -8,6 +8,11 @@
import SwiftUI
enum WindowManagerWindowType: Hashable, Codable {
case room(roomID: String)
case settings
}
protocol SecureWindowManagerDelegate: AnyObject {
/// The window manager has configured its windows.
func windowManagerDidConfigureWindows(_ windowManager: SecureWindowManagerProtocol)
@@ -20,6 +25,12 @@ protocol SecureWindowManagerProtocol: WindowManagerProtocol {
/// Configures the window manager to operate on the supplied scene.
func configure(with windowScene: UIWindowScene)
func configure(with openWindowAction: OpenWindowAction)
func handleRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType)
func windowForType(_ type: WindowManagerWindowType) -> AnyView?
/// Shows the main and overlay window combo, hiding the alternate window.
func switchToMain()
@@ -43,6 +54,8 @@ protocol WindowManagerProtocol: AnyObject, OrientationManagerProtocol {
/// All the windows being managed
var windows: [UIWindow] { get }
func registerCoordinator(_ coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?, forWindowType type: WindowManagerWindowType)
/// Makes the global search window key. Used to get automatic text field focus.
func showGlobalSearch()