Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/application/application-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
/**
Expand Down
233 changes: 233 additions & 0 deletions packages/core/application/application-delegate.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}) {
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);
});
});
44 changes: 43 additions & 1 deletion packages/core/application/application.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UISceneSession>): 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.
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading