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.