Short solution
UIScene migration is not just an Info.plist change.
The safe first step is to move foreground UIWindow creation into SceneDelegate, while keeping the old AppDelegate as a temporary behavior bridge.
This is simplified pseudocode, not production code.
final class ExampleSceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let rootViewControllerFactory = RootViewControllerFactory()
private let launchRouter = LaunchRouter()
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options options: UIScene.ConnectionOptions
) {
guard let scene = scene as? UIWindowScene else { return }
let sceneWindow = UIWindow(windowScene: scene)
sceneWindow.rootViewController = rootViewControllerFactory.makeRootViewController()
sceneWindow.makeKeyAndVisible()
window = sceneWindow
UIApplication.shared.delegate?.window = sceneWindow
launchRouter.route(options)
}
}
The exact names do not matter. The boundary does: SceneDelegate owns the real window, and the old app delegate only mirrors it until the rest of the app is cleaned up.
Stop creating a process-owned window
Before scenes, many UIKit apps created the main window inside AppDelegate.
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = InitialScreenController()
window?.makeKeyAndVisible()
After UIScene, that code creates the wrong kind of foreground window. It belongs to the process-level app lifecycle, not to a connected UIWindowScene.
The replacement is small but important.
let sceneWindow = UIWindow(windowScene: connectedScene)
sceneWindow.rootViewController = InitialScreenController()
sceneWindow.makeKeyAndVisible()
The window now has a scene. That is what prevents black screens, misplaced root setup, and lifecycle drift when iOS reconnects foreground UI.
Keep shared setup in AppDelegate
AppDelegate should still own setup that is not tied to one visible scene: logging, notifications, appearance, shared SDKs, and launch option capture.
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
sharedServices.install()
notificationRegistrar.configure()
appearanceDefaults.apply()
launchRouter.capture(launchOptions)
return true
}
What it should not do is start foreground UI before the scene-backed window exists.
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
sharedServices.install()
window?.rootViewController = makeRootViewController() // Too early after UIScene adoption.
return true
}
The initial interface needs a real scene window. Starting it earlier can set root UI on a stale or non-scene window.
Mirror the old window only for compatibility
Old UIKit code often reads a window from the app delegate.
let screenWindow = UIApplication.shared.delegate?.window
Deleting every old call site is a larger refactor. Stage one does not need that.
Mirror the scene-backed window into the old property instead.
let sceneWindow = UIWindow(windowScene: connectedScene)
sceneDelegate.window = sceneWindow
UIApplication.shared.delegate?.window = sceneWindow
That keeps old code working while making the scene delegate the real owner.
Route cold-launch inputs
The easiest behavior to break is cold launch routing.
If the app starts from a custom URL, universal link, or shortcut item, the input can arrive through UIScene.ConnectionOptions.
struct LaunchRouter {
func route(_ options: UIScene.ConnectionOptions) {
options.urlContexts.forEach { context in
openURL(context.url)
}
options.userActivities.forEach { activity in
continueActivity(activity)
}
if let item = options.shortcutItem {
performShortcut(item)
}
}
}
This is intentionally generic. The app can keep its existing handlers behind openURL, continueActivity, and performShortcut.
Without this step, a deep link may work when the app is already open, but fail when the app is launched cold.
Route runtime inputs too
The same inputs can arrive after the scene already exists.
func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
contexts.forEach { context in
launchRouter.openURL(context.url)
}
}
Universal links use NSUserActivity.
func scene(_ scene: UIScene, continue activity: NSUserActivity) {
launchRouter.continueActivity(activity)
}
Shortcut items have a scene callback as well.
func windowScene(
_ windowScene: UIWindowScene,
performActionFor item: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void
) {
launchRouter.performShortcut(item)
completionHandler(true)
}
Cold and runtime paths should end up in the same routing layer. Otherwise behavior depends on whether the process was already alive.
Bridge lifecycle behavior during stage one
If the old app delegate owns lifecycle side effects, keep those effects reachable during the first migration step.
private let lifecycleObserver = AppLifecycleObserver()
func sceneDidBecomeActive(_ scene: UIScene) {
lifecycleObserver.didBecomeActive()
}
func sceneDidEnterBackground(_ scene: UIScene) {
lifecycleObserver.didEnterBackground()
}
This is not the final design. It is a controlled compatibility layer that keeps analytics, session state, and persistence behavior stable while window ownership moves.
Generated projects need source-level changes
If the project uses Tuist or another generator, edit the generator source, not the generated Info.plist.
let sceneManifest: [String: Any] = [
"UIApplicationSupportsMultipleScenes": false,
"UISceneConfigurations": [
"UIWindowSceneSessionRoleApplication": [
[
"UISceneConfigurationName": "Default",
"UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).ExampleSceneDelegate"
]
]
]
]
Editing generated files usually works for one local build and disappears on the next generation.
A safe migration shape
This is simplified pseudocode, not production code.
import UIKit
final class ExampleSceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let rootViewControllerFactory = RootViewControllerFactory()
private let launchRouter = LaunchRouter()
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let sceneWindow = UIWindow(windowScene: windowScene)
sceneWindow.rootViewController = rootViewControllerFactory.makeRootViewController()
sceneWindow.makeKeyAndVisible()
window = sceneWindow
UIApplication.shared.delegate?.window = sceneWindow
launchRouter.route(connectionOptions)
}
}
The exact variable names are invented. The pattern is the point.
Takeaway
UIScene adoption is safest when it is treated as a window ownership migration.
Move the real window to SceneDelegate. Keep process-level setup in AppDelegate. Mirror the window only for compatibility. Route launch inputs through the same behavior as before.
That gives you a small first step that changes the architecture boundary without changing user-visible launch behavior.