diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index d784b2241f..6a0d6ffaa3 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -146,7 +146,7 @@ interface ApplicationEvents { export class ApplicationCommon { /** - * @deprecated Use the 'ready' event for application initialization and Application.setWindowContentResolver() to provide window UI. 'launch' continues to fire before the first window's content is created, and its 'root' property is still honored, for backwards compatibility. It will not fire for additional windows or for background launches. + * @deprecated Use the 'ready' event for application initialization and Application.setWindowContentResolver() to provide window UI. 'launch' continues to fire before the first window's content is created, and its 'root' property is still honored, for backwards compatibility. It never fires for additional windows. In a scene-based app it fires with the first window's content, so a background launch that connects no scene does not raise it. */ readonly launchEvent = 'launch'; /** diff --git a/packages/core/application/application-delegate.spec.ts b/packages/core/application/application-delegate.spec.ts new file mode 100644 index 0000000000..92004fc214 --- /dev/null +++ b/packages/core/application/application-delegate.spec.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Application } from './application.ios'; + +const APPLICATION_ROLE = 'UIWindowSceneSessionRoleApplication'; +const CARPLAY_ROLE = 'CPTemplateApplicationSceneSessionRoleApplication'; + +/** Mirrors the surface of UISceneConfiguration the default configuration builds. */ +class FakeSceneConfiguration { + sceneClass: any; + delegateClass: any; + + constructor( + readonly name: string, + readonly role: string, + ) {} + + static configurationWithNameSessionRole(name: string, role: string) { + return new FakeSceneConfiguration(name, role); + } +} + +function createSession(role: string) { + return { role, persistentIdentifier: `session-${role}` } as any; +} + +function createSessionSet(sessions: any[]) { + return { + allObjects: { + count: sessions.length, + objectAtIndex: (index: number) => sessions[index], + }, + } as any; +} + +/** A custom application delegate as an app would declare it, minus the native class registration. */ +function createDelegateClass(prototypeMembers: Record = {}) { + class CustomDelegate {} + + Object.assign(CustomDelegate.prototype, prototypeMembers); + (CustomDelegate as any).ObjCProtocols = [(global as any).UIApplicationDelegate]; + + return CustomDelegate as any; +} + +let previousDelegate: any; + +beforeEach(() => { + (global as any).UISceneConfiguration = FakeSceneConfiguration; + (global as any).UIWindowSceneSessionRoleApplication = APPLICATION_ROLE; + (global as any).UIWindowScene = class UIWindowScene {}; + previousDelegate = Application.ios.delegate; +}); + +afterEach(() => { + Application.ios.delegate = previousDelegate; + Application.ios.onSceneConfiguration = null; + delete (global as any).UISceneConfiguration; + delete (global as any).UIWindowSceneSessionRoleApplication; + delete (global as any).UIWindowScene; +}); + +describe('custom application delegate', () => { + it('installs both scene methods on a delegate that has neither', () => { + const CustomDelegate = createDelegateClass(); + + Application.ios.delegate = CustomDelegate; + + expect(typeof CustomDelegate.prototype.applicationConfigurationForConnectingSceneSessionOptions).toBe('function'); + expect(typeof CustomDelegate.prototype.applicationDidDiscardSceneSessions).toBe('function'); + }); + + it('keeps a delegate implementation and installs only the missing one', () => { + const ownConfiguration = vi.fn(); + const CustomDelegate = createDelegateClass({ applicationConfigurationForConnectingSceneSessionOptions: ownConfiguration }); + + Application.ios.delegate = CustomDelegate; + + expect(CustomDelegate.prototype.applicationConfigurationForConnectingSceneSessionOptions).toBe(ownConfiguration); + expect(typeof CustomDelegate.prototype.applicationDidDiscardSceneSessions).toBe('function'); + }); + + it('leaves a method inherited from a base class alone', () => { + const inherited = vi.fn(); + + class BaseDelegate {} + Object.assign(BaseDelegate.prototype, { applicationDidDiscardSceneSessions: inherited }); + + class CustomDelegate extends BaseDelegate {} + (CustomDelegate as any).ObjCProtocols = [(global as any).UIApplicationDelegate]; + + Application.ios.delegate = CustomDelegate as any; + + expect((CustomDelegate.prototype as any).applicationDidDiscardSceneSessions).toBe(inherited); + expect(Object.prototype.hasOwnProperty.call(CustomDelegate.prototype, 'applicationDidDiscardSceneSessions')).toBe(false); + }); + + it('installs a scene configuration that matches the default', () => { + const CustomDelegate = createDelegateClass(); + const session = createSession(APPLICATION_ROLE); + + Application.ios.delegate = CustomDelegate; + + const installed = CustomDelegate.prototype.applicationConfigurationForConnectingSceneSessionOptions(null, session, null); + const expected = Application.ios.defaultSceneConfiguration(null, session, null); + + expect(installed.name).toBe(expected.name); + expect(installed.role).toBe(expected.role); + expect(installed.sceneClass).toBe(expected.sceneClass); + expect(installed.delegateClass).toBe(expected.delegateClass); + }); + + it('installs a discard handler that retires the sessions windows', () => { + const CustomDelegate = createDelegateClass(); + const sessions = createSessionSet([createSession(APPLICATION_ROLE)]); + const discarded = vi.spyOn(Application.ios, '_onSceneSessionsDiscarded'); + + Application.ios.delegate = CustomDelegate; + CustomDelegate.prototype.applicationDidDiscardSceneSessions(null, sessions); + + expect(discarded).toHaveBeenCalledWith(sessions); + + discarded.mockRestore(); + }); +}); + +describe('defaultSceneConfiguration', () => { + it('returns a NativeScript managed configuration for the application role', () => { + const config = Application.ios.defaultSceneConfiguration(null, createSession(APPLICATION_ROLE), null) as any; + + expect(config.name).toBe('Default Configuration'); + expect(config.role).toBe(APPLICATION_ROLE); + expect(config.sceneClass).toBe((global as any).UIWindowScene); + expect(config.delegateClass).toBe((global as any).SceneDelegate); + }); + + it('returns an unmanaged configuration for any other role', () => { + const config = Application.ios.defaultSceneConfiguration(null, createSession(CARPLAY_ROLE), null) as any; + + expect(config.name).toBe('Unmanaged'); + expect(config.role).toBe(CARPLAY_ROLE); + expect(config.sceneClass).toBeUndefined(); + expect(config.delegateClass).toBeUndefined(); + }); + + it('honors a configuration returned by onSceneConfiguration', () => { + const session = createSession(APPLICATION_ROLE); + const userConfig = { name: 'User Configuration' } as any; + const handler = vi.fn(() => userConfig); + + Application.ios.onSceneConfiguration = handler; + + expect(Application.ios.defaultSceneConfiguration(null, session, null)).toBe(userConfig); + expect(handler).toHaveBeenCalledWith(null, session, null); + }); + + it('falls back to the default when onSceneConfiguration declines', () => { + Application.ios.onSceneConfiguration = () => null; + + const config = Application.ios.defaultSceneConfiguration(null, createSession(APPLICATION_ROLE), null) as any; + + expect(config.name).toBe('Default Configuration'); + }); +}); + +describe('delegate warnings', () => { + it('warns when the delegate class does not declare UIApplicationDelegate conformance', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + class CustomDelegate {} + Application.ios.delegate = CustomDelegate as any; + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('ObjCProtocols'); + + // The warning is one-shot, so a second offending class must stay quiet. + class AnotherDelegate {} + Application.ios.delegate = AnotherDelegate as any; + + expect(warn).toHaveBeenCalledTimes(1); + + warn.mockRestore(); + }); + + it('warns when the delegate is assigned after the app has started', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const wasStarted = Application.ios.started; + Application.ios.started = true; + + Application.ios.delegate = createDelegateClass(); + + Application.ios.started = wasStarted; + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('after the application started'); + + warn.mockRestore(); + }); +}); + +describe('delegate window accessor', () => { + it('stores what is assigned instead of discarding it', () => { + const CustomDelegate = createDelegateClass(); + + Application.ios.delegate = CustomDelegate; + + const instance = new CustomDelegate(); + const window = { tag: 'window' }; + instance.window = window; + + expect(instance.window).toBe(window); + }); + + it('leaves a delegate that declares its own window accessor alone', () => { + let assigned: any; + + class CustomDelegate { + get window() { + return assigned; + } + + set window(value: any) { + assigned = value; + } + } + (CustomDelegate as any).ObjCProtocols = [(global as any).UIApplicationDelegate]; + + const descriptorBefore = Object.getOwnPropertyDescriptor(CustomDelegate.prototype, 'window'); + + Application.ios.delegate = CustomDelegate as any; + + expect(Object.getOwnPropertyDescriptor(CustomDelegate.prototype, 'window')).toEqual(descriptorBefore); + }); +}); diff --git a/packages/core/application/application.d.ts b/packages/core/application/application.d.ts index a9144f6519..2c90bd45dd 100644 --- a/packages/core/application/application.d.ts +++ b/packages/core/application/application.d.ts @@ -200,6 +200,41 @@ export class iOSApplication extends ApplicationCommon { */ set delegate(value: UIApplicationDelegate | unknown); + /** + * NativeScript's default implementation of the `UIApplicationDelegate` + * `applicationConfigurationForConnectingSceneSessionOptions` method. + * + * It is installed automatically on the application delegate class unless that class + * already implements the method. A delegate that does implement it can handle the + * scenes it cares about and forward the rest here: + * + * ```ts + * applicationConfigurationForConnectingSceneSessionOptions(app, session, options) { + * if (session.role === myCustomRole) { + * return myConfig; + * } + * return Application.ios.defaultSceneConfiguration(app, session, options); + * } + * ``` + * + * `onSceneConfiguration` is consulted first. Scenes with the + * `UIWindowSceneSessionRoleApplication` role then get a configuration backed by + * NativeScript's SceneDelegate; every other role gets a bare configuration that + * NativeScript does not manage. + */ + defaultSceneConfiguration(application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions): UISceneConfiguration; + + /** + * NativeScript's default implementation of the `UIApplicationDelegate` + * `applicationDidDiscardSceneSessions` method, which retires the windows belonging + * to the discarded sessions. + * + * It is installed automatically on the application delegate class unless that class + * already implements the method, in which case forward to it from there so window + * bookkeeping stays correct. + */ + defaultDiscardSceneSessions(application: UIApplication, sceneSessions: NSSet): void; + /** * Adds a delegate handler for the specified delegate method name. This method does not replace an existing handler, * but rather adds the new handler to the existing chain of handlers. @@ -323,7 +358,14 @@ export class iOSApplication extends ApplicationCommon { onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; /** - * @deprecated Has no effect. Application initialization is signalled by the 'ready' event, which is never deferred. + * Delays the 'launch' event, and with it the creation of the first window's content, until the + * app first becomes active, instead of raising it while the app finishes launching. + * + * Applies to non-scene apps only. It has no effect in a scene-based app, where each window's + * content is resolved as its scene connects. + * + * @deprecated Use the 'ready' event for application initialization, and + * Application.setWindowContentResolver() to provide window UI. */ shouldDelayLaunchEvent: boolean; } diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index 64afa2df57..0b28d703e4 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -157,47 +157,87 @@ class Responder extends UIResponder implements UIApplicationDelegate { static ObjCProtocols = [UIApplicationDelegate]; } -if (supportsScenes()) { - /** - * This method is called when a new scene session is being created. - * Important: When this method is implemented, the app assumes scene-based lifecycle management. - * Detected by the Info.plist existence 'UIApplicationSceneManifest'. - * If this method is implemented when there is no manifest defined, - * the app will boot to a white screen. - * - * Since we configure the delegate dynamically here, UISceneConfigurations - * does NOT need to be present in Info.plist — only UIApplicationSceneManifest is required. - * - * NativeScript only handles UIWindowSceneSessionRoleApplication by default. - * Other scene types (CarPlay, external displays, etc.) are ignored unless - * the user provides an `onSceneConfiguration` callback. - */ - (Responder.prototype as UIApplicationDelegate).applicationConfigurationForConnectingSceneSessionOptions = function (application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions): UISceneConfiguration { - // Let the user intercept scene configuration for any/all scenes - const userHandler = Application.ios._onSceneConfiguration; - if (userHandler) { - const userConfig = userHandler(application, connectingSceneSession, options); - if (userConfig) { - return userConfig; - } +const delegateWindowKey = Symbol('nativescript.delegateWindow'); + +/** + * Installs NativeScript's default `UIApplicationDelegate` members on the class that will + * be handed to `UIApplicationMain`, so scenes keep working with a custom delegate. + * + * Each member is installed only when the class does not already provide one (its own or + * inherited), so a delegate that implements a method keeps it and can forward to + * `Application.ios.defaultSceneConfiguration` / `defaultDiscardSceneSessions` itself. + * + * @internal + */ +function installSceneDelegateDefaults(delegateClass: unknown): void { + const proto = (delegateClass as { prototype?: UIApplicationDelegate })?.prototype; + + if (!proto) { + return; + } + + // Implementing the scene methods makes the app assume scene-based lifecycle management, + // which boots to a white screen when Info.plist has no UIApplicationSceneManifest. + // Configuring the delegate here is also why UISceneConfigurations does not have to be + // declared in Info.plist — UIApplicationSceneManifest on its own is enough. + if (supportsScenes()) { + if (!proto.applicationConfigurationForConnectingSceneSessionOptions) { + proto.applicationConfigurationForConnectingSceneSessionOptions = function (application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions): UISceneConfiguration { + return Application.ios.defaultSceneConfiguration(application, connectingSceneSession, options); + }; } - // Only handle the standard window scene role — skip CarPlay, external displays, etc. - if (connectingSceneSession.role !== UIWindowSceneSessionRoleApplication) { - // Return a bare configuration so iOS doesn't crash, but NativeScript won't manage it - return UISceneConfiguration.configurationWithNameSessionRole('Unmanaged', connectingSceneSession.role); + if (!proto.applicationDidDiscardSceneSessions) { + proto.applicationDidDiscardSceneSessions = function (application: UIApplication, sceneSessions: NSSet): void { + Application.ios.defaultDiscardSceneSessions(application, sceneSessions); + }; } + } - const config = UISceneConfiguration.configurationWithNameSessionRole('Default Configuration', connectingSceneSession.role); - config.sceneClass = UIWindowScene as any; - config.delegateClass = SceneDelegate; - return config; - }; + if (!('window' in proto)) { + Object.defineProperty(proto, 'window', { + get(this: Record): UIWindow { + return this[delegateWindowKey] ?? Application.ios.window; + }, + // UIKit assigns `delegate.window` on non-scene apps and a delegate may assign it + // itself, so the value has to be kept: a discarding setter would leave the + // delegate reporting a window it never set. + set(this: Record, value: UIWindow) { + this[delegateWindowKey] = value; + }, + enumerable: true, + configurable: true, + }); + } +} - // scene session destruction handling - (Responder.prototype as UIApplicationDelegate).applicationDidDiscardSceneSessions = function (application: UIApplication, sceneSessions: NSSet): void { - Application.ios._onSceneSessionsDiscarded(sceneSessions); - }; +installSceneDelegateDefaults(Responder); + +let warnedAboutDelegateProtocols = false; +let warnedAboutDelegateAfterStart = false; + +function warnAboutDelegate(message: string): void { + Trace.write(message, Trace.categories.Error, Trace.messageType.warn); + console.warn(message); +} + +/** + * Reports the two custom-delegate mistakes NativeScript cannot correct on the app's behalf: + * a delegate class that never declared `UIApplicationDelegate` conformance, and a delegate + * assigned once `UIApplicationMain` has already been given a class. + */ +function warnAboutDelegateClass(delegateClass: unknown, alreadyStarted: boolean): void { + const protocols = (delegateClass as { ObjCProtocols?: unknown[] })?.ObjCProtocols; + + if (!warnedAboutDelegateProtocols && !(Array.isArray(protocols) && protocols.indexOf(UIApplicationDelegate) !== -1)) { + warnedAboutDelegateProtocols = true; + warnAboutDelegate('Application.ios.delegate was set to a class that does not list UIApplicationDelegate in its static ObjCProtocols. Add `static ObjCProtocols = [UIApplicationDelegate];` to the class body: the Objective-C class is built from ObjCProtocols and cached, so conformance cannot be declared from here and UIKit may never dispatch the delegate methods.'); + } + + if (alreadyStarted && !warnedAboutDelegateAfterStart) { + warnedAboutDelegateAfterStart = true; + warnAboutDelegate('Application.ios.delegate was set after the application started. UIApplicationMain has already been given a delegate class, so this assignment has no effect — set Application.ios.delegate before calling Application.run().'); + } } /** @@ -532,7 +572,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication private _delegate: UIApplicationDelegate; private _delegateHandlers = new Map>(); private _rootView: View; - /** Set when a background launch defers the primary window's content until the app first becomes active. */ + /** Set when `shouldDelayLaunchEvent` defers the primary window's content until the app first becomes active. */ private _pendingWindowContentResolve: (() => void) | null; private _sceneDelegate: UIWindowSceneDelegate; /** @@ -561,7 +601,14 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication displayedLink: CADisplayLink; /** - * @deprecated Has no effect. Application initialization is signalled by the 'ready' event, which is never deferred. + * Delays the 'launch' event, and with it the creation of the first window's content, until the + * app first becomes active, instead of raising it while the app finishes launching. + * + * Applies to non-scene apps only. It has no effect in a scene-based app, where each window's + * content is resolved as its scene connects. + * + * @deprecated Use the 'ready' event for application initialization, and + * Application.setWindowContentResolver() to provide window UI. */ shouldDelayLaunchEvent = false; @@ -885,17 +932,81 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication set delegate(value: UIApplicationDelegate | unknown) { if (this._delegate !== value) { this._delegate = value as UIApplicationDelegate; + + if (value) { + warnAboutDelegateClass(value, this.started); + installSceneDelegateDefaults(value); + } } } + /** + * NativeScript's default implementation of the `UIApplicationDelegate` + * `applicationConfigurationForConnectingSceneSessionOptions` method. + * + * It is installed automatically on the application delegate class unless that class + * already implements the method. A delegate that does implement it can handle the + * scenes it cares about and forward the rest here: + * + * ```ts + * applicationConfigurationForConnectingSceneSessionOptions(app, session, options) { + * if (session.role === myCustomRole) { + * return myConfig; + * } + * return Application.ios.defaultSceneConfiguration(app, session, options); + * } + * ``` + * + * `onSceneConfiguration` is consulted first. Scenes with the + * `UIWindowSceneSessionRoleApplication` role then get a configuration backed by + * NativeScript's SceneDelegate; every other role gets a bare configuration that + * NativeScript does not manage. + */ + defaultSceneConfiguration(application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions): UISceneConfiguration { + // Let the user intercept scene configuration for any/all scenes + const userHandler = this._onSceneConfiguration; + if (userHandler) { + const userConfig = userHandler(application, connectingSceneSession, options); + if (userConfig) { + return userConfig; + } + } + + // Only handle the standard window scene role — skip CarPlay, external displays, etc. + if (connectingSceneSession.role !== UIWindowSceneSessionRoleApplication) { + // Return a bare configuration so iOS doesn't crash, but NativeScript won't manage it + return UISceneConfiguration.configurationWithNameSessionRole('Unmanaged', connectingSceneSession.role); + } + + const config = UISceneConfiguration.configurationWithNameSessionRole('Default Configuration', connectingSceneSession.role); + config.sceneClass = UIWindowScene as any; + config.delegateClass = SceneDelegate; + return config; + } + + /** + * NativeScript's default implementation of the `UIApplicationDelegate` + * `applicationDidDiscardSceneSessions` method, which retires the `NativeWindow`s + * belonging to the discarded sessions. + * + * It is installed automatically on the application delegate class unless that class + * already implements the method, in which case forward to it from there so window + * bookkeeping stays correct. + */ + defaultDiscardSceneSessions(application: UIApplication, sceneSessions: NSSet): void { + this._onSceneSessionsDiscarded(sceneSessions); + } + addDelegateHandler(methodName: T, handler: (typeof UIApplicationDelegate.prototype)[T]): void { // safe-guard against invalid handlers if (typeof handler !== 'function') { return; } - // ensure we have a delegate - this.delegate ??= Responder as any; + // ensure we have a delegate; Responder already carries the defaults, so it is + // stored directly rather than through the setter, whose warnings only apply to + // a delegate class the app supplied. + this._delegate ??= Responder as any; const handlers = this._delegateHandlers.get(methodName) ?? []; @@ -1134,8 +1245,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication }, ); - if (UIApplication.sharedApplication.applicationState === UIApplicationState.Background) { - // A background launch has no UI to build yet, so content waits for the first activation. + if (this.shouldDelayLaunchEvent) { this._pendingWindowContentResolve = resolveContent; } else { resolveContent(); diff --git a/packages/core/application/launch-timing.ios.spec.ts b/packages/core/application/launch-timing.ios.spec.ts new file mode 100644 index 0000000000..068b14e9c2 --- /dev/null +++ b/packages/core/application/launch-timing.ios.spec.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Observable } from '../data/observable'; +import { iOSApplication } from './application.ios'; +import { getAppMainEntry, getiOSWindow, setAppMainEntry, setiOSWindow } from './helpers-common'; + +/** + * `vitest.setup.ts` installs a `NativeScriptGlobals` whose event bus methods are no-ops, and + * `ApplicationCommon` binds them per instance. Swapping in a real Observable before each + * Application is constructed is what makes its events observable at all. + */ +function installApplicationEventBus(): Observable { + const events = new Observable(); + const bus = (global.NativeScriptGlobals as any).events; + + bus.on = events.on.bind(events); + bus.once = events.once.bind(events); + bus.off = events.off.bind(events); + bus.notify = events.notify.bind(events); + bus.hasListeners = events.hasListeners.bind(events); + + return events; +} + +/** Mirrors the surface of UIWindow the non-scene launch path touches. */ +function createFakeUIWindow() { + return { + backgroundColor: null, + rootViewController: null, + makeKeyAndVisible() {}, + } as unknown as UIWindow; +} + +function createLaunchNotification() { + return { + userInfo: { + objectForKey: () => null, + }, + } as unknown as NSNotification; +} + +describe('non-scene launch timing', () => { + let app: iOSApplication; + let order: string[]; + let previousMainEntry: any; + let previousWindow: UIWindow; + let previousApplicationState: any; + + beforeEach(() => { + previousMainEntry = getAppMainEntry(); + previousWindow = getiOSWindow(); + previousApplicationState = (UIApplication.sharedApplication as any).applicationState; + (global as any).UIApplicationState = { Active: 0, Inactive: 1, Background: 2 }; + (UIApplication.sharedApplication as any).applicationState = 0; + + installApplicationEventBus(); + app = new iOSApplication(); + vi.spyOn(app, 'supportsScenes').mockReturnValue(false); + // Display-link plumbing, unrelated to launch timing and with no stand-in under test. + vi.spyOn(app, 'setMaxRefreshRate').mockImplementation(() => {}); + setiOSWindow(createFakeUIWindow()); + setAppMainEntry({ moduleName: 'app-root' }); + + order = []; + app.on('ready', () => order.push('ready')); + app.on('windowOpen', () => order.push('windowOpen')); + // Taking ownership of the root keeps the assertions on timing rather than on view creation. + app.on('launch', (args: any) => { + order.push('launch'); + args.root = null; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setAppMainEntry(previousMainEntry); + setiOSWindow(previousWindow); + (UIApplication.sharedApplication as any).applicationState = previousApplicationState; + delete (global as any).UIApplicationState; + installApplicationEventBus(); + }); + + it('resolves the first window content while the app finishes launching', () => { + (app as any).didFinishLaunchingWithOptions(createLaunchNotification()); + + expect(order).toEqual(['ready', 'windowOpen', 'launch']); + }); + + it('resolves it while finishing launching even when the app launched into the background', () => { + (UIApplication.sharedApplication as any).applicationState = (global as any).UIApplicationState.Background; + + (app as any).didFinishLaunchingWithOptions(createLaunchNotification()); + + expect(order).toContain('launch'); + expect(app.primaryWindow).toBeDefined(); + }); + + it('holds the content back until the app first becomes active when asked to delay', () => { + app.shouldDelayLaunchEvent = true; + + (app as any).didFinishLaunchingWithOptions(createLaunchNotification()); + + expect(order).toEqual(['ready', 'windowOpen']); + + (app as any).didBecomeActive(createLaunchNotification()); + + expect(order).toEqual(['ready', 'windowOpen', 'launch']); + }); + + it('resolves the delayed content only once, however often the app becomes active', () => { + app.shouldDelayLaunchEvent = true; + + (app as any).didFinishLaunchingWithOptions(createLaunchNotification()); + (app as any).didBecomeActive(createLaunchNotification()); + (app as any).didBecomeActive(createLaunchNotification()); + + expect(order.filter((entry) => entry === 'launch')).toHaveLength(1); + }); + + it('raises ready before the window opens whether or not the content is delayed', () => { + app.shouldDelayLaunchEvent = true; + + (app as any).didFinishLaunchingWithOptions(createLaunchNotification()); + + expect(order[0]).toBe('ready'); + expect(order[1]).toBe('windowOpen'); + }); +}); + +describe('scene launch timing', () => { + let app: iOSApplication; + let order: string[]; + let previousWindow: UIWindow; + + beforeEach(() => { + previousWindow = getiOSWindow(); + installApplicationEventBus(); + app = new iOSApplication(); + vi.spyOn(app, 'supportsScenes').mockReturnValue(true); + // Display-link plumbing, unrelated to launch timing and with no stand-in under test. + vi.spyOn(app, 'setMaxRefreshRate').mockImplementation(() => {}); + + order = []; + app.on('ready', () => order.push('ready')); + app.on('windowOpen', () => order.push('windowOpen')); + app.on('launch', () => order.push('launch')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setiOSWindow(previousWindow); + installApplicationEventBus(); + }); + + it('raises ready and leaves every window to its scene, delay flag or not', () => { + app.shouldDelayLaunchEvent = true; + + (app as any).didFinishLaunchingWithOptions(createLaunchNotification()); + + expect(order).toEqual(['ready']); + expect(app._getWindows()).toHaveLength(0); + + (app as any).didBecomeActive(createLaunchNotification()); + + expect(order).toEqual(['ready']); + }); +});