From cbc36f1290e74648a2c468643b00331f82951fee Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 9 Jun 2026 14:52:56 +1200 Subject: [PATCH 1/8] Format files before UIScene refactor --- .../System/WordPressAppDelegate+openURL.swift | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift index 8290d07fab7e..895aaa9b2f94 100644 --- a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift +++ b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift @@ -4,7 +4,11 @@ import WordPressData import WordPressShared @objc extension WordPressAppDelegate { - public func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + public func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { let redactedURL = LoggingURLRedactor.redactedURL(url) DDLogInfo("Application launched with URL: \(redactedURL)") @@ -19,10 +23,13 @@ import WordPressShared } /// WordPress only. Handle deeplink from JP that requests data export. - let wordPressExportRouter = MigrationDeepLinkRouter(urlForScheme: URL(string: AppScheme.wordpressMigrationV1.rawValue), - routes: [WordPressExportRoute()]) + let wordPressExportRouter = MigrationDeepLinkRouter( + urlForScheme: URL(string: AppScheme.wordpressMigrationV1.rawValue), + routes: [WordPressExportRoute()] + ) if AppConfiguration.isWordPress, - wordPressExportRouter.canHandle(url: url) { + wordPressExportRouter.canHandle(url: url) + { wordPressExportRouter.handle(url: url) return true } @@ -59,7 +66,8 @@ import WordPressShared private func handleViewPost(url: URL) -> Bool { guard let params = url.queryItems, let blogId = params.intValue(of: "blogId"), - let postId = params.intValue(of: "postId") else { + let postId = params.intValue(of: "postId") + else { return false } RootViewCoordinator.sharedPresenter.showReader(path: .post(postID: postId, siteID: blogId)) @@ -71,7 +79,8 @@ import WordPressShared guard let params = url.queryItems, let siteId = params.intValue(of: "siteId"), - let blog = try? Blog.lookup(withID: siteId, in: ContextManager.shared.mainContext) else { + let blog = try? Blog.lookup(withID: siteId, in: ContextManager.shared.mainContext) + else { return false } @@ -110,7 +119,8 @@ import WordPressShared private func handleDebugging(url: URL) -> Bool { guard let params = url.queryItems, let debugType = params.value(of: "type"), - let debugKey = params.value(of: "key") else { + let debugKey = params.value(of: "key") + else { return false } @@ -131,8 +141,9 @@ import WordPressShared /// This is mostly a return of the old functionality: https://github.com/wordpress-mobile/WordPress-iOS/blob/d89b7ec712be1f2e11fb1228089771a25f5587c5/WordPress/Classes/ViewRelated/System/WPTabBarController.m#L388``` private func handleNewPost(url: URL) -> Bool { guard let params = url.queryItems, - let contentRaw = params.value(of: NewPostKey.content) else { - return false + let contentRaw = params.value(of: NewPostKey.content) + else { + return false } let title = params.value(of: NewPostKey.title) @@ -156,7 +167,10 @@ import WordPressShared RootViewCoordinator.sharedPresenter.rootViewController.present(postVC, animated: true, completion: nil) - WPAppAnalytics.track(.editorCreatedPost, withProperties: [WPAppAnalyticsKeyTapSource: "url_scheme", WPAppAnalyticsKeyPostType: "post"]) + WPAppAnalytics.track( + .editorCreatedPost, + withProperties: [WPAppAnalyticsKeyTapSource: "url_scheme", WPAppAnalyticsKeyPostType: "post"] + ) return true } @@ -169,8 +183,9 @@ import WordPressShared /// text. May support other formats, such as HTML or Markdown in the future. private func handleNewPage(url: URL) -> Bool { guard let params = url.queryItems, - let contentRaw = params.value(of: NewPostKey.content) else { - return false + let contentRaw = params.value(of: NewPostKey.content) + else { + return false } let title = params.value(of: NewPostKey.title) @@ -183,7 +198,12 @@ import WordPressShared // Should more formats be accepted be accepted in the future, this line would have to be expanded to accomodate it. let contentEscaped = contentRaw.escapeHtmlNamedEntities() - RootViewCoordinator.sharedPresenter.showPageEditor(blog: blog, title: title, content: contentEscaped, source: "url_scheme") + RootViewCoordinator.sharedPresenter.showPageEditor( + blog: blog, + title: title, + content: contentEscaped, + source: "url_scheme" + ) return true } @@ -198,7 +218,7 @@ import WordPressShared private extension Array where Element == URLQueryItem { func value(of key: String) -> String? { - return self.first(where: { $0.name == key })?.value + self.first(where: { $0.name == key })?.value } func intValue(of key: String) -> Int? { @@ -213,8 +233,9 @@ private extension URL { var queryItems: [URLQueryItem]? { guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), let queryItems = components.queryItems, - !queryItems.isEmpty else { - return nil + !queryItems.isEmpty + else { + return nil } return queryItems } From 3392e800f02862d6f277291a7564654939ba60a0 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 9 Jun 2026 14:54:42 +1200 Subject: [PATCH 2/8] Extract WordPressAppDelegate lifecycle bodies into reusable handlers --- .../System/WordPressAppDelegate+openURL.swift | 6 ++++ .../Classes/System/WordPressAppDelegate.swift | 32 ++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift index 895aaa9b2f94..76894ab4851a 100644 --- a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift +++ b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift @@ -9,7 +9,13 @@ import WordPressShared open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { + handle(url: url) + } + /// Routes an inbound URL (custom scheme deep links, magic-login, migration, OAuth). + /// Reused by both the legacy app-lifecycle path and `WordPressSceneDelegate`. + @discardableResult + func handle(url: URL) -> Bool { let redactedURL = LoggingURLRedactor.redactedURL(url) DDLogInfo("Application launched with URL: \(redactedURL)") diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 285d6949daa9..93a78bf476c7 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -185,6 +185,24 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { } public func applicationDidEnterBackground(_ application: UIApplication) { + handleDidEnterBackground() + } + + public func applicationWillEnterForeground(_ application: UIApplication) { + handleWillEnterForeground() + } + + public func applicationWillResignActive(_ application: UIApplication) { + handleWillResignActive() + } + + public func applicationDidBecomeActive(_ application: UIApplication) { + handleDidBecomeActive() + } + + // MARK: - Activation handlers (shared by app- and scene-lifecycle) + + func handleDidEnterBackground() { Loggers.app.info("\(self) \(#function)") analytics?.trackApplicationDidEnterBackground(screenName: currentlySelectedScreen) @@ -212,7 +230,7 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { } } - public func applicationWillEnterForeground(_ application: UIApplication) { + func handleWillEnterForeground() { Loggers.app.info("\(self) \(#function)") updateFeatureFlags() @@ -224,11 +242,11 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { } } - public func applicationWillResignActive(_ application: UIApplication) { + func handleWillResignActive() { Loggers.app.info("\(self) \(#function)") } - public func applicationDidBecomeActive(_ application: UIApplication) { + func handleDidBecomeActive() { Loggers.app.info("\(self) \(#function)") analytics?.trackApplicationDidBecomeActive() @@ -268,14 +286,18 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void ) -> Bool { + handle(userActivity: userActivity) + return true + } + + /// Routes an inbound `NSUserActivity` (universal links via web browsing, Spotlight, Handoff). + func handle(userActivity: NSUserActivity) { if userActivity.activityType == NSUserActivityTypeBrowsingWeb { handleWebActivity(userActivity) } else { // Spotlight search SearchManager.shared.handle(activity: userActivity) } - - return true } // Note that this method only appears to be called for iPhone devices, not iPad. From 9d7a404f66f0b754e0232dba65c1ef4a0357b8c8 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 12 Jun 2026 11:17:34 +1200 Subject: [PATCH 3/8] Adopt the UIScene life cycle The app opts into the scene life cycle via application(_:configurationForConnecting:options:), which names WordPressSceneDelegate as the scene delegate in one place for all three app targets instead of three copies of a UIApplicationSceneManifest; the scene delegate creates the scene-attached window and forwards every scene callback into the AppDelegate's shared handlers. Window creation moves from willFinishLaunching to the scene-connect phase, and launch-time URL, user-activity, and shortcut payloads arrive through UIScene.ConnectionOptions, routed through the same methods that handle them at runtime, so the launchOptions plumbing is dead and removed. UITestConfigurator splits into a process part that stays in willFinishLaunching (UI-test flags must be parsed before the launch sequence reads them) and a window part applied when the window is created. The unit test host's TestingAppDelegate doesn't implement the scene opt-in, so tests keep the legacy life cycle. --- .../System/UITesting/UITestConfigurator.swift | 19 ++-- .../System/WordPressAppDelegate+openURL.swift | 10 +- .../Classes/System/WordPressAppDelegate.swift | 100 ++++++++---------- .../System/WordPressSceneDelegate.swift | 79 ++++++++++++++ 4 files changed, 138 insertions(+), 70 deletions(-) create mode 100644 WordPress/Classes/System/WordPressSceneDelegate.swift diff --git a/WordPress/Classes/System/UITesting/UITestConfigurator.swift b/WordPress/Classes/System/UITesting/UITestConfigurator.swift index 6a73c02697a9..7803b365c7b0 100644 --- a/WordPress/Classes/System/UITesting/UITestConfigurator.swift +++ b/WordPress/Classes/System/UITesting/UITestConfigurator.swift @@ -7,7 +7,10 @@ struct UITestConfigurator { CommandLine.arguments.contains("-ui-testing") } - static func prepareApplicationForUITests(in app: UIApplication, window: UIWindow) { + /// Applies the process-scoped part of the UI-test setup: flag parsing and, when + /// requested, wiping persistent state. Must run at the very start of the launch + /// sequence, before anything reads the flags or touches Core Data and user defaults. + static func prepareApplicationForUITests() { let arguments = CommandLine.arguments if arguments.contains("-ui-testing") { flags.insert(.disableLogging) @@ -23,7 +26,7 @@ struct UITestConfigurator { } if arguments.contains("-ui-test-disable-animations") { flags.insert(.disableAnimations) - disableAnimations(in: app, window: window) + UIView.setAnimationsEnabled(false) } if arguments.contains("-ui-test-use-mock-data") { flags.insert(.useMockData) @@ -37,11 +40,13 @@ struct UITestConfigurator { } } - /// This method will disable animations and speed-up keyboad input if command-line arguments includes "NoAnimations" - /// It was designed to be used in UI test suites. To enable it just pass a launch argument into XCUIApplicaton. - private static func disableAnimations(in app: UIApplication, window: UIWindow) { - UIView.setAnimationsEnabled(false) - window.layer.speed = MAXFLOAT + /// Applies the window-scoped part of the UI-test setup. Runs whenever the main + /// window is created, which (under the scene life cycle) happens at scene connect, + /// after `prepareApplicationForUITests()` already parsed the flags. + static func prepareWindowForUITests(_ window: UIWindow) { + if isEnabled(.disableAnimations) { + window.layer.speed = MAXFLOAT + } } private static func resetEverything() { diff --git a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift index 76894ab4851a..d5e7f389c5dc 100644 --- a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift +++ b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift @@ -4,16 +4,8 @@ import WordPressData import WordPressShared @objc extension WordPressAppDelegate { - public func application( - _ app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:] - ) -> Bool { - handle(url: url) - } - /// Routes an inbound URL (custom scheme deep links, magic-login, migration, OAuth). - /// Reused by both the legacy app-lifecycle path and `WordPressSceneDelegate`. + /// Called by `WordPressSceneDelegate` for inbound URLs. @discardableResult func handle(url: URL) -> Bool { let redactedURL = LoggingURLRedactor.redactedURL(url) diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 93a78bf476c7..d84b24dc49d1 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -95,13 +95,10 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { ]) } - let window = UIWindow(frame: UIScreen.main.bounds) - self.window = window - WordPressAPIInternal.setupLogger(appId: Bundle.main.bundleIdentifier!) DesignSystem.FontManager.registerCustomFonts() AssertionLoggerDependencyContainer.logger = AssertionLogger() - UITestConfigurator.prepareApplicationForUITests(in: application, window: window) + UITestConfigurator.prepareApplicationForUITests() // The following extensive logging configuration detects if extensive logging is enabled internally. wpkURLSessionNotifyingDelegate = PulseNetworkLogger() @@ -117,27 +114,63 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { // Configure WPCom API overrides configureWordPressComApi() - configureWordPressAuthenticator() - ReachabilityUtils.configure() configureSelfHostedChallengeHandler() updateFeatureFlags() updateRemoteConfig() - window.makeKeyAndVisible() - // Restore a disassociated account prior to fixing tokens. AccountService(coreDataStack: ContextManager.shared).restoreDisassociatedAccountIfNecessary() - customizeAppearance() configureAnalytics() - runStartupSequence(with: launchOptions ?? [:]) + runStartupSequence() return true } + /// Opts the app into the UIScene life cycle and names the scene delegate. Doing + /// this in code (instead of a UIApplicationSceneManifest in each Info.plist) + /// keeps the configuration in one place for all three app targets. The unit test + /// host runs TestingAppDelegate, which doesn't implement this method, so tests + /// keep the legacy life cycle. + public func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + configuration.delegateClass = WordPressSceneDelegate.self + return configuration + } + + /// Builds and shows the app's UI in the given window scene. Runs when the main + /// scene connects, which (under the scene life cycle) is after `didFinishLaunching`. + func showInitialUI(in windowScene: UIWindowScene) { + let window = UIWindow(windowScene: windowScene) + self.window = window + + UITestConfigurator.prepareWindowForUITests(window) + + // Must run here (not in willFinishLaunching): it accesses `windowManager`, + // which force-unwraps `window`. startObservingAppleIDCredentialRevoked also + // requires the authenticator to be initialized first. A background-only + // launch therefore runs without the revocation observer; the + // `checkAppleIDCredentialState` call in `handleDidBecomeActive` covers the + // gap at the next foreground. + configureWordPressAuthenticator() + startObservingAppleIDCredentialRevoked() + customizeAppearance() + + window.makeKeyAndVisible() + + windowManager.showUI() + restoreAppState() + + DebugMenuViewController.configure(in: window) + } + public func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -155,7 +188,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { // (e.g. the share extension's background URL session) post notices that it // delivers as local notifications, with no UI visible. setupNoticePresenter() - DebugMenuViewController.configure(in: window) AppTips.initialize() // This was necessary to properly load fonts for the Stories editor. I believe external libraries may require this call to access fonts. @@ -165,8 +197,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) }) - startObservingAppleIDCredentialRevoked() - NotificationCenter.default.post(name: .applicationLaunchCompleted, object: nil) DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { WKWebView.warmup() @@ -184,22 +214,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { Loggers.app.info("\(self) \(#function)") } - public func applicationDidEnterBackground(_ application: UIApplication) { - handleDidEnterBackground() - } - - public func applicationWillEnterForeground(_ application: UIApplication) { - handleWillEnterForeground() - } - - public func applicationWillResignActive(_ application: UIApplication) { - handleWillResignActive() - } - - public func applicationDidBecomeActive(_ application: UIApplication) { - handleDidBecomeActive() - } - // MARK: - Activation handlers (shared by app- and scene-lifecycle) func handleDidEnterBackground() { @@ -257,15 +271,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { GutenbergSettings().performGutenbergPhase2MigrationIfNeeded() } - public func application( - _ application: UIApplication, - performActionFor shortcutItem: UIApplicationShortcutItem, - completionHandler: @escaping (Bool) -> Void - ) { - let handler = WP3DTouchShortcutHandler() - completionHandler(handler.handleShortcutItem(shortcutItem)) - } - public func application( _ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, @@ -281,15 +286,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { } } - public func application( - _ application: UIApplication, - continue userActivity: NSUserActivity, - restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void - ) -> Bool { - handle(userActivity: userActivity) - return true - } - /// Routes an inbound `NSUserActivity` (universal links via web browsing, Spotlight, Handoff). func handle(userActivity: NSUserActivity) { if userActivity.activityType == NSUserActivityTypeBrowsingWeb { @@ -317,7 +313,7 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Setup - func runStartupSequence(with launchOptions: [UIApplication.LaunchOptionsKey: Any] = [:]) { + func runStartupSequence() { // Local notifications addNotificationObservers() @@ -330,7 +326,7 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { TracksLogging.delegate = libraryLogger WPKitSetLoggingDelegate(libraryLogger) WPSetLoggingDelegate(libraryLogger) - printDebugLaunchInfoWithLaunchOptions(launchOptions) + printDebugLaunchInfo() toggleExtraDebuggingIfNeeded() } @@ -370,9 +366,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { shortcutCreator.createShortcutsIf3DTouchAvailable(AccountHelper.isLoggedIn) AccountService.loadDefaultAccountCookies() - - windowManager.showUI() - restoreAppState() } private func mergeDuplicateAccountsIfNeeded() { @@ -631,7 +624,7 @@ extension WordPressAppDelegate { // MARK: - Debugging extension WordPressAppDelegate { - func printDebugLaunchInfoWithLaunchOptions(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) { + func printDebugLaunchInfo() { let unknown = "Unknown" let device = UIDevice.current @@ -666,7 +659,6 @@ extension WordPressAppDelegate { Loggers.app.info("Language: \(currentLanguage)") Loggers.app.info("UDID: \(udid)") Loggers.app.info("APN token: \(PushNotificationsManager.shared.deviceToken ?? "None")") - Loggers.app.info("Launch options: \(String(describing: launchOptions ?? [:]))") AccountHelper.logBlogsAndAccounts(context: mainContext) Loggers.app.info("===========================================================================") diff --git a/WordPress/Classes/System/WordPressSceneDelegate.swift b/WordPress/Classes/System/WordPressSceneDelegate.swift new file mode 100644 index 000000000000..e4e63f858011 --- /dev/null +++ b/WordPress/Classes/System/WordPressSceneDelegate.swift @@ -0,0 +1,79 @@ +import UIKit + +/// The app's single-window scene delegate. +/// +/// `WordPressAppDelegate` still owns `window` and `windowManager`; this delegate +/// creates the scene-attached window and forwards every scene callback into the +/// AppDelegate's shared handlers so the routing logic lives in one place. +@objc(WordPressSceneDelegate) +final class WordPressSceneDelegate: UIResponder, UIWindowSceneDelegate { + + private var appDelegate: WordPressAppDelegate? { + WordPressAppDelegate.shared + } + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { + return + } + + appDelegate?.showInitialUI(in: windowScene) + + // Drain any launch-time entry points through the same methods that handle + // them while the app is running. + self.scene(scene, openURLContexts: connectionOptions.urlContexts) + for activity in connectionOptions.userActivities { + self.scene(scene, continue: activity) + } + if let shortcutItem = connectionOptions.shortcutItem { + _ = handle(shortcutItem: shortcutItem) + } + } + + // MARK: - Entry points + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for context in URLContexts { + appDelegate?.handle(url: context.url) + } + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + appDelegate?.handle(userActivity: userActivity) + } + + func windowScene( + _ windowScene: UIWindowScene, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void + ) { + completionHandler(handle(shortcutItem: shortcutItem)) + } + + /// Handles a Home Screen quick action. Returns whether the item was handled. + private func handle(shortcutItem: UIApplicationShortcutItem) -> Bool { + WP3DTouchShortcutHandler().handleShortcutItem(shortcutItem) + } + + // MARK: - Activation + + func sceneDidBecomeActive(_ scene: UIScene) { + appDelegate?.handleDidBecomeActive() + } + + func sceneWillResignActive(_ scene: UIScene) { + appDelegate?.handleWillResignActive() + } + + func sceneDidEnterBackground(_ scene: UIScene) { + appDelegate?.handleDidEnterBackground() + } + + func sceneWillEnterForeground(_ scene: UIScene) { + appDelegate?.handleWillEnterForeground() + } +} From 3702d930d413dcb1748ab9fc81452d398afd54e7 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 9 Jun 2026 19:01:48 +1200 Subject: [PATCH 4/8] Run appearance and shortcut setup in the window phase AppAppearance.overrideAppearance() and the Home Screen quick action creation both reach the app's window indirectly (the override style target and the 3D Touch trait check). Under the scene life cycle the window does not exist until the scene connects, so these no-opped when left in willFinishLaunching and runStartupSequence. Move them into showInitialUI alongside the other window-dependent launch steps. --- WordPress/Classes/System/WordPressAppDelegate.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index d84b24dc49d1..618422ee96a4 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -103,7 +103,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { // The following extensive logging configuration detects if extensive logging is enabled internally. wpkURLSessionNotifyingDelegate = PulseNetworkLogger() - AppAppearance.overrideAppearance() MemoryCache.shared.register() MediaImageService.migrateCacheIfNeeded() PostCoordinator.shared.delegate = self @@ -162,9 +161,16 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { configureWordPressAuthenticator() startObservingAppleIDCredentialRevoked() customizeAppearance() + // Reapplies the user's saved light/dark override; reads the app's window, so it + // can only take effect once the window exists. + AppAppearance.overrideAppearance() window.makeKeyAndVisible() + // The 3D Touch / Home Screen quick actions probe the trait collection of + // `UIApplication.shared.mainWindow`, which only exists once the window above is key. + shortcutCreator.createShortcutsIf3DTouchAvailable(AccountHelper.isLoggedIn) + windowManager.showUI() restoreAppState() @@ -363,8 +369,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { setupWordPressExtensions() - shortcutCreator.createShortcutsIf3DTouchAvailable(AccountHelper.isLoggedIn) - AccountService.loadDefaultAccountCookies() } From dabfecc1752c079cc83d889df91c93a23b83b283 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 10 Jun 2026 19:11:49 +1200 Subject: [PATCH 5/8] Rebuild the UI once per process and reattach the window on scene reconnect The system can disconnect the app's scene in the background and reconnect it later in the same process. showInitialUI previously re-ran the full UI bootstrap on every connect: it created a fresh window while the window manager stayed bound to the old one (leaving the new key window empty), re-registered the Apple ID revocation observer, and replaced the authenticator and notice presenter. The bootstrap now runs exactly once; later connects reattach the existing window to the new scene. The window manager's missing-window fatalError is also downgraded to an assertion with a recovery path, since background launches make it reachable for any unguarded UI path. --- .../Classes/System/WordPressAppDelegate.swift | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 618422ee96a4..3b6468c5a592 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -36,9 +36,15 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { @objc lazy var windowManager: WindowManager = { - guard let window else { - fatalError("The App cannot run without a window.") - } + if window == nil { + // Reachable only when a code path builds UI before any scene has connected + // (e.g. during a background launch); such paths should be guarded instead. + // Recover by creating the window now: `WordPressSceneDelegate` attaches it to + // the scene once one connects. + assertionFailure("windowManager accessed before any scene connected") + window = UIWindow(frame: UIScreen.main.bounds) + } + let window = window! return switch BuildSettings.current.brand { case .wordpress: WindowManager(window: window) case .jetpack: JetpackWindowManager(window: window) @@ -144,16 +150,38 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { return configuration } + /// Whether `showInitialUI(in:)` has already built the UI. The scene can disconnect + /// and reconnect while the process stays alive, so the build must run exactly once. + private(set) var hasConfiguredInitialUI = false + /// Builds and shows the app's UI in the given window scene. Runs when the main - /// scene connects, which (under the scene life cycle) is after `didFinishLaunching`. + /// scene connects, which (under the scene life cycle) is after `didFinishLaunching` + /// and can happen multiple times per process: the system may disconnect the scene + /// in the background and reconnect it later. After the first connect, the existing + /// window (with all its UI state) is simply reattached to the new scene. func showInitialUI(in windowScene: UIWindowScene) { - let window = UIWindow(windowScene: windowScene) - self.window = window + if hasConfiguredInitialUI { + window?.windowScene = windowScene + window?.makeKeyAndVisible() + return + } + hasConfiguredInitialUI = true + + let window: UIWindow + if let existingWindow = self.window { + // Created by the `windowManager` emergency fallback before any scene + // connected; adopt it instead of orphaning the UI already built in it. + existingWindow.windowScene = windowScene + window = existingWindow + } else { + window = UIWindow(windowScene: windowScene) + self.window = window + } UITestConfigurator.prepareWindowForUITests(window) // Must run here (not in willFinishLaunching): it accesses `windowManager`, - // which force-unwraps `window`. startObservingAppleIDCredentialRevoked also + // which requires `window`. startObservingAppleIDCredentialRevoked also // requires the authenticator to be initialized first. A background-only // launch therefore runs without the revocation observer; the // `checkAppleIDCredentialState` call in `handleDidBecomeActive` covers the From 68b1daa8127412edbb7e69ad40bd72a287e0b60f Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 10 Jun 2026 19:10:27 +1200 Subject: [PATCH 6/8] Guard background UI paths against running before the UI is built Under the scene life cycle, background launches (silent push, background URL session wake) run with no UI and no authenticator: both are set up at the first scene connect now. The support-notification handler built UI through the root view coordinator, and the account-changed handler showed sign-in UI and touched WordPressAuthenticator.shared, which fatalErrors before configureWordPressAuthenticator runs. These paths now skip the UI and observer work when the UI has not been built. The guards key on hasConfiguredInitialUI rather than window presence, because the windowManager emergency fallback can create a window while no scene has ever connected. --- .../Classes/System/WordPressAppDelegate.swift | 18 +++++++++++++++--- .../PushNotificationsManager.swift | 6 +++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 3b6468c5a592..254fdde67d09 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -770,14 +770,26 @@ extension WordPressAppDelegate { // If the notification object is not nil, then it's a login if notification.object != nil { setupWordPressExtensions() - startObservingAppleIDCredentialRevoked() + // Pre-UI logins (the observer requires the authenticator, which is + // configured at first scene connect) are covered by `showInitialUI`, + // which registers the observer itself. + if hasConfiguredInitialUI { + startObservingAppleIDCredentialRevoked() + } AccountService.loadDefaultAccountCookies() } else { trackLogoutIfNeeded() removeShareExtensionConfiguration() removeNotificationExtensionConfiguration() - windowManager.showFullscreenSignIn() - stopObservingAppleIDCredentialRevoked() + // Skip when the UI was never built (e.g. the account is removed during a + // background launch): there is no UI to show sign-in in, `showInitialUI` + // shows it anyway once a scene connects and finds no account, and the + // Apple ID observer was never registered (stopping it would hit + // `WordPressAuthenticator.shared` before the authenticator exists). + if hasConfiguredInitialUI { + windowManager.showFullscreenSignIn() + stopObservingAppleIDCredentialRevoked() + } clearReaderStuff() } diff --git a/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift b/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift index 2edd785ce75e..4cd79c3bb619 100644 --- a/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift +++ b/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift @@ -303,7 +303,11 @@ extension PushNotificationsManager { WPAnalytics.track(.supportReceivedResponseFromSupport) - if applicationState == .background { + // Pre-build the Me screen so it's ready when the user opens the app. Skip it + // when the UI was never built (a background launch). Keyed on the UI flag, not + // window presence: the windowManager emergency fallback can create a window + // while no scene has ever connected. + if applicationState == .background, WordPressAppDelegate.shared?.hasConfiguredInitialUI == true { RootViewCoordinator.sharedPresenter.showMeScreen() } From 8814d283309e5c12e944e4082309ebec786e5a2a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 12 Jun 2026 14:22:03 +1200 Subject: [PATCH 7/8] Skip only the migration check right after the initial scene connect sceneWillEnterForeground also fires right after the initial scene connect during a cold launch, unlike the legacy applicationWillEnterForeground, which re-ran the Jetpack migration eligibility check and emitted its analytics event twice per launch; the window phase already runs that check via windowManager.showUI(). The feature flag and remote config refresh is not skipped though: for a background-launched process, the launch-time refresh may be hours stale by the time the user first opens the app, and an extra refresh right after a cold launch is harmless. --- .../Classes/System/WordPressAppDelegate.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 254fdde67d09..22c2ab37bf20 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -154,6 +154,11 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { /// and reconnect while the process stays alive, so the build must run exactly once. private(set) var hasConfiguredInitialUI = false + /// Set during the first scene connect, because `sceneWillEnterForeground` also + /// fires right after it. The legacy `applicationWillEnterForeground` did not fire + /// during a cold launch, and `handleWillEnterForeground` keeps those semantics. + private var isInitialSceneConnect = false + /// Builds and shows the app's UI in the given window scene. Runs when the main /// scene connects, which (under the scene life cycle) is after `didFinishLaunching` /// and can happen multiple times per process: the system may disconnect the scene @@ -166,6 +171,7 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { return } hasConfiguredInitialUI = true + isInitialSceneConnect = true let window: UIWindow if let existingWindow = self.window { @@ -281,9 +287,21 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { func handleWillEnterForeground() { Loggers.app.info("\(self) \(#function)") + // Refresh on every foreground, including the one right after the initial + // scene connect: a background-launched process may sit for hours between + // the launch-time refresh and the user opening the app. updateFeatureFlags() updateRemoteConfig() + // Skip the migration check on the call that follows the initial scene + // connect: the window phase already ran it via `windowManager.showUI()`, + // and running it twice emits its analytics event twice per launch. Scene + // reconnects are a real return from the background and fall through. + if isInitialSceneConnect { + isInitialSceneConnect = false + return + } + // JetpackWindowManager is only available in the Jetpack target. if let windowManager = windowManager as? JetpackWindowManager { windowManager.startMigrationFlowIfNeeded() From 72d621e8979a01deefc4756b1d8c689e174ca13e Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 12 Jun 2026 14:22:54 +1200 Subject: [PATCH 8/8] Route notification taps to the note during a cold launch A notification-tap cold launch reaches handleNotification while the application state is still .background under the scene life cycle: the scene has connected, but the foreground transition hasn't completed. The inactive handler rejected that state, so the background handler swallowed the tap with a silent sync and the app opened without navigating to the tapped notification. The inactive handler now accepts taps in the background state, matching the existing workaround in the authentication notification handler. --- .../Utility/Notifications/PushNotificationsManager.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift b/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift index 4cd79c3bb619..93718084b0e8 100644 --- a/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift +++ b/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift @@ -395,7 +395,12 @@ extension PushNotificationsManager { userInteraction: Bool, completionHandler: ((UIBackgroundFetchResult) -> Void)? ) -> Bool { - guard applicationState == .inactive else { + // A notification-tap cold launch reaches this point while the application + // state is still .background under the scene life cycle (the scene has + // connected but the foreground transition hasn't completed). A tap is + // explicit user navigation regardless of state, so accept it too; same + // workaround as the authentication handler above. + guard applicationState == .inactive || (applicationState == .background && userInteraction) else { return false }