From 9f03323db3c34f92c03551c47c62851bbc429028 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 13 Apr 2026 13:17:59 -0300 Subject: [PATCH 01/23] feat: add cross-platform NativeWindow implementation - Introduced NativeWindow class to manage platform-specific window behavior for iOS and Android. - Added interfaces and common functionality for NativeWindow, including event handling and lifecycle management. - Implemented Android-specific NativeWindow logic to wrap AppCompatActivity and manage its lifecycle. - Implemented iOS-specific NativeWindow logic to wrap UIWindowScene and UIWindow, handling view controller setup and trait collection changes. - Updated core index files to export new NativeWindow functionality. --- .../core/application/application.android.ts | 107 +++ packages/core/application/application.d.ts | 52 +- packages/core/application/application.ios.ts | 635 +++++++++--------- packages/core/index.d.ts | 1 + packages/core/index.ts | 1 + packages/core/native-window/index.android.ts | 3 + packages/core/native-window/index.d.ts | 2 + packages/core/native-window/index.ios.ts | 3 + .../native-window/native-window-common.ts | 311 +++++++++ .../native-window/native-window-interfaces.ts | 148 ++++ .../native-window/native-window.android.ts | 144 ++++ .../core/native-window/native-window.d.ts | 33 + .../core/native-window/native-window.ios.ts | 216 ++++++ 13 files changed, 1332 insertions(+), 324 deletions(-) create mode 100644 packages/core/native-window/index.android.ts create mode 100644 packages/core/native-window/index.d.ts create mode 100644 packages/core/native-window/index.ios.ts create mode 100644 packages/core/native-window/native-window-common.ts create mode 100644 packages/core/native-window/native-window-interfaces.ts create mode 100644 packages/core/native-window/native-window.android.ts create mode 100644 packages/core/native-window/native-window.d.ts create mode 100644 packages/core/native-window/native-window.ios.ts diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index 126664aa55..a60b7f5484 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -8,6 +8,8 @@ import { ApplicationCommon } from './application-common'; import type { AndroidActivityBackPressedEventData, AndroidActivityBundleEventData, AndroidActivityEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, AndroidActivityResultEventData, ApplicationEventData } from './application-interfaces'; import { Observable } from '../data/observable'; import { Trace } from '../trace'; +import { NativeWindow } from '../native-window/native-window.android'; +import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; import { CommonA11YServiceEnabledObservable, SharedA11YObservable, @@ -78,6 +80,12 @@ function initNativeScriptLifecycleCallbacks() { this.nativescriptActivity = activity; } + // Create and register NativeWindow for this activity + const isPrimary = Application.android._getWindows().length === 0; + const nativeWindowId = NativeWindow.getActivityId(activity); + const nativeWindow = new NativeWindow(activity, nativeWindowId, isPrimary); + Application.android._registerWindow(nativeWindow); + this.notifyActivityCreated(activity, savedInstanceState); if (Application.hasListeners(Application.displayedEvent)) { @@ -105,6 +113,13 @@ function initNativeScriptLifecycleCallbacks() { } } + // Unregister NativeWindow for this activity + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.close); + Application.android._unregisterWindow(nativeWindow); + } + Application.android.notify({ eventName: Application.android.activityDestroyedEvent, object: Application.android, @@ -126,6 +141,11 @@ function initNativeScriptLifecycleCallbacks() { }); } + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.deactivate); + } + Application.android.notify({ eventName: Application.android.activityPausedEvent, object: Application.android, @@ -138,6 +158,11 @@ function initNativeScriptLifecycleCallbacks() { // console.log('NativeScriptLifecycleCallbacks onActivityResumed'); Application.android.setForegroundActivity(activity); + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.activate); + } + // NOTE: setSuspended(false) is called in frame/index.android.ts inside onPostResume // This is done to ensure proper timing for the event to be raised @@ -173,6 +198,11 @@ function initNativeScriptLifecycleCallbacks() { }); } + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.foreground); + } + Application.android.notify({ eventName: Application.android.activityStartedEvent, object: Application.android, @@ -192,6 +222,11 @@ function initNativeScriptLifecycleCallbacks() { }); } + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.background); + } + Application.android.notify({ eventName: Application.android.activityStoppedEvent, object: Application.android, @@ -534,6 +569,78 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp } return []; } + + // --- NativeWindow registry --- + private _windows: NativeWindow[] = []; + + /** + * @internal - Register a NativeWindow created by the lifecycle callbacks. + */ + _registerWindow(nativeWindow: NativeWindow): void { + this._windows.push(nativeWindow); + this.notify({ + eventName: WindowEvents.windowOpen, + object: this, + window: nativeWindow, + }); + } + + /** + * @internal - Unregister a NativeWindow when its activity is destroyed. + */ + _unregisterWindow(nativeWindow: NativeWindow): void { + const idx = this._windows.indexOf(nativeWindow); + if (idx >= 0) { + this._windows.splice(idx, 1); + } + this.notify({ + eventName: WindowEvents.windowClose, + object: this, + window: nativeWindow, + }); + nativeWindow._destroy(); + + // If primary was removed, promote next window + if (nativeWindow.isPrimary && this._windows.length > 0) { + (this._windows[0] as any)._isPrimary = true; + } + } + + /** + * @internal - Get all registered NativeWindows. + */ + _getWindows(): NativeWindow[] { + return this._windows; + } + + /** + * @internal - Get a NativeWindow by its activity. + */ + _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): NativeWindow | undefined { + return this._windows.find((nw) => nw.activity === activity); + } + + /** + * @internal - Get a NativeWindow by its id. + */ + _getWindowById(id: string): NativeWindow | undefined { + return this._windows.find((nw) => nw.id === id); + } + + /** + * Get the primary NativeWindow. + */ + get primaryWindow(): NativeWindow | undefined { + return this._windows.find((nw) => nw.isPrimary); + } + + /** + * Get all active NativeWindows. + */ + getWindows(): NativeWindow[] { + return [...this._windows]; + } + getRootView(): View { const activity = this.foregroundActivity || this.startActivity; if (!activity) { diff --git a/packages/core/application/application.d.ts b/packages/core/application/application.d.ts index a305a88838..f3be8c5639 100644 --- a/packages/core/application/application.d.ts +++ b/packages/core/application/application.d.ts @@ -1,5 +1,6 @@ import { ApplicationCommon } from './application-common'; import { FontScaleCategory } from '../accessibility/font-scale-common'; +import type { NativeWindowCommon } from '../native-window/native-window-common'; export * from './application-common'; export * from './application-interfaces'; @@ -147,6 +148,16 @@ export class AndroidApplication extends ApplicationCommon { on(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; on(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; on(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + + /** + * Get the primary NativeWindow. + */ + get primaryWindow(): NativeWindowCommon | undefined; + + /** + * Get all active NativeWindows. + */ + getWindows(): NativeWindowCommon[]; } export class iOSApplication extends ApplicationCommon { @@ -230,28 +241,33 @@ export class iOSApplication extends ApplicationCommon { /** * Gets all windows for the application. + * @deprecated Use `getWindows()` instead. */ getAllWindows(): UIWindow[]; /** * Gets all scenes for the application. + * @deprecated Use `getWindows()` instead. */ getAllScenes(): UIScene[]; /** * Gets all window scenes for the application. + * @deprecated Use `getWindows()` instead. */ getWindowScenes(): UIWindowScene[]; /** * Gets the primary window for the application. + * @deprecated Use `primaryWindow?.iosWindow?.window` instead. */ getPrimaryWindow(): UIWindow; /** * Gets the primary scene for the application. + * @deprecated Use `primaryWindow?.iosWindow?.scene` instead. */ - getPrimaryScene(): UIWindowScene; + getPrimaryScene(): UIWindowScene | null; /** * Sets the root view for a specific window. @@ -266,6 +282,40 @@ export class iOSApplication extends ApplicationCommon { */ sceneDelegate: UIWindowSceneDelegate; + /** + * Register a callback to intercept scene configuration. + * + * Called for every new scene session. Return a `UISceneConfiguration` to handle + * the scene yourself (e.g. CarPlay, external display), or return `null`/`undefined` + * to let NativeScript handle it with the default SceneDelegate. + * + * NativeScript only auto-manages `UIWindowSceneSessionRoleApplication` scenes. + * All other scene roles are ignored unless you provide a configuration here. + * + * @example + * ```ts + * Application.ios.onSceneConfiguration = (app, session, options) => { + * if (session.role === CPTemplateApplicationSceneSessionRoleApplication) { + * const config = UISceneConfiguration.configurationWithNameSessionRole('CarPlay', session.role); + * config.delegateClass = MyCarPlaySceneDelegate; + * return config; + * } + * return null; + * }; + * ``` + */ + onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; + + /** + * Get the primary NativeWindow. + */ + get primaryWindow(): NativeWindowCommon | undefined; + + /** + * Get all active NativeWindows. + */ + getWindows(): NativeWindowCommon[]; + /** * Flag to be set when the launch event should be delayed until the application has become active. * This is useful when you want to process notifications or data in the background without creating the UI. diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index b0b94e2d8b..23d472fe83 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -11,6 +11,8 @@ import { ApplicationEventData, SceneEventData } from './application-interfaces'; import { Observable } from '../data/observable'; import type { iOSApplication as IiOSApplication } from './application'; import { Trace } from '../trace'; +import { NativeWindow } from '../native-window/native-window.ios'; +import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; import { AccessibilityServiceEnabledPropName, CommonA11YServiceEnabledObservable, @@ -157,8 +159,30 @@ if (supportsScenes()) { * 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; + } + } + + // 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; @@ -197,53 +221,82 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { return; } - const isFirstScene = !Application.ios.getPrimaryScene() && !Application.hasLaunched(); + const windowScene = scene as UIWindowScene; + const isFirstScene = Application.ios._getWindows().length === 0 && !Application.hasLaunched(); - this._scene = scene; + this._scene = windowScene; // Create window for this scene - this._window = UIWindow.alloc().initWithWindowScene(scene); + this._window = UIWindow.alloc().initWithWindowScene(windowScene); + + // Set up window background + if (!__VISIONOS__) { + this._window.backgroundColor = SDK_VERSION <= 12 || !UIColor.systemBackgroundColor ? UIColor.whiteColor : UIColor.systemBackgroundColor; + } - // Store the window scene for this window - Application.ios._setWindowForScene(this._window, scene); + const isPrimary = isFirstScene || !Application.ios.primaryWindow; + const nativeWindowId = NativeWindow.getSceneId(windowScene); - // Set up the window content - Application.ios._setupWindowForScene(this._window, scene); + // Create NativeWindow and register it + const nativeWindow = new NativeWindow(windowScene, this._window, nativeWindowId, isPrimary); + Application.ios._registerWindow(nativeWindow); + + if (isPrimary) { + // For primary, also set the legacy global window reference + setiOSWindow(this._window); + } // Notify that scene will connect Application.ios.notify({ eventName: SceneEvents.sceneWillConnect, object: Application.ios, - scene: scene, + scene: windowScene, window: this._window, connectionOptions: connectionOptions, } as SceneEventData); - if (scene === Application.ios.getPrimaryScene()) { + if (isPrimary) { // primary scene, activate right away this._window.makeKeyAndVisible(); - } else { - // For secondary scenes, emit an event to allow developers to set up custom content for the window - Application.ios.notify({ - eventName: SceneEvents.sceneContentSetup, - object: Application.ios, - scene: scene, - window: this._window, - connectionOptions: connectionOptions, - } as SceneEventData); } // If this is the first scene, trigger app startup if (isFirstScene) { Application.ios._notifySceneAppStarted(); + } else if (isPrimary && Application.ios.hasLaunched()) { + // Primary scene reconnecting after disconnect — restore content + (Application.ios as any).setWindowContent(); } } + sceneDidBecomeActive(scene: UIScene): void { - // This will be handled by the notification observer in iOSApplication - // The notification system will automatically trigger sceneDidActivate + const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.activate); + } + + // If this is the primary scene, trigger traditional app lifecycle + if (nativeWindow?.isPrimary) { + const additionalData = { + ios: UIApplication.sharedApplication, + scene: scene, + }; + Application.ios.setInBackground(false, additionalData); + Application.ios.setSuspended(false, additionalData); + + const rootView = nativeWindow.rootView; + if (rootView && !rootView.isLoaded) { + rootView.callLoaded(); + } + } } sceneWillResignActive(scene: UIScene): void { + const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.deactivate); + } + // Notify that scene will resign active Application.ios.notify({ eventName: SceneEvents.sceneWillResignActive, @@ -253,15 +306,58 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { } sceneWillEnterForeground(scene: UIScene): void { - // This will be handled by the notification observer in iOSApplication + const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.foreground); + } + + Application.ios.notify({ + eventName: SceneEvents.sceneWillEnterForeground, + object: Application.ios, + scene: scene, + } as SceneEventData); } sceneDidEnterBackground(scene: UIScene): void { - // This will be handled by the notification observer in iOSApplication + const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.background); + } + + Application.ios.notify({ + eventName: SceneEvents.sceneDidEnterBackground, + object: Application.ios, + scene: scene, + } as SceneEventData); + + // If this is the primary scene, trigger traditional app lifecycle + if (nativeWindow?.isPrimary) { + const additionalData = { + ios: UIApplication.sharedApplication, + scene: scene, + }; + Application.ios.setInBackground(true, additionalData); + Application.ios.setSuspended(true, additionalData); + + const rootView = nativeWindow.rootView; + if (rootView && rootView.isLoaded) { + rootView.callUnloaded(); + } + } } sceneDidDisconnect(scene: UIScene): void { - // This will be handled by the notification observer in iOSApplication + const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.close); + Application.ios._unregisterWindow(nativeWindow); + } + + Application.ios.notify({ + eventName: SceneEvents.sceneDidDisconnect, + object: Application.ios, + scene: scene, + } as SceneEventData); } } // ensure available globally @@ -273,9 +369,17 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication private _rootView: View; private launchEventCalled = false; private _sceneDelegate: UIWindowSceneDelegate; - private _windowSceneMap = new Map(); - private _primaryScene: UIWindowScene | null = null; - private _openedScenesById = new Map(); + /** + * User-provided callback to intercept scene configuration. + * Called for every new scene session. Return a UISceneConfiguration to handle + * the scene yourself, or return null/undefined to let NativeScript handle it + * (only for UIWindowSceneSessionRoleApplication scenes). + * @internal + */ + _onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; + + // NativeWindow registry + private _windows: NativeWindow[] = []; private _notificationObservers: NotificationObserver[] = []; @@ -299,22 +403,11 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication super(); this.addNotificationObserver(UIApplicationDidFinishLaunchingNotification, this.didFinishLaunchingWithOptions.bind(this)); + this.addNotificationObserver(UIApplicationDidBecomeActiveNotification, this.didBecomeActive.bind(this)); + this.addNotificationObserver(UIApplicationDidEnterBackgroundNotification, this.didEnterBackground.bind(this)); this.addNotificationObserver(UIApplicationWillTerminateNotification, this.willTerminate.bind(this)); this.addNotificationObserver(UIApplicationDidReceiveMemoryWarningNotification, this.didReceiveMemoryWarning.bind(this)); this.addNotificationObserver(UIApplicationDidChangeStatusBarOrientationNotification, this.didChangeStatusBarOrientation.bind(this)); - - // Add scene lifecycle notification observers only if scenes are supported - if (this.supportsScenes()) { - this.addNotificationObserver('UISceneWillConnectNotification', this.sceneWillConnect.bind(this)); - this.addNotificationObserver('UISceneDidActivateNotification', this.sceneDidActivate.bind(this)); - this.addNotificationObserver('UISceneWillEnterForegroundNotification', this.sceneWillEnterForeground.bind(this)); - this.addNotificationObserver('UISceneDidEnterBackgroundNotification', this.sceneDidEnterBackground.bind(this)); - this.addNotificationObserver('UISceneDidDisconnectNotification', this.sceneDidDisconnect.bind(this)); - } else { - // For scene-based apps, the below are not needed as they are handled by the scene notifications - this.addNotificationObserver(UIApplicationDidBecomeActiveNotification, this.didBecomeActive.bind(this)); - this.addNotificationObserver(UIApplicationDidEnterBackgroundNotification, this.didEnterBackground.bind(this)); - } } getRootView(): View { @@ -412,8 +505,20 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } if (targetScene) { window = UIWindow.alloc().initWithWindowScene(targetScene); - this._setWindowForScene(window, targetScene); - this._setupWindowForScene?.(window, targetScene); + + if (!__VISIONOS__) { + window.backgroundColor = SDK_VERSION <= 12 || !UIColor.systemBackgroundColor ? UIColor.whiteColor : UIColor.systemBackgroundColor; + } + + // The registry lives in JS and was lost with the previous isolate, so + // the still-connected scene needs a fresh NativeWindow to be reachable. + const isPrimary = !this.primaryWindow; + const nativeWindow = new NativeWindow(targetScene, window, NativeWindow.getSceneId(targetScene), isPrimary); + this._registerWindow(nativeWindow); + + if (isPrimary) { + setiOSWindow(window); + } // If the scene's delegate was recreated after a soft reboot, point it // at the new window so `scene.delegate.window` queries resolve. @@ -872,28 +977,36 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication if (!this.launchEventCalled) { this.notifyAppStarted(notification); } - const additionalData = { - ios: UIApplication.sharedApplication, - }; - this.setInBackground(false, additionalData); - this.setSuspended(false, additionalData); - const rootView = this._rootView; - if (rootView && !rootView.isLoaded) { - rootView.callLoaded(); + // Only handle lifecycle here when NOT using scenes + // (scene lifecycle is handled by SceneDelegate methods) + if (!this.supportsScenes()) { + const additionalData = { + ios: UIApplication.sharedApplication, + }; + this.setInBackground(false, additionalData); + this.setSuspended(false, additionalData); + + const rootView = this._rootView; + if (rootView && !rootView.isLoaded) { + rootView.callLoaded(); + } } } private didEnterBackground(notification: NSNotification) { - const additionalData = { - ios: UIApplication.sharedApplication, - }; - this.setInBackground(true, additionalData); - this.setSuspended(true, additionalData); + // Only handle lifecycle here when NOT using scenes + if (!this.supportsScenes()) { + const additionalData = { + ios: UIApplication.sharedApplication, + }; + this.setInBackground(true, additionalData); + this.setSuspended(true, additionalData); - const rootView = this._rootView; - if (rootView && rootView.isLoaded) { - rootView.callUnloaded(); + const rootView = this._rootView; + if (rootView && rootView.isLoaded) { + rootView.callUnloaded(); + } } } @@ -924,173 +1037,114 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication this.setOrientation(newOrientation); } - // Scene lifecycle notification handlers - private sceneWillConnect(notification: NSNotification) { - const scene = notification.object as UIWindowScene; - if (!scene || !(scene instanceof UIWindowScene)) { - return; - } - - // Store as primary scene if it's the first one - if (!this._primaryScene) { - this._primaryScene = scene; - } + // --- NativeWindow registry --- - this.notify({ - eventName: SceneEvents.sceneWillConnect, + /** + * @internal - Register a NativeWindow created by the SceneDelegate. + */ + _registerWindow(nativeWindow: NativeWindow): void { + this._windows.push(nativeWindow); + this.notify({ + eventName: WindowEvents.windowOpen, object: this, - scene: scene, - userInfo: notification.userInfo, - } as SceneEventData); + window: nativeWindow, + }); } - private sceneDidActivate(notification: NSNotification) { - const scene = notification.object as UIScene; - this.notify({ - eventName: SceneEvents.sceneDidActivate, + /** + * @internal - Unregister a NativeWindow when its scene disconnects. + */ + _unregisterWindow(nativeWindow: NativeWindow): void { + const idx = this._windows.indexOf(nativeWindow); + if (idx >= 0) { + this._windows.splice(idx, 1); + } + this.notify({ + eventName: WindowEvents.windowClose, object: this, - scene: scene, - } as SceneEventData); - - // If this is the primary scene, trigger traditional app lifecycle - if (scene === this._primaryScene) { - const additionalData = { - ios: UIApplication.sharedApplication, - scene: scene, - }; - this.setInBackground(false, additionalData); - this.setSuspended(false, additionalData); - - if (this._rootView && !this._rootView.isLoaded) { - this._rootView.callLoaded(); + window: nativeWindow, + }); + nativeWindow._destroy(); + + // If primary was removed, promote next window + if (nativeWindow.isPrimary && this._windows.length > 0) { + (this._windows[0] as any)._isPrimary = true; + const promotedWindow = this._windows[0].iosWindow?.window; + if (promotedWindow) { + setiOSWindow(promotedWindow); } } } - private sceneWillEnterForeground(notification: NSNotification) { - const scene = notification.object as UIScene; - this.notify({ - eventName: SceneEvents.sceneWillEnterForeground, - object: this, - scene: scene, - } as SceneEventData); + /** + * @internal - Get all registered NativeWindows. + */ + _getWindows(): NativeWindow[] { + return this._windows; } - private sceneDidEnterBackground(notification: NSNotification) { - const scene = notification.object as UIScene; - this.notify({ - eventName: SceneEvents.sceneDidEnterBackground, - object: this, - scene: scene, - } as SceneEventData); - - // If this is the primary scene, trigger traditional app lifecycle - if (scene === this._primaryScene) { - const additionalData = { - ios: UIApplication.sharedApplication, - scene: scene, - }; - this.setInBackground(true, additionalData); - this.setSuspended(true, additionalData); - - if (this._rootView && this._rootView.isLoaded) { - this._rootView.callUnloaded(); - } - } + /** + * @internal - Get a NativeWindow by its scene. + */ + _getWindowForScene(scene: UIWindowScene): NativeWindow | undefined { + return this._windows.find((nw) => nw.iosWindow?.scene === scene); } - private sceneDidDisconnect(notification: NSNotification) { - const scene = notification.object as UIScene; - this._removeWindowForScene(scene); - - // If primary scene disconnected, clear it - if (scene === this._primaryScene) { - this._primaryScene = null; - } - - if (this._primaryScene) { - if (SDK_VERSION >= 17) { - const request = UISceneSessionActivationRequest.requestWithSession(this._primaryScene.session); + /** + * @internal - Get a NativeWindow by its id. + */ + _getWindowById(id: string): NativeWindow | undefined { + return this._windows.find((nw) => nw.id === id); + } - UIApplication.sharedApplication.activateSceneSessionForRequestErrorHandler(request, (err: NSError) => { - if (err) { - console.log('Failed to activate primary scene:', err.localizedDescription); - } - }); - } else { - UIApplication.sharedApplication.requestSceneSessionActivationUserActivityOptionsErrorHandler(this._primaryScene.session, null, null, (err: NSError) => { - if (err) { - console.log('Failed to activate primary scene (legacy):', err.localizedDescription); - } - }); - } - } + // --- Public NativeWindow API --- - this.notify({ - eventName: SceneEvents.sceneDidDisconnect, - object: this, - scene: scene, - } as SceneEventData); + /** + * Get the primary NativeWindow. + */ + get primaryWindow(): NativeWindow | undefined { + return this._windows.find((nw) => nw.isPrimary); } - // Scene management helper methods - _setWindowForScene(window: UIWindow, scene: UIScene): void { - this._windowSceneMap.set(scene, window); + /** + * Get all active NativeWindows. + */ + getWindows(): NativeWindow[] { + return [...this._windows]; } - _removeWindowForScene(scene: UIScene): void { - this._windowSceneMap.delete(scene); - // also untrack opened scene id - try { - const s: any = scene as any; - if (s && s.session) { - const id = this._getSceneId(s as UIWindowScene); - this._openedScenesById.delete(id); - } - } catch {} + /** + * Register a callback to intercept scene configuration. + * + * Called for every new scene session. Return a `UISceneConfiguration` to handle + * the scene yourself (e.g. CarPlay, external display), or return `null`/`undefined` + * to let NativeScript handle it with the default SceneDelegate. + * + * NativeScript only auto-manages `UIWindowSceneSessionRoleApplication` scenes. + * All other scene roles are ignored unless you provide a configuration here. + * + * @example + * ```ts + * Application.ios.onSceneConfiguration = (app, session, options) => { + * if (session.role === CPTemplateApplicationSceneSessionRoleApplication) { + * const config = UISceneConfiguration.configurationWithNameSessionRole('CarPlay', session.role); + * config.delegateClass = MyCarPlaySceneDelegate; + * return config; + * } + * // Return null to let NativeScript handle the default window scene + * return null; + * }; + * ``` + */ + set onSceneConfiguration(handler: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null) { + this._onSceneConfiguration = handler; } - _getWindowForScene(scene: UIScene): UIWindow | undefined { - return this._windowSceneMap.get(scene); + get onSceneConfiguration(): ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null { + return this._onSceneConfiguration; } - _setupWindowForScene(window: UIWindow, scene: UIWindowScene): void { - if (!window) { - return; - } - - // track opened scene - try { - const id = this._getSceneId(scene); - this._openedScenesById.set(id, scene); - } catch {} - - // Set up window background - if (!__VISIONOS__) { - window.backgroundColor = SDK_VERSION <= 12 || !UIColor.systemBackgroundColor ? UIColor.whiteColor : UIColor.systemBackgroundColor; - } - - // If this is the primary scene, set up the main application content - if (scene === this._primaryScene || !this._primaryScene) { - this._primaryScene = scene; - - if (!getiOSWindow()) { - setiOSWindow(window); - } - - // During initial scene startup we must wait for launch to be notified first. - // Some frameworks provide root content from launch handlers. - // Guard: skip setWindowContent when no main entry is configured yet. - // During Vite HMR dev boot, the placeholder calls Application.run() with - // no entry; the real entry is set later when the HTTP-loaded main module - // calls Application.run({ moduleName: ... }). Without this guard the - // scene handler would throw "Main entry is missing" and leave the window - // in a broken state (root view reset but no replacement created). - if (this.hasLaunched() && this.getMainEntry()) { - this.setWindowContent(); - } - } - } + // Scene management helper methods (kept for backward compat) get sceneDelegate(): UIWindowSceneDelegate { if (!this._sceneDelegate) { @@ -1122,39 +1176,20 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // iOS 17+ if (SDK_VERSION >= 17) { - // Create a new scene activation request with proper role let request: UISceneSessionActivationRequest; try { - // Use the correct factory method to create request with role - // Based on the type definitions, this is the proper way request = UISceneSessionActivationRequest.requestWithRole(UIWindowSceneSessionRoleApplication); - // Note: may be useful to allow user defined activity type through optional string typed data in future const activity = NSUserActivity.alloc().initWithActivityType(`${NSBundle.mainBundle.bundleIdentifier}.scene`); activity.userInfo = dataSerialize(data); request.userActivity = activity; - // Set proper options with requesting scene const options = UISceneActivationRequestOptions.new(); - - // Note: explore secondary windows spawning other windows - // and if this context needs to change in those cases - const mainWindow = Application.ios.getPrimaryWindow(); - options.requestingScene = mainWindow?.windowScene; - - /** - * Note: This does not work in testing but worth exploring further sometime - * regarding the size/dimensions of opened secondary windows. - * The initial size is ultimately determined by the system - * based on available space and user context. - */ - // Get the size restrictions from the window scene - // const sizeRestrictions = (options.requestingScene as UIWindowScene).sizeRestrictions; - - // // Set your minimum and maximum dimensions - // sizeRestrictions.minimumSize = CGSizeMake(320, 400); - // sizeRestrictions.maximumSize = CGSizeMake(600, 800); + const primary = this.primaryWindow; + if (primary?.iosWindow?.scene) { + options.requestingScene = primary.iosWindow.scene; + } request.options = options; } catch (roleError) { @@ -1166,12 +1201,10 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication if (error) { console.log('Error creating new scene (iOS 17+):', error); - // Log additional debugging info if (error.userInfo) { console.error(`Error userInfo: ${error.userInfo.description}`); } - // Handle specific error types if (error.localizedDescription.includes('role') && error.localizedDescription.includes('nil')) { this.createSceneWithLegacyAPI(data); } else if (error.domain === 'FBSWorkspaceErrorDomain' && error.code === 2) { @@ -1179,22 +1212,13 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } } }); - } - // iOS 13-16 - Use the legacy requestSceneSessionActivationUserActivityOptionsErrorHandler method - else if (SDK_VERSION >= 13 && SDK_VERSION < 17) { - app.requestSceneSessionActivationUserActivityOptionsErrorHandler( - null, // session - null, // userActivity - null, // options - (error) => { - if (error) { - console.log('Error creating new scene (legacy):', error); - } - }, - ); - } - // Fallback for older iOS versions or unsupported configurations - else { + } else if (SDK_VERSION >= 13 && SDK_VERSION < 17) { + app.requestSceneSessionActivationUserActivityOptionsErrorHandler(null, null, null, (error) => { + if (error) { + console.log('Error creating new scene (legacy):', error); + } + }); + } else { console.log('Neither new nor legacy scene activation methods are available'); } } catch (error) { @@ -1204,76 +1228,72 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication /** * Closes a secondary window/scene. - * Usage examples: - * - Application.ios.closeWindow() // best-effort close of a non-primary scene - * - Application.ios.closeWindow(button) // from a tap handler within the scene - * - Application.ios.closeWindow(window) - * - Application.ios.closeWindow(scene) - * - Application.ios.closeWindow('scene-id') + * Accepts a NativeWindow, View, UIWindow, UIWindowScene, or string id. */ - public closeWindow(target?: View | UIWindow | UIWindowScene | string): void { + public closeWindow(target?: NativeWindow | View | UIWindow | UIWindowScene | string): void { if (!__APPLE__) { return; } try { - const scene = this._resolveScene(target); - if (!scene) { - console.log('closeWindow: No scene resolved for target'); - return; - } + let nativeWindow: NativeWindow | undefined; - // Don't allow closing the primary scene - if (scene === this._primaryScene) { - console.log('closeWindow: Refusing to close the primary scene'); - return; + if (target instanceof NativeWindow) { + nativeWindow = target; + } else { + const scene = this._resolveScene(target); + if (scene) { + nativeWindow = this._getWindowForScene(scene); + } } - const session = scene.session; - if (!session) { - console.log('closeWindow: Scene has no session to destroy'); + if (!nativeWindow) { + console.log('closeWindow: No window resolved for target'); return; } - const app = UIApplication.sharedApplication; - if (app.requestSceneSessionDestructionOptionsErrorHandler) { - app.requestSceneSessionDestructionOptionsErrorHandler(session, null, (error: NSError) => { - if (error) { - console.log('closeWindow: destruction error', error); - } else { - // clean up tracked id - const id = this._getSceneId(scene); - this._openedScenesById.delete(id); - } - }); - } else { - console.info('closeWindow: Scene destruction API not available on this iOS version'); - } + nativeWindow.close(); } catch (err) { console.log('closeWindow: Unexpected error', err); } } + /** + * @deprecated Use `getWindows()` instead. + */ getAllWindows(): UIWindow[] { - return Array.from(this._windowSceneMap.values()); + return this._windows.map((nw) => nw.iosWindow?.window).filter(Boolean) as UIWindow[]; } + /** + * @deprecated Use `getWindows()` instead. + */ getAllScenes(): UIScene[] { - return Array.from(this._windowSceneMap.keys()); + return this._windows.map((nw) => nw.iosWindow?.scene).filter(Boolean) as UIScene[]; } + /** + * @deprecated Use `getWindows()` instead. + */ getWindowScenes(): UIWindowScene[] { return this.getAllScenes().filter((scene) => scene instanceof UIWindowScene) as UIWindowScene[]; } + /** + * @deprecated Use `primaryWindow?.iosWindow?.window` instead. + */ getPrimaryWindow(): UIWindow { - if (this._primaryScene) { - return this._getWindowForScene(this._primaryScene) || getiOSWindow(); + const primary = this.primaryWindow; + if (primary?.iosWindow?.window) { + return primary.iosWindow.window; } return getiOSWindow(); } + /** + * @deprecated Use `primaryWindow?.iosWindow?.scene` instead. + */ getPrimaryScene(): UIWindowScene | null { - return this._primaryScene; + return this.primaryWindow?.iosWindow?.scene || null; } // Scene lifecycle management @@ -1286,7 +1306,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } isUsingSceneLifecycle(): boolean { - return this.supportsScenes() && this._windowSceneMap.size > 0; + return this.supportsScenes() && this._windows.length > 0; } // Call this to set up scene-based configuration @@ -1295,35 +1315,6 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication console.warn('Scene-based lifecycle is only supported on iOS 13+ iPad or visionOS with multi-scene enabled apps.'); return; } - - // Additional scene configuration can be added here - // For now, the notification observers are already set up in the constructor - } - - // Stable scene id for lookups - private _getSceneId(scene: UIWindowScene): string { - try { - if (!scene) { - return 'Unknown'; - } - // Prefer session persistentIdentifier when available (stable across lifetime) - const session = scene.session; - const persistentId = session && session.persistentIdentifier; - if (persistentId) { - return `${persistentId}`; - } - // Fallbacks - if (scene.hash != null) { - return `${scene.hash}`; - } - const desc = scene.description; - if (desc) { - return `${desc}`; - } - } catch (err) { - // ignore - } - return 'Unknown'; } // Resolve a UIWindowScene from various input types @@ -1332,12 +1323,10 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication return null; } if (!target) { - // Try to pick a non-primary foreground active scene, else last known scene - const scenes = this.getWindowScenes?.() || []; - const nonPrimary = scenes.filter((s) => s !== this._primaryScene); - return nonPrimary[0] || scenes[0] || null; + // Try to pick a non-primary window's scene + const nonPrimary = this._windows.filter((nw) => !nw.isPrimary); + return nonPrimary[0]?.iosWindow?.scene || this.primaryWindow?.iosWindow?.scene || null; } - // If a View was passed, derive its window.scene if (target && typeof target === 'object') { // UIWindowScene if ((target as UIWindowScene).session && (target as UIWindowScene).activationState !== undefined) { @@ -1356,15 +1345,15 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } // String id lookup if (typeof target === 'string') { - if (this._openedScenesById.has(target)) { - return this._openedScenesById.get(target); + const found = this._getWindowById(target); + if (found) { + return found.iosWindow?.scene || null; } - // Try matching by persistentIdentifier or hash among known scenes - const scenes = this.getWindowScenes?.() || []; - for (const s of scenes) { - const sid = this._getSceneId(s); - if (sid === target) { - return s; + // Try matching among known scenes + for (const nw of this._windows) { + const scene = nw.iosWindow?.scene; + if (scene && NativeWindow.getSceneId(scene) === target) { + return scene; } } } diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 93539deb49..d97326afaf 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -11,6 +11,7 @@ export type { NativeScriptConfig } from './config'; export * from './application'; export { androidRegisterBroadcastReceiver, androidUnregisterBroadcastReceiver, androidRegisteredReceivers, iosAddNotificationObserver, iosRemoveNotificationObserver, iosNotificationObservers } from './application/helpers'; export { getNativeApp, setNativeApp } from './application/helpers-common'; +export * from './native-window'; export * as ApplicationSettings from './application-settings'; export namespace AccessibilityEvents { export const accessibilityBlurEvent: 'accessibilityBlur'; diff --git a/packages/core/index.ts b/packages/core/index.ts index 4e12c9fe93..f7fe4efefc 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -4,6 +4,7 @@ import './globals'; export * from './application'; export { getNativeApp, setNativeApp } from './application/helpers-common'; +export * from './native-window'; export * as ApplicationSettings from './application-settings'; import * as Accessibility from './accessibility'; export namespace AccessibilityEvents { diff --git a/packages/core/native-window/index.android.ts b/packages/core/native-window/index.android.ts new file mode 100644 index 0000000000..83ccbf211d --- /dev/null +++ b/packages/core/native-window/index.android.ts @@ -0,0 +1,3 @@ +export * from './native-window-interfaces'; +export * from './native-window-common'; +export * from './native-window'; diff --git a/packages/core/native-window/index.d.ts b/packages/core/native-window/index.d.ts new file mode 100644 index 0000000000..96264fa3ca --- /dev/null +++ b/packages/core/native-window/index.d.ts @@ -0,0 +1,2 @@ +export * from './native-window-interfaces'; +export * from './native-window-common'; diff --git a/packages/core/native-window/index.ios.ts b/packages/core/native-window/index.ios.ts new file mode 100644 index 0000000000..83ccbf211d --- /dev/null +++ b/packages/core/native-window/index.ios.ts @@ -0,0 +1,3 @@ +export * from './native-window-interfaces'; +export * from './native-window-common'; +export * from './native-window'; diff --git a/packages/core/native-window/native-window-common.ts b/packages/core/native-window/native-window-common.ts new file mode 100644 index 0000000000..41319f00ab --- /dev/null +++ b/packages/core/native-window/native-window-common.ts @@ -0,0 +1,311 @@ +import { Observable } from '../data/observable'; +import { CoreTypes } from '../core-types'; +import { CSSUtils } from '../css/system-classes'; +import { Device } from '../platform'; +import { Trace } from '../trace'; +import { Builder } from '../ui/builder'; +import type { View } from '../ui/core/view'; +import type { Frame } from '../ui/frame'; +import type { NavigationEntry } from '../ui/frame/frame-interfaces'; +import type { StyleScope } from '../ui/styling/style-scope'; +import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessibility/accessibility-common'; +import { SDK_VERSION } from '../utils/constants'; +import type { INativeWindow, NativeWindowEventData, NativeWindowEventName } from './native-window-interfaces'; +import { NativeWindowEvents } from './native-window-interfaces'; + +// prettier-ignore +const ORIENTATION_CSS_CLASSES = [ + `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.portrait}`, + `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.landscape}`, + `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.unknown}`, +]; + +// prettier-ignore +const SYSTEM_APPEARANCE_CSS_CLASSES = [ + `${CSSUtils.CLASS_PREFIX}${CoreTypes.SystemAppearance.light}`, + `${CSSUtils.CLASS_PREFIX}${CoreTypes.SystemAppearance.dark}`, +]; + +// prettier-ignore +const LAYOUT_DIRECTION_CSS_CLASSES = [ + `${CSSUtils.CLASS_PREFIX}${CoreTypes.LayoutDirection.ltr}`, + `${CSSUtils.CLASS_PREFIX}${CoreTypes.LayoutDirection.rtl}`, +]; + +let _windowIdCounter = 0; + +/** + * Cross-platform NativeWindow base class. + * + * Wraps a platform window surface (iOS UIWindowScene+UIWindow, Android Activity) + * and manages per-window root view lifecycle, CSS classes, and events. + * + * Platform-specific subclasses implement the abstract methods. + */ +export abstract class NativeWindowCommon extends Observable implements INativeWindow { + private _id: string; + private _isPrimary: boolean; + protected _rootView: View; + protected _orientation: 'portrait' | 'landscape' | 'unknown'; + protected _systemAppearance: 'dark' | 'light' | null; + protected _layoutDirection: CoreTypes.LayoutDirectionType | null; + + constructor(id?: string, isPrimary = false) { + super(); + this._id = id || `window-${++_windowIdCounter}`; + this._isPrimary = isPrimary; + } + + get id(): string { + return this._id; + } + + get isPrimary(): boolean { + return this._isPrimary; + } + + /** + * @internal - used by the Application to promote a window to primary. + */ + _setIsPrimary(value: boolean): void { + this._isPrimary = value; + } + + get rootView(): View { + return this._rootView; + } + + /** + * Set the content of this window. + * Accepts a View, a NavigationEntry, or a module name string. + */ + setContent(content: View | NavigationEntry | string): void { + let view: View; + + if (typeof content === 'string') { + view = Builder.createViewFromEntry({ moduleName: content }); + } else if (content && typeof content === 'object') { + if ((content as NavigationEntry).moduleName || (content as NavigationEntry).create) { + view = Builder.createViewFromEntry(content as NavigationEntry); + } else { + view = content as View; + } + } + + if (!view) { + throw new Error('NativeWindow.setContent: Invalid content provided.'); + } + + const previousRootView = this._rootView; + if (previousRootView) { + previousRootView._onRootViewReset(); + } + + this._rootView = view; + this._applyRootViewSettings(view); + this._setNativeContent(view); + + this._notifyEvent(NativeWindowEvents.contentLoaded); + } + + /** + * Platform-specific: apply the view to the native window surface. + */ + protected abstract _setNativeContent(view: View): void; + + /** + * Close this window. + */ + abstract close(): void; + + /** + * Get the current orientation of this window. + */ + orientation(): 'portrait' | 'landscape' | 'unknown' { + return (this._orientation ??= this._getOrientation()); + } + + /** + * Get the current system appearance of this window. + */ + systemAppearance(): 'light' | 'dark' | null { + return (this._systemAppearance ??= this._getSystemAppearance()); + } + + /** + * Get the current layout direction of this window. + */ + layoutDirection(): CoreTypes.LayoutDirectionType | null { + return (this._layoutDirection ??= this._getLayoutDirection()); + } + + get iosWindow(): INativeWindow['iosWindow'] { + return undefined; + } + + get androidWindow(): INativeWindow['androidWindow'] { + return undefined; + } + + // Platform-specific abstract getters + protected abstract _getOrientation(): 'portrait' | 'landscape' | 'unknown'; + protected abstract _getSystemAppearance(): 'light' | 'dark' | null; + protected abstract _getLayoutDirection(): CoreTypes.LayoutDirectionType | null; + + // --- Root view CSS class management --- + + /** + * Applies platform, orientation, appearance, and layout direction CSS classes + * to the root view. + */ + protected _applyRootViewSettings(rootView: View): void { + rootView._setupAsRootView({}); + this._setRootViewCSSClasses(rootView); + readyInitAccessibilityCssHelper(); + readyInitFontScale(); + } + + private _setRootViewCSSClasses(rootView: View): void { + const platform = Device.os.toLowerCase(); + const deviceType = Device.deviceType.toLowerCase(); + const orientationValue = this.orientation(); + const appearanceValue = this.systemAppearance(); + const directionValue = this.layoutDirection(); + + if (platform) { + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${platform}`); + + // SDK Version CSS classes + // Add exact version class (e.g., .ns-ios-26 or .ns-android-36) + // this acts like 'gte' for that major version range + // e.g., if user wants iOS 27, they can add .ns-ios-27 specifiers + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${platform}-${Math.floor(SDK_VERSION)}`); + } + + if (deviceType) { + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${deviceType}`); + } + + if (orientationValue) { + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${orientationValue}`); + } + + if (appearanceValue) { + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${appearanceValue}`); + } + + if (directionValue) { + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${directionValue}`); + } + + rootView.cssClasses.add(CSSUtils.ROOT_VIEW_CSS_CLASS); + const rootViewCssClasses = CSSUtils.getSystemCssClasses(); + rootViewCssClasses.forEach((c) => rootView.cssClasses.add(c)); + + this._increaseStyleScopeVersion(rootView); + rootView._onCssStateChange(); + + if (Trace.isEnabled()) { + const rootCssClasses = Array.from(rootView.cssClasses); + Trace.write(`NativeWindow [${this._id}] Setting root css classes: ${rootCssClasses.join(' ')}`, Trace.categories.Style); + } + } + + // --- Orientation / Appearance / Direction change handling --- + + /** + * @internal – called by platform when orientation changes for this window. + */ + _setOrientation(value: 'portrait' | 'landscape' | 'unknown'): void { + if (this._orientation === value) { + return; + } + this._orientation = value; + if (this._rootView) { + const cssClass = `${CSSUtils.CLASS_PREFIX}${value}`; + this._applyCssClass(this._rootView, ORIENTATION_CSS_CLASSES, cssClass); + } + } + + /** + * @internal – called by platform when system appearance changes for this window. + */ + _setSystemAppearance(value: 'dark' | 'light'): void { + if (this._systemAppearance === value) { + return; + } + this._systemAppearance = value; + if (this._rootView) { + const cssClass = `${CSSUtils.CLASS_PREFIX}${value}`; + this._applyCssClass(this._rootView, SYSTEM_APPEARANCE_CSS_CLASSES, cssClass); + } + } + + /** + * @internal – called by platform when layout direction changes for this window. + */ + _setLayoutDirection(value: CoreTypes.LayoutDirectionType): void { + if (this._layoutDirection === value) { + return; + } + this._layoutDirection = value; + if (this._rootView) { + const cssClass = `${CSSUtils.CLASS_PREFIX}${value}`; + this._applyCssClass(this._rootView, LAYOUT_DIRECTION_CSS_CLASSES, cssClass); + } + } + + // --- Internal helpers --- + + private _applyCssClass(rootView: View, cssClasses: string[], newCssClass: string): void { + if (!rootView.cssClasses.has(newCssClass)) { + cssClasses.forEach((cssClass) => { + CSSUtils.removeSystemCssClass(cssClass); + rootView.cssClasses.delete(cssClass); + }); + CSSUtils.pushToSystemCssClasses(newCssClass); + rootView.cssClasses.add(newCssClass); + this._increaseStyleScopeVersion(rootView); + rootView._onCssStateChange(); + } + + // Apply to modal views + const rootModalViews = >rootView._getRootModalViews(); + rootModalViews.forEach((modalView) => { + if (!modalView.cssClasses.has(newCssClass)) { + cssClasses.forEach((cssClass) => modalView.cssClasses.delete(cssClass)); + modalView.cssClasses.add(newCssClass); + modalView._onCssStateChange(); + } + }); + } + + private _increaseStyleScopeVersion(rootView: View): void { + const styleScope: StyleScope = rootView._styleScope ?? (rootView as unknown as Frame)?.currentPage?._styleScope; + if (styleScope) { + styleScope._increaseApplicationCssSelectorVersion(); + } + } + + /** + * @internal – emit a NativeWindow lifecycle event. + */ + _notifyEvent(eventName: NativeWindowEventName): void { + this.notify({ + eventName, + window: this, + object: this, + }); + } + + /** + * @internal – called when the window is being torn down. + */ + _destroy(): void { + this._notifyEvent(NativeWindowEvents.close); + if (this._rootView) { + this._rootView._onRootViewReset(); + this._rootView = null; + } + } +} diff --git a/packages/core/native-window/native-window-interfaces.ts b/packages/core/native-window/native-window-interfaces.ts new file mode 100644 index 0000000000..cd8ce89d74 --- /dev/null +++ b/packages/core/native-window/native-window-interfaces.ts @@ -0,0 +1,148 @@ +import type { EventData } from '../data/observable'; +import type { View } from '../ui/core/view'; +import type { NavigationEntry } from '../ui/frame/frame-interfaces'; +import type { CoreTypes } from '../core-types'; + +/** + * Events emitted by a NativeWindow instance. + */ +export const NativeWindowEvents = { + /** Fired when the window becomes the active/focused window. */ + activate: 'activate', + /** Fired when the window loses focus. */ + deactivate: 'deactivate', + /** Fired when the window enters the background. */ + background: 'background', + /** Fired when the window enters the foreground. */ + foreground: 'foreground', + /** Fired when the window is being closed/destroyed. */ + close: 'close', + /** Fired after the window content has been displayed for the first time. */ + displayed: 'displayed', + /** Fired when the root view content is set or changed. */ + contentLoaded: 'contentLoaded', +} as const; + +export type NativeWindowEventName = (typeof NativeWindowEvents)[keyof typeof NativeWindowEvents]; + +/** + * Application-level events related to window management. + */ +export const WindowEvents = { + /** Fired on Application when a new NativeWindow is created. */ + windowOpen: 'windowOpen', + /** Fired on Application when a NativeWindow is closed/destroyed. */ + windowClose: 'windowClose', +} as const; + +/** + * Base event data for NativeWindow events. + */ +export interface NativeWindowEventData extends EventData { + /** The NativeWindow that emitted the event. */ + window: INativeWindow; +} + +/** + * Event data fired on Application when a window opens. + */ +export interface WindowOpenEventData extends EventData { + /** The NativeWindow that was opened. */ + window: INativeWindow; +} + +/** + * Event data fired on Application when a window closes. + */ +export interface WindowCloseEventData extends EventData { + /** The NativeWindow that was closed. */ + window: INativeWindow; +} + +/** + * Cross-platform NativeWindow interface. + * + * A NativeWindow represents a platform window surface: + * - iOS: UIWindowScene + UIWindow + * - Android: Activity + * + * Each NativeWindow manages its own root view, lifecycle events, + * and platform-specific accessors. + */ +export interface INativeWindow { + /** + * A stable identifier for this window. + * On iOS: derived from the UISceneSession persistentIdentifier. + * On Android: derived from the Activity hashCode. + */ + readonly id: string; + + /** + * Whether this is the primary (main) window. + * The first window created is typically the primary window. + * Application-level lifecycle events are bridged from the primary window. + */ + readonly isPrimary: boolean; + + /** + * The current root view of this window. + */ + readonly rootView: View; + + /** + * Set the content of this window. + * @param content A View instance, a NavigationEntry, or a module name string. + */ + setContent(content: View | NavigationEntry | string): void; + + /** + * Close this window. + * The primary window cannot be closed. + * + * iOS: requests scene session destruction. + * Android: finishes the activity. + */ + close(): void; + + /** + * The current orientation of this window. + */ + orientation(): 'portrait' | 'landscape' | 'unknown'; + + /** + * The current system appearance (light/dark) for this window. + */ + systemAppearance(): 'light' | 'dark' | null; + + /** + * The current layout direction for this window. + */ + layoutDirection(): CoreTypes.LayoutDirectionType | null; + + /** + * iOS-specific accessors. Only available when running on iOS. + */ + readonly iosWindow?: { + readonly scene: UIWindowScene; + readonly window: UIWindow; + }; + + /** + * Android-specific accessors. Only available when running on Android. + */ + readonly androidWindow?: { + readonly activity: androidx.appcompat.app.AppCompatActivity; + }; +} + +/** + * Options for opening a new window. + */ +export interface WindowOpenOptions { + /** + * Data to pass to the new window. + * On iOS: serialized into NSUserActivity.userInfo. + * On Android: added as intent extras. + */ + data?: Record; +} diff --git a/packages/core/native-window/native-window.android.ts b/packages/core/native-window/native-window.android.ts new file mode 100644 index 0000000000..8676a843bd --- /dev/null +++ b/packages/core/native-window/native-window.android.ts @@ -0,0 +1,144 @@ +import type { View } from '../ui/core/view'; +import { CoreTypes } from '../core-types'; +import { SDK_VERSION } from '../utils/constants'; +import { AndroidActivityCallbacks, NavigationEntry } from '../ui/frame/frame-common'; +import { NativeWindowCommon } from './native-window-common'; +import type { INativeWindow } from './native-window-interfaces'; + +/** + * Android implementation of NativeWindow. + * Wraps an AppCompatActivity. + */ +export class NativeWindow extends NativeWindowCommon { + private _activity: WeakRef; + + constructor(activity: androidx.appcompat.app.AppCompatActivity, id: string, isPrimary = false) { + super(id, isPrimary); + this._activity = new WeakRef(activity); + } + + /** + * The wrapped Android Activity (may be GC'd). + */ + get activity(): androidx.appcompat.app.AppCompatActivity | undefined { + return this._activity?.deref(); + } + + get androidWindow(): INativeWindow['androidWindow'] { + const activity = this.activity; + if (!activity) { + return undefined; + } + return { activity }; + } + + /** + * Platform-specific: apply the view as root content of this Activity. + */ + protected _setNativeContent(view: View): void { + const activity = this.activity; + if (!activity) { + throw new Error('NativeWindow: Activity is no longer available.'); + } + + const callbacks: AndroidActivityCallbacks = (activity as any)['_callbacks']; + if (!callbacks) { + throw new Error('NativeWindow: Cannot find activity callbacks.'); + } + callbacks.resetActivityContent(activity); + } + + /** + * Close this window by finishing the activity. + */ + close(): void { + if (this.isPrimary) { + console.log('NativeWindow: Cannot close the primary window.'); + return; + } + + const activity = this.activity; + if (activity) { + activity.finish(); + } + } + + // --- Platform getters --- + + protected _getOrientation(): 'portrait' | 'landscape' | 'unknown' { + const activity = this.activity; + if (!activity) { + return 'unknown'; + } + const configuration = activity.getResources().getConfiguration(); + return this._getOrientationValue(configuration); + } + + protected _getSystemAppearance(): 'light' | 'dark' | null { + const activity = this.activity; + if (!activity) { + return null; + } + const configuration = activity.getResources().getConfiguration(); + return this._getSystemAppearanceValue(configuration); + } + + protected _getLayoutDirection(): CoreTypes.LayoutDirectionType | null { + const activity = this.activity; + if (!activity) { + return null; + } + const configuration = activity.getResources().getConfiguration(); + return this._getLayoutDirectionValue(configuration); + } + + // --- Value converters --- + + _getOrientationValue(configuration: android.content.res.Configuration): 'portrait' | 'landscape' | 'unknown' { + switch (configuration.orientation) { + case android.content.res.Configuration.ORIENTATION_LANDSCAPE: + return 'landscape'; + case android.content.res.Configuration.ORIENTATION_PORTRAIT: + return 'portrait'; + default: + return 'unknown'; + } + } + + _getSystemAppearanceValue(configuration: android.content.res.Configuration): 'dark' | 'light' { + const mode = configuration.uiMode & android.content.res.Configuration.UI_MODE_NIGHT_MASK; + switch (mode) { + case android.content.res.Configuration.UI_MODE_NIGHT_YES: + return 'dark'; + case android.content.res.Configuration.UI_MODE_NIGHT_NO: + case android.content.res.Configuration.UI_MODE_NIGHT_UNDEFINED: + default: + return 'light'; + } + } + + _getLayoutDirectionValue(configuration: android.content.res.Configuration): CoreTypes.LayoutDirectionType { + switch (configuration.getLayoutDirection()) { + case android.view.View.LAYOUT_DIRECTION_RTL: + return CoreTypes.LayoutDirection.rtl; + case android.view.View.LAYOUT_DIRECTION_LTR: + default: + return CoreTypes.LayoutDirection.ltr; + } + } + + /** + * @internal + */ + _destroy(): void { + super._destroy(); + this._activity = null; + } + + /** + * Gets a stable identifier from an Activity. + */ + static getActivityId(activity: androidx.appcompat.app.AppCompatActivity): string { + return `activity-${activity.hashCode()}`; + } +} diff --git a/packages/core/native-window/native-window.d.ts b/packages/core/native-window/native-window.d.ts new file mode 100644 index 0000000000..a089657564 --- /dev/null +++ b/packages/core/native-window/native-window.d.ts @@ -0,0 +1,33 @@ +import { NativeWindowCommon } from './native-window-common'; +import type { INativeWindow, NativeWindowEventData } from './native-window-interfaces'; + +export { NativeWindowCommon } from './native-window-common'; +export * from './native-window-interfaces'; + +/** + * Cross-platform NativeWindow class. + * + * On iOS: wraps a UIWindowScene + UIWindow. + * On Android: wraps an AppCompatActivity. + * + * Use `Application.primaryWindow` to get the main window, + * or `Application.getWindows()` to get all active windows. + */ +export class NativeWindow extends NativeWindowCommon implements INativeWindow { + readonly iosWindow: + | { + readonly scene: UIWindowScene; + readonly window: UIWindow; + } + | undefined; + + readonly androidWindow: + | { + readonly activity: androidx.appcompat.app.AppCompatActivity; + } + | undefined; + // Event methods (inherited from Observable) + on(eventName: string, callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + once(eventName: string, callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + off(eventName: string, callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; +} diff --git a/packages/core/native-window/native-window.ios.ts b/packages/core/native-window/native-window.ios.ts new file mode 100644 index 0000000000..f97709729a --- /dev/null +++ b/packages/core/native-window/native-window.ios.ts @@ -0,0 +1,216 @@ +import type { View } from '../ui/core/view'; +import { IOSHelper } from '../ui/core/view/view-helper'; +import { SDK_VERSION } from '../utils/constants'; +import { CoreTypes } from '../core-types'; +import { NativeWindowCommon } from './native-window-common'; +import { NativeWindowEvents } from './native-window-interfaces'; +import type { INativeWindow } from './native-window-interfaces'; + +/** + * iOS implementation of NativeWindow. + * Wraps a UIWindowScene + UIWindow pair. + */ +export class NativeWindow extends NativeWindowCommon { + private _scene: UIWindowScene; + private _window: UIWindow; + + constructor(scene: UIWindowScene, window: UIWindow, id: string, isPrimary = false) { + super(id, isPrimary); + this._scene = scene; + this._window = window; + } + + get iosWindow(): INativeWindow['iosWindow'] { + return { + scene: this._scene, + window: this._window, + }; + } + + /** + * Platform-specific: set the view as root content of this UIWindow. + */ + protected _setNativeContent(view: View): void { + const controller = this._getViewController(view); + this._setViewControllerView(view); + + const haveController = this._window.rootViewController !== null; + this._window.rootViewController = controller; + + if (!haveController) { + this._window.makeKeyAndVisible(); + } + + // Listen for trait collection changes per-window + view.on(IOSHelper.traitCollectionColorAppearanceChangedEvent, () => { + const userInterfaceStyle = controller.traitCollection.userInterfaceStyle; + this._setSystemAppearance(this._getSystemAppearanceValue(userInterfaceStyle)); + }); + + view.on(IOSHelper.traitCollectionLayoutDirectionChangedEvent, () => { + const layoutDirection = controller.traitCollection.layoutDirection; + this._setLayoutDirection(this._getLayoutDirectionValue(layoutDirection)); + }); + } + + /** + * Close this window/scene. + */ + close(): void { + if (this.isPrimary) { + console.log('NativeWindow: Cannot close the primary window.'); + return; + } + + const session = this._scene?.session; + if (!session) { + console.log('NativeWindow: Scene has no session to destroy.'); + return; + } + + const app = UIApplication.sharedApplication; + if (app.requestSceneSessionDestructionOptionsErrorHandler) { + app.requestSceneSessionDestructionOptionsErrorHandler(session, null, (error: NSError) => { + if (error) { + console.log('NativeWindow: Error destroying scene session:', error.localizedDescription); + } + }); + } else { + console.log('NativeWindow: Scene destruction API not available on this iOS version.'); + } + } + + // --- Platform getters --- + + protected _getOrientation(): 'portrait' | 'landscape' | 'unknown' { + if (__VISIONOS__) { + return this._getOrientationValue(NativeScriptEmbedder.sharedInstance().windowScene?.interfaceOrientation); + } + if (this._scene) { + return this._getOrientationValue(this._scene.interfaceOrientation); + } + return this._getOrientationValue(UIApplication.sharedApplication.statusBarOrientation); + } + + protected _getSystemAppearance(): 'light' | 'dark' | null { + if (!__VISIONOS__ && SDK_VERSION <= 11) { + return null; + } + const rootVC = this._window?.rootViewController; + if (!rootVC) { + return null; + } + return this._getSystemAppearanceValue(rootVC.traitCollection.userInterfaceStyle); + } + + protected _getLayoutDirection(): CoreTypes.LayoutDirectionType | null { + const rootVC = this._window?.rootViewController; + if (!rootVC) { + return null; + } + return this._getLayoutDirectionValue(rootVC.traitCollection.layoutDirection); + } + + // --- Value converters --- + + private _getOrientationValue(orientation: number): 'portrait' | 'landscape' | 'unknown' { + switch (orientation) { + case UIInterfaceOrientation.LandscapeRight: + case UIInterfaceOrientation.LandscapeLeft: + return 'landscape'; + case UIInterfaceOrientation.PortraitUpsideDown: + case UIInterfaceOrientation.Portrait: + return 'portrait'; + case UIInterfaceOrientation.Unknown: + default: + return 'unknown'; + } + } + + _getSystemAppearanceValue(userInterfaceStyle: number): 'dark' | 'light' { + switch (userInterfaceStyle) { + case UIUserInterfaceStyle.Dark: + return 'dark'; + case UIUserInterfaceStyle.Light: + case UIUserInterfaceStyle.Unspecified: + default: + return 'light'; + } + } + + _getLayoutDirectionValue(layoutDirection: number): CoreTypes.LayoutDirectionType { + switch (layoutDirection) { + case UITraitEnvironmentLayoutDirection.RightToLeft: + return CoreTypes.LayoutDirection.rtl; + case UITraitEnvironmentLayoutDirection.LeftToRight: + default: + return CoreTypes.LayoutDirection.ltr; + } + } + + // --- ViewController helpers --- + + private _getViewController(rootView: View): UIViewController { + let viewController: UIViewController = rootView.viewController || rootView.ios; + + if (!(viewController instanceof UIViewController)) { + viewController = IOSHelper.UILayoutViewController.initWithOwner(new WeakRef(rootView)) as UIViewController; + rootView.viewController = viewController; + } + + return viewController; + } + + private _setViewControllerView(view: View): void { + const viewController: UIViewController = view.viewController || view.ios; + const nativeView = view.ios || view.nativeViewProtected; + + if (!nativeView || !viewController) { + throw new Error('Root should be either UIViewController or UIView'); + } + + if (viewController instanceof IOSHelper.UILayoutViewController) { + viewController.view.addSubview(nativeView); + } + } + + /** + * @internal + */ + _destroy(): void { + // Remove trait collection listeners from root view before destroying + if (this._rootView) { + this._rootView.off(IOSHelper.traitCollectionColorAppearanceChangedEvent); + this._rootView.off(IOSHelper.traitCollectionLayoutDirectionChangedEvent); + } + super._destroy(); + this._scene = null; + this._window = null; + } + + /** + * Gets the stable scene identifier. + */ + static getSceneId(scene: UIWindowScene): string { + try { + if (!scene) { + return 'unknown'; + } + const session = scene.session; + const persistentId = session?.persistentIdentifier; + if (persistentId) { + return `${persistentId}`; + } + if (scene.hash != null) { + return `${scene.hash}`; + } + const desc = scene.description; + if (desc) { + return `${desc}`; + } + } catch { + // ignore + } + return 'unknown'; + } +} From 70f6b8f92370c8f27487a84bcb61926ea7b2eb33 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 13 Apr 2026 15:56:00 -0300 Subject: [PATCH 02/23] refactor: Update NativeWindow implementation and lifecycle event handling - Refactored NativeWindow to separate platform-specific implementations for Android and iOS. - Introduced AndroidNativeWindow and IOSNativeWindow classes extending a common NativeWindow base class. - Updated lifecycle event notifications to emit events directly from NativeWindow instances instead of the Application class. - Deprecated direct event listeners on the Application class in favor of listening on NativeWindow instances. - Removed redundant index files for native-window on both Android and iOS platforms. - Enhanced type definitions for NativeWindow events and interfaces to improve clarity and maintainability. --- apps/toolbox/src/pages/multiple-scenes.ts | 144 ++++++++++++------ .../core/application/application-common.ts | 14 ++ .../core/application/application.android.ts | 80 ++++++++-- packages/core/application/application.d.ts | 135 ++++++++++++++-- packages/core/application/application.ios.ts | 88 ++++++++--- packages/core/native-window/index.android.ts | 3 - packages/core/native-window/index.ios.ts | 3 - .../native-window/{index.d.ts => index.ts} | 0 .../native-window/native-window-common.ts | 42 ++++- .../native-window/native-window-interfaces.ts | 124 +++++---------- .../native-window/native-window.android.ts | 7 +- .../core/native-window/native-window.d.ts | 33 ---- .../core/native-window/native-window.ios.ts | 7 +- packages/core/ui/frame/index.android.ts | 102 +++++++++++-- 14 files changed, 537 insertions(+), 245 deletions(-) delete mode 100644 packages/core/native-window/index.android.ts delete mode 100644 packages/core/native-window/index.ios.ts rename packages/core/native-window/{index.d.ts => index.ts} (100%) delete mode 100644 packages/core/native-window/native-window.d.ts diff --git a/apps/toolbox/src/pages/multiple-scenes.ts b/apps/toolbox/src/pages/multiple-scenes.ts index 916c6449b4..34718a69d2 100644 --- a/apps/toolbox/src/pages/multiple-scenes.ts +++ b/apps/toolbox/src/pages/multiple-scenes.ts @@ -1,4 +1,4 @@ -import { Observable, EventData, Page, Application, Frame, StackLayout, Label, Button, Dialogs, View, Color, SceneEvents, SceneEventData, Utils } from '@nativescript/core'; +import { Observable, EventData, Page, Application, StackLayout, Label, Button, Dialogs, View, Color, NativeWindowEvents, SceneEventData, Utils, WindowEvents, WindowOpenEventData, WindowCloseEventData, NativeWindow } from '@nativescript/core'; let page: Page; let viewModel: MultipleScenesModel; @@ -9,12 +9,21 @@ export function navigatingTo(args: EventData) { page.bindingContext = viewModel; } +export function navigatingFrom(args: EventData) { + if (viewModel) { + viewModel.destroy(); + viewModel = undefined; + } +} + export class MultipleScenesModel extends Observable { private _sceneCount = 0; private _isMultiSceneSupported = false; - private _currentScenes: any[] = []; private _currentWindows: any[] = []; private _sceneEvents: string[] = []; + private _windowOpenHandler: (args: WindowOpenEventData) => void; + private _windowCloseHandler: (args: WindowCloseEventData) => void; + private _sceneEventHandlers: Map void> = new Map(); constructor() { super(); @@ -32,10 +41,6 @@ export class MultipleScenesModel extends Observable { return this._isMultiSceneSupported; } - get currentScenes(): any[] { - return this._currentScenes; - } - get currentWindows(): any[] { return this._currentWindows; } @@ -105,39 +110,84 @@ export class MultipleScenesModel extends Observable { private setupSceneEventListeners() { if (!__APPLE__) return; - // Listen to all scene lifecycle events - Application.on(SceneEvents.sceneWillConnect, (args: SceneEventData) => { - this.addSceneEvent(`Scene Will Connect: ${this.getSceneDescription(args.scene)}`); + // Listen for window open/close on Application + this._windowOpenHandler = (args: WindowOpenEventData) => { + const nativeWindow = args.window; + if (!nativeWindow) return; + this.addSceneEvent(`Window opened: ${nativeWindow.id}`); + this.registerNativeWindowListeners(nativeWindow); this.updateSceneInfo(); - }); - - Application.on(SceneEvents.sceneDidActivate, (args: SceneEventData) => { - this.addSceneEvent(`Scene Did Activate: ${this.getSceneDescription(args.scene)}`); + }; + this._windowCloseHandler = (args: WindowCloseEventData) => { + const nativeWindow = args.window; + if (!nativeWindow) return; + this.addSceneEvent(`Window closed: ${nativeWindow.id}`); this.updateSceneInfo(); - }); + }; + Application.ios.on(WindowEvents.windowOpen, this._windowOpenHandler); + Application.ios.on(WindowEvents.windowClose, this._windowCloseHandler); - Application.on(SceneEvents.sceneWillResignActive, (args: SceneEventData) => { - this.addSceneEvent(`Scene Will Resign Active: ${this.getSceneDescription(args.scene)}`); - }); + // Register listeners on existing windows + for (const nativeWindow of Application.ios.getWindows()) { + this.registerNativeWindowListeners(nativeWindow); + } + } - Application.on(SceneEvents.sceneWillEnterForeground, (args: SceneEventData) => { - this.addSceneEvent(`Scene Will Enter Foreground: ${this.getSceneDescription(args.scene)}`); - }); + private registerNativeWindowListeners(nativeWindow: NativeWindow) { + const events = [ + { name: NativeWindowEvents.sceneWillConnect, label: 'Scene Will Connect' }, + { name: NativeWindowEvents.sceneDidActivate, label: 'Scene Did Activate' }, + { name: NativeWindowEvents.sceneWillResignActive, label: 'Scene Will Resign Active' }, + { name: NativeWindowEvents.sceneWillEnterForeground, label: 'Scene Will Enter Foreground' }, + { name: NativeWindowEvents.sceneDidEnterBackground, label: 'Scene Did Enter Background' }, + { name: NativeWindowEvents.sceneDidDisconnect, label: 'Scene Did Disconnect' }, + ]; + + for (const event of events) { + const handler = (args: SceneEventData) => { + this.addSceneEvent(`${event.label}: Window ${nativeWindow.id}`); + this.updateSceneInfo(); + + // Set up content for new scenes when they connect + if (event.name === NativeWindowEvents.sceneWillConnect) { + this.setupSceneContent(nativeWindow, args); + } + }; + const handlerKey = `${nativeWindow.id}:${event.name}`; + this._sceneEventHandlers.set(handlerKey, handler); + nativeWindow.on(event.name, handler as any); + } + } - Application.on(SceneEvents.sceneDidEnterBackground, (args: SceneEventData) => { - this.addSceneEvent(`Scene Did Enter Background: ${this.getSceneDescription(args.scene)}`); - }); + private unregisterNativeWindowListeners(nativeWindow: NativeWindow) { + const events = [NativeWindowEvents.sceneWillConnect, NativeWindowEvents.sceneDidActivate, NativeWindowEvents.sceneWillResignActive, NativeWindowEvents.sceneWillEnterForeground, NativeWindowEvents.sceneDidEnterBackground, NativeWindowEvents.sceneDidDisconnect]; - Application.on(SceneEvents.sceneDidDisconnect, (args: SceneEventData) => { - this.addSceneEvent(`Scene Did Disconnect: ${this.getSceneDescription(args.scene)}`); - this.updateSceneInfo(); - }); + for (const eventName of events) { + const handlerKey = `${nativeWindow.id}:${eventName}`; + const handler = this._sceneEventHandlers.get(handlerKey); + if (handler) { + nativeWindow.off(eventName, handler); + this._sceneEventHandlers.delete(handlerKey); + } + } + } - // Listen for scene content setup events to provide content for new scenes - Application.on(SceneEvents.sceneContentSetup, (args: SceneEventData) => { - this.addSceneEvent(`Setting up content for new scene: ${this.getSceneDescription(args.scene)}`); - this.setupSceneContent(args); - }); + destroy() { + if (!__APPLE__) return; + + // Unregister window open/close listeners + if (this._windowOpenHandler) { + Application.ios.off(WindowEvents.windowOpen, this._windowOpenHandler); + } + if (this._windowCloseHandler) { + Application.ios.off(WindowEvents.windowClose, this._windowCloseHandler); + } + + // Unregister all NativeWindow listeners + for (const nativeWindow of Application.ios.getWindows()) { + this.unregisterNativeWindowListeners(nativeWindow); + } + this._sceneEventHandlers.clear(); } private getSceneDescription(scene: UIWindowScene): string { @@ -149,9 +199,12 @@ export class MultipleScenesModel extends Observable { return scene?.hash ? `${scene?.hash}` : scene?.description || 'Unknown'; } - private setupSceneContent(args: SceneEventData) { + private setupSceneContent(nativeWindow: NativeWindow, args: SceneEventData) { if (!args.scene || !args.window || !__APPLE__) return; + // Skip the primary scene (it already has content) + if (nativeWindow === Application.ios.primaryWindow) return; + try { let nsViewId: string; if (args.connectionOptions?.userActivities?.count > 0) { @@ -170,9 +223,11 @@ export class MultipleScenesModel extends Observable { // Note: can implement any number of other scene views } - console.log('setWindowRootView for:', args.window); - Application.ios.setWindowRootView(args.window, page); - this.addSceneEvent(`Content successfully set for scene: ${this.getSceneDescription(args.scene)}`); + if (page) { + console.log('setContent for window:', nativeWindow.id); + nativeWindow.setContent(page); + this.addSceneEvent(`Content successfully set for window: ${nativeWindow.id}`); + } } catch (error) { this.addSceneEvent(`Error setting up scene content: ${error.message}`); } @@ -232,8 +287,6 @@ export class MultipleScenesModel extends Observable { this._closeButtons.set(sceneId, closeButton); layout.addChild(closeButton); - // Set up the layout as a root view (this creates the native iOS view) - page._setupAsRootView({}); return page; } @@ -283,8 +336,6 @@ export class MultipleScenesModel extends Observable { this._closeButtons.set(sceneId, closeButton); layout.addChild(closeButton); - // Set up the layout as a root view (this creates the native iOS view) - page._setupAsRootView({}); return page; } @@ -334,25 +385,20 @@ export class MultipleScenesModel extends Observable { private updateSceneInfo() { if (__APPLE__ && this._isMultiSceneSupported) { try { - this._currentScenes = Application.ios.getAllScenes() || []; - - this._currentWindows = Application.ios.getAllWindows() || []; - - this._sceneCount = this._currentScenes.length; + const windows = Application.ios.getWindows() || []; + this._currentWindows = windows; + this._sceneCount = windows.length; } catch (error) { console.log('Error getting scene info:', error); this._sceneCount = 0; - this._currentScenes = []; this._currentWindows = []; } } else { this._sceneCount = 1; // Traditional single window - this._currentScenes = []; this._currentWindows = []; } this.notifyPropertyChange('sceneCount', this._sceneCount); - this.notifyPropertyChange('currentScenes', this._currentScenes); this.notifyPropertyChange('currentWindows', this._currentWindows); this.notifyPropertyChange('statusText', this.statusText); } @@ -418,7 +464,7 @@ export class MultipleScenesModel extends Observable { this.addSceneEvent(`API Test: ${apiInfo}`); // Also log current scene/window counts - this.addSceneEvent(`Current state: ${this._currentScenes.length} scenes, ${this._currentWindows.length} windows`); + this.addSceneEvent(`Current state: ${this._currentWindows.length} windows`); // Add device and system info try { diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index b9c474c82e..591da837ee 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -16,6 +16,7 @@ import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessib import { getAppMainEntry, isAppInBackground, setAppInBackground, setAppMainEntry } from './helpers-common'; import { getNativeScriptGlobals } from '../globals/global-utils'; import { SDK_VERSION } from '../utils/constants'; +import type { WindowCloseEventData, WindowOpenEventData } from '../native-window'; // prettier-ignore const ORIENTATION_CSS_CLASSES = [ @@ -39,13 +40,23 @@ const LAYOUT_DIRECTION_CSS_CLASSES = [ const globalEvents = getNativeScriptGlobals().events; // Scene lifecycle event names +/** + * @deprecated Use `NativeWindowEvents` from `@nativescript/core/native-window` instead. + */ export const SceneEvents = { + /** @deprecated Use `NativeWindowEvents.sceneWillConnect` instead. */ sceneWillConnect: 'sceneWillConnect', + /** @deprecated Use `NativeWindowEvents.sceneDidActivate` instead. */ sceneDidActivate: 'sceneDidActivate', + /** @deprecated Use `NativeWindowEvents.sceneWillResignActive` instead. */ sceneWillResignActive: 'sceneWillResignActive', + /** @deprecated Use `NativeWindowEvents.sceneWillEnterForeground` instead. */ sceneWillEnterForeground: 'sceneWillEnterForeground', + /** @deprecated Use `NativeWindowEvents.sceneDidEnterBackground` instead. */ sceneDidEnterBackground: 'sceneDidEnterBackground', + /** @deprecated Use `NativeWindowEvents.sceneDidDisconnect` instead. */ sceneDidDisconnect: 'sceneDidDisconnect', + /** @deprecated Use `NativeWindowEvents.sceneContentSetup` instead. */ sceneContentSetup: 'sceneContentSetup', }; @@ -129,6 +140,9 @@ interface ApplicationEvents { on(event: 'layoutDirectionChanged', callback: (args: LayoutDirectionChangedEventData) => void, thisArg?: any): void; on(event: 'fontScaleChanged', callback: (args: FontScaleChangedEventData) => void, thisArg?: any): void; + + on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; + on(event: 'windowClose', callback: (args: WindowCloseEventData) => void, thisArg?: any): void; } export class ApplicationCommon { diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index a60b7f5484..f2ad0054b4 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -8,7 +8,8 @@ import { ApplicationCommon } from './application-common'; import type { AndroidActivityBackPressedEventData, AndroidActivityBundleEventData, AndroidActivityEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, AndroidActivityResultEventData, ApplicationEventData } from './application-interfaces'; import { Observable } from '../data/observable'; import { Trace } from '../trace'; -import { NativeWindow } from '../native-window/native-window.android'; +import { AndroidNativeWindow } from '../native-window/native-window.android'; +import { NativeWindow } from '../native-window/native-window-common'; import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; import { CommonA11YServiceEnabledObservable, @@ -82,11 +83,11 @@ function initNativeScriptLifecycleCallbacks() { // Create and register NativeWindow for this activity const isPrimary = Application.android._getWindows().length === 0; - const nativeWindowId = NativeWindow.getActivityId(activity); - const nativeWindow = new NativeWindow(activity, nativeWindowId, isPrimary); + const nativeWindowId = AndroidNativeWindow.getActivityId(activity); + const nativeWindow = new AndroidNativeWindow(activity, nativeWindowId, isPrimary); Application.android._registerWindow(nativeWindow); - this.notifyActivityCreated(activity, savedInstanceState); + this.notifyActivityCreated(activity, savedInstanceState, nativeWindow); if (Application.hasListeners(Application.displayedEvent)) { this.subscribeForGlobalLayout(activity); @@ -117,9 +118,16 @@ function initNativeScriptLifecycleCallbacks() { const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.close); + // Emit activityDestroyed on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.activityDestroyed, + object: nativeWindow, + activity, + } as AndroidActivityEventData); Application.android._unregisterWindow(nativeWindow); } + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityDestroyedEvent, object: Application.android, @@ -144,8 +152,15 @@ function initNativeScriptLifecycleCallbacks() { const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.deactivate); + // Emit activityPaused on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.activityPaused, + object: nativeWindow, + activity, + } as AndroidActivityEventData); } + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityPausedEvent, object: Application.android, @@ -161,11 +176,18 @@ function initNativeScriptLifecycleCallbacks() { const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.activate); + // Emit activityResumed on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.activityResumed, + object: nativeWindow, + activity, + } as AndroidActivityEventData); } // NOTE: setSuspended(false) is called in frame/index.android.ts inside onPostResume // This is done to ensure proper timing for the event to be raised + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityResumedEvent, object: Application.android, @@ -177,6 +199,18 @@ function initNativeScriptLifecycleCallbacks() { public onActivitySaveInstanceState(activity: androidx.appcompat.app.AppCompatActivity, bundle: android.os.Bundle): void { // console.log('NativeScriptLifecycleCallbacks onActivitySaveInstanceState'); + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + eventName: NativeWindowEvents.saveActivityState, + object: nativeWindow, + activity, + bundle, + } as AndroidActivityBundleEventData); + } + + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.saveActivityStateEvent, object: Application.android, @@ -201,8 +235,15 @@ function initNativeScriptLifecycleCallbacks() { const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.foreground); + // Emit activityStarted on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.activityStarted, + object: nativeWindow, + activity, + } as AndroidActivityEventData); } + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityStartedEvent, object: Application.android, @@ -225,8 +266,15 @@ function initNativeScriptLifecycleCallbacks() { const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.background); + // Emit activityStopped on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.activityStopped, + object: nativeWindow, + activity, + } as AndroidActivityEventData); } + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityStoppedEvent, object: Application.android, @@ -247,7 +295,17 @@ function initNativeScriptLifecycleCallbacks() { } @profile - notifyActivityCreated(activity: androidx.appcompat.app.AppCompatActivity, bundle: android.os.Bundle) { + notifyActivityCreated(activity: androidx.appcompat.app.AppCompatActivity, bundle: android.os.Bundle, nativeWindow?: NativeWindow) { + // Emit on NativeWindow first + if (nativeWindow) { + nativeWindow.notify({ + eventName: NativeWindowEvents.activityCreated, + object: nativeWindow, + activity, + bundle, + } as AndroidActivityBundleEventData); + } + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityCreatedEvent, object: Application.android, @@ -571,14 +629,14 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp } // --- NativeWindow registry --- - private _windows: NativeWindow[] = []; + private _windows: AndroidNativeWindow[] = []; /** * @internal - Register a NativeWindow created by the lifecycle callbacks. */ - _registerWindow(nativeWindow: NativeWindow): void { + _registerWindow(nativeWindow: AndroidNativeWindow): void { this._windows.push(nativeWindow); - this.notify({ + this.notify({ eventName: WindowEvents.windowOpen, object: this, window: nativeWindow, @@ -588,12 +646,12 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp /** * @internal - Unregister a NativeWindow when its activity is destroyed. */ - _unregisterWindow(nativeWindow: NativeWindow): void { + _unregisterWindow(nativeWindow: AndroidNativeWindow): void { const idx = this._windows.indexOf(nativeWindow); if (idx >= 0) { this._windows.splice(idx, 1); } - this.notify({ + this.notify({ eventName: WindowEvents.windowClose, object: this, window: nativeWindow, @@ -616,7 +674,7 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp /** * @internal - Get a NativeWindow by its activity. */ - _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): NativeWindow | undefined { + _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): AndroidNativeWindow | undefined { return this._windows.find((nw) => nw.activity === activity); } diff --git a/packages/core/application/application.d.ts b/packages/core/application/application.d.ts index f3be8c5639..6c59b93e33 100644 --- a/packages/core/application/application.d.ts +++ b/packages/core/application/application.d.ts @@ -1,6 +1,7 @@ import { ApplicationCommon } from './application-common'; import { FontScaleCategory } from '../accessibility/font-scale-common'; -import type { NativeWindowCommon } from '../native-window/native-window-common'; +import type { NativeWindow } from '../native-window/native-window-common'; +import type { WindowOpenEventData, WindowCloseEventData } from '../native-window/native-window-interfaces'; export * from './application-common'; export * from './application-interfaces'; @@ -9,60 +10,93 @@ export const Application: ApplicationCommon; export class AndroidApplication extends ApplicationCommon { /** - * @deprecated Use `Application.android.activityCreatedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityCreatedEvent = 'activityCreated'; /** - * @deprecated Use `Application.android.activityDestroyedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityDestroyedEvent = 'activityDestroyed'; /** - * @deprecated Use `Application.android.activityStartedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityStartedEvent = 'activityStarted'; /** - * @deprecated Use `Application.android.activityPausedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityPausedEvent = 'activityPaused'; /** - * @deprecated Use `Application.android.activityResumedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityResumedEvent = 'activityResumed'; /** - * @deprecated Use `Application.android.activityStoppedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityStoppedEvent = 'activityStopped'; /** - * @deprecated Use `Application.android.saveActivityStateEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly saveActivityStateEvent = 'saveActivityState'; /** - * @deprecated Use `Application.android.activityResultEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityResultEvent = 'activityResult'; /** - * @deprecated Use `Application.android.activityBackPressedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityBackPressedEvent = 'activityBackPressed'; /** - * @deprecated Use `Application.android.activityNewIntentEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityNewIntentEvent = 'activityNewIntent'; /** - * @deprecated Use `Application.android.activityRequestPermissionsEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityRequestPermissionsEvent = 'activityRequestPermissions'; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityCreatedEvent = AndroidApplication.activityCreatedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityDestroyedEvent = AndroidApplication.activityDestroyedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityStartedEvent = AndroidApplication.activityStartedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityPausedEvent = AndroidApplication.activityPausedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityResumedEvent = AndroidApplication.activityResumedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityStoppedEvent = AndroidApplication.activityStoppedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly saveActivityStateEvent = AndroidApplication.saveActivityStateEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityResultEvent = AndroidApplication.activityResultEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityBackPressedEvent = AndroidApplication.activityBackPressedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityNewIntentEvent = AndroidApplication.activityNewIntentEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityRequestPermissionsEvent = AndroidApplication.activityRequestPermissionsEvent; getNativeApplication(): android.app.Application; @@ -137,27 +171,68 @@ export class AndroidApplication extends ApplicationCommon { */ getRegisteredBroadcastReceivers(intentFilter: string): android.content.BroadcastReceiver[]; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; + on(event: 'windowClose', callback: (args: WindowCloseEventData) => void, thisArg?: any): void; + + /** + * @internal - Get a NativeWindow by its activity. + */ + _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): NativeWindow | undefined; + /** * Get the primary NativeWindow. */ - get primaryWindow(): NativeWindowCommon | undefined; + get primaryWindow(): NativeWindow | undefined; /** * Get all active NativeWindows. */ - getWindows(): NativeWindowCommon[]; + getWindows(): NativeWindow[]; } export class iOSApplication extends ApplicationCommon { @@ -306,15 +381,43 @@ export class iOSApplication extends ApplicationCommon { */ onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + + on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; + on(event: 'windowClose', callback: (args: WindowCloseEventData) => void, thisArg?: any): void; + /** * Get the primary NativeWindow. */ - get primaryWindow(): NativeWindowCommon | undefined; + get primaryWindow(): NativeWindow | undefined; /** * Get all active NativeWindows. */ - getWindows(): NativeWindowCommon[]; + getWindows(): NativeWindow[]; /** * Flag to be set when the launch event should be delayed until the application has become active. diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index 23d472fe83..b6daa3ee8f 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -6,12 +6,13 @@ import type { NavigationEntry } from '../ui/frame/frame-interfaces'; import { getWindow } from '../utils/native-helper'; import { SDK_VERSION } from '../utils/constants'; import { ios as iosUtils, dataSerialize } from '../utils/native-helper'; -import { ApplicationCommon, SceneEvents } from './application-common'; +import { ApplicationCommon } from './application-common'; import { ApplicationEventData, SceneEventData } from './application-interfaces'; import { Observable } from '../data/observable'; import type { iOSApplication as IiOSApplication } from './application'; import { Trace } from '../trace'; -import { NativeWindow } from '../native-window/native-window.ios'; +import { IOSNativeWindow } from '../native-window/native-window.ios'; +import { NativeWindow } from '../native-window/native-window-common'; import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; import { AccessibilityServiceEnabledPropName, @@ -235,10 +236,10 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { } const isPrimary = isFirstScene || !Application.ios.primaryWindow; - const nativeWindowId = NativeWindow.getSceneId(windowScene); + const nativeWindowId = IOSNativeWindow.getSceneId(windowScene); // Create NativeWindow and register it - const nativeWindow = new NativeWindow(windowScene, this._window, nativeWindowId, isPrimary); + const nativeWindow = new IOSNativeWindow(windowScene, this._window, nativeWindowId, isPrimary); Application.ios._registerWindow(nativeWindow); if (isPrimary) { @@ -246,9 +247,18 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { setiOSWindow(this._window); } - // Notify that scene will connect + // Notify on NativeWindow first + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneWillConnect, + object: nativeWindow, + scene: windowScene, + window: this._window, + connectionOptions: connectionOptions, + } as SceneEventData); + + // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ - eventName: SceneEvents.sceneWillConnect, + eventName: NativeWindowEvents.sceneWillConnect, object: Application.ios, scene: windowScene, window: this._window, @@ -273,8 +283,21 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.activate); + // Emit sceneDidActivate on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneDidActivate, + object: nativeWindow, + scene: scene, + } as SceneEventData); } + // @deprecated - Bridge to Application.ios for backward compat + Application.ios.notify({ + eventName: NativeWindowEvents.sceneDidActivate, + object: Application.ios, + scene: scene, + } as SceneEventData); + // If this is the primary scene, trigger traditional app lifecycle if (nativeWindow?.isPrimary) { const additionalData = { @@ -295,11 +318,17 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.deactivate); + // Emit sceneWillResignActive on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneWillResignActive, + object: nativeWindow, + scene: scene, + } as SceneEventData); } - // Notify that scene will resign active + // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ - eventName: SceneEvents.sceneWillResignActive, + eventName: NativeWindowEvents.sceneWillResignActive, object: Application.ios, scene: scene, } as SceneEventData); @@ -309,10 +338,17 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.foreground); + // Emit sceneWillEnterForeground on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneWillEnterForeground, + object: nativeWindow, + scene: scene, + } as SceneEventData); } + // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ - eventName: SceneEvents.sceneWillEnterForeground, + eventName: NativeWindowEvents.sceneWillEnterForeground, object: Application.ios, scene: scene, } as SceneEventData); @@ -322,10 +358,17 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.background); + // Emit sceneDidEnterBackground on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneDidEnterBackground, + object: nativeWindow, + scene: scene, + } as SceneEventData); } + // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ - eventName: SceneEvents.sceneDidEnterBackground, + eventName: NativeWindowEvents.sceneDidEnterBackground, object: Application.ios, scene: scene, } as SceneEventData); @@ -350,11 +393,18 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.close); + // Emit sceneDidDisconnect on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneDidDisconnect, + object: nativeWindow, + scene: scene, + } as SceneEventData); Application.ios._unregisterWindow(nativeWindow); } + // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ - eventName: SceneEvents.sceneDidDisconnect, + eventName: NativeWindowEvents.sceneDidDisconnect, object: Application.ios, scene: scene, } as SceneEventData); @@ -379,7 +429,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication _onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; // NativeWindow registry - private _windows: NativeWindow[] = []; + private _windows: IOSNativeWindow[] = []; private _notificationObservers: NotificationObserver[] = []; @@ -513,7 +563,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // The registry lives in JS and was lost with the previous isolate, so // the still-connected scene needs a fresh NativeWindow to be reachable. const isPrimary = !this.primaryWindow; - const nativeWindow = new NativeWindow(targetScene, window, NativeWindow.getSceneId(targetScene), isPrimary); + const nativeWindow = new IOSNativeWindow(targetScene, window, IOSNativeWindow.getSceneId(targetScene), isPrimary); this._registerWindow(nativeWindow); if (isPrimary) { @@ -1042,9 +1092,9 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication /** * @internal - Register a NativeWindow created by the SceneDelegate. */ - _registerWindow(nativeWindow: NativeWindow): void { + _registerWindow(nativeWindow: IOSNativeWindow): void { this._windows.push(nativeWindow); - this.notify({ + this.notify({ eventName: WindowEvents.windowOpen, object: this, window: nativeWindow, @@ -1054,12 +1104,12 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication /** * @internal - Unregister a NativeWindow when its scene disconnects. */ - _unregisterWindow(nativeWindow: NativeWindow): void { + _unregisterWindow(nativeWindow: IOSNativeWindow): void { const idx = this._windows.indexOf(nativeWindow); if (idx >= 0) { this._windows.splice(idx, 1); } - this.notify({ + this.notify({ eventName: WindowEvents.windowClose, object: this, window: nativeWindow, @@ -1086,7 +1136,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication /** * @internal - Get a NativeWindow by its scene. */ - _getWindowForScene(scene: UIWindowScene): NativeWindow | undefined { + _getWindowForScene(scene: UIWindowScene): IOSNativeWindow | undefined { return this._windows.find((nw) => nw.iosWindow?.scene === scene); } @@ -1352,7 +1402,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // Try matching among known scenes for (const nw of this._windows) { const scene = nw.iosWindow?.scene; - if (scene && NativeWindow.getSceneId(scene) === target) { + if (scene && IOSNativeWindow.getSceneId(scene) === target) { return scene; } } diff --git a/packages/core/native-window/index.android.ts b/packages/core/native-window/index.android.ts deleted file mode 100644 index 83ccbf211d..0000000000 --- a/packages/core/native-window/index.android.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './native-window-interfaces'; -export * from './native-window-common'; -export * from './native-window'; diff --git a/packages/core/native-window/index.ios.ts b/packages/core/native-window/index.ios.ts deleted file mode 100644 index 83ccbf211d..0000000000 --- a/packages/core/native-window/index.ios.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './native-window-interfaces'; -export * from './native-window-common'; -export * from './native-window'; diff --git a/packages/core/native-window/index.d.ts b/packages/core/native-window/index.ts similarity index 100% rename from packages/core/native-window/index.d.ts rename to packages/core/native-window/index.ts diff --git a/packages/core/native-window/native-window-common.ts b/packages/core/native-window/native-window-common.ts index 41319f00ab..b8a89fe7d6 100644 --- a/packages/core/native-window/native-window-common.ts +++ b/packages/core/native-window/native-window-common.ts @@ -10,8 +10,9 @@ import type { NavigationEntry } from '../ui/frame/frame-interfaces'; import type { StyleScope } from '../ui/styling/style-scope'; import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessibility/accessibility-common'; import { SDK_VERSION } from '../utils/constants'; -import type { INativeWindow, NativeWindowEventData, NativeWindowEventName } from './native-window-interfaces'; +import type { NativeWindowEventData, NativeWindowEventName } from './native-window-interfaces'; import { NativeWindowEvents } from './native-window-interfaces'; +import type { AndroidActivityEventData, AndroidActivityBundleEventData, AndroidActivityResultEventData, AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, SceneEventData } from '../application/application-interfaces'; // prettier-ignore const ORIENTATION_CSS_CLASSES = [ @@ -42,7 +43,7 @@ let _windowIdCounter = 0; * * Platform-specific subclasses implement the abstract methods. */ -export abstract class NativeWindowCommon extends Observable implements INativeWindow { +export abstract class NativeWindow extends Observable { private _id: string; private _isPrimary: boolean; protected _rootView: View; @@ -139,14 +140,45 @@ export abstract class NativeWindowCommon extends Observable implements INativeWi return (this._layoutDirection ??= this._getLayoutDirection()); } - get iosWindow(): INativeWindow['iosWindow'] { + get iosWindow(): { readonly scene: UIWindowScene; readonly window: UIWindow } | undefined { return undefined; } - get androidWindow(): INativeWindow['androidWindow'] { + get androidWindow(): { readonly activity: androidx.appcompat.app.AppCompatActivity } | undefined { return undefined; } + // --- Typed event overloads --- + + on(event: 'activate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'deactivate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'background', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'foreground', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'close', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'displayed', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'contentLoaded', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + on(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + on(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + on(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + on(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + on(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(eventName: string, callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(eventName: string, callback: (data: any) => void, thisArg?: any): void { + super.on(eventName, callback, thisArg); + } + // Platform-specific abstract getters protected abstract _getOrientation(): 'portrait' | 'landscape' | 'unknown'; protected abstract _getSystemAppearance(): 'light' | 'dark' | null; @@ -293,7 +325,7 @@ export abstract class NativeWindowCommon extends Observable implements INativeWi _notifyEvent(eventName: NativeWindowEventName): void { this.notify({ eventName, - window: this, + window: this as any, object: this, }); } diff --git a/packages/core/native-window/native-window-interfaces.ts b/packages/core/native-window/native-window-interfaces.ts index cd8ce89d74..fce4a35a22 100644 --- a/packages/core/native-window/native-window-interfaces.ts +++ b/packages/core/native-window/native-window-interfaces.ts @@ -1,7 +1,5 @@ import type { EventData } from '../data/observable'; -import type { View } from '../ui/core/view'; -import type { NavigationEntry } from '../ui/frame/frame-interfaces'; -import type { CoreTypes } from '../core-types'; +import type { NativeWindow } from './native-window-common'; /** * Events emitted by a NativeWindow instance. @@ -21,6 +19,44 @@ export const NativeWindowEvents = { displayed: 'displayed', /** Fired when the root view content is set or changed. */ contentLoaded: 'contentLoaded', + + // iOS scene lifecycle events + /** Fired when the scene is about to connect (iOS only). */ + sceneWillConnect: 'sceneWillConnect', + /** Fired when the scene becomes active (iOS only). */ + sceneDidActivate: 'sceneDidActivate', + /** Fired when the scene is about to resign active state (iOS only). */ + sceneWillResignActive: 'sceneWillResignActive', + /** Fired when the scene is about to enter the foreground (iOS only). */ + sceneWillEnterForeground: 'sceneWillEnterForeground', + /** Fired when the scene has entered the background (iOS only). */ + sceneDidEnterBackground: 'sceneDidEnterBackground', + /** Fired when the scene has disconnected (iOS only). */ + sceneDidDisconnect: 'sceneDidDisconnect', + + // Android activity lifecycle events + /** Fired when the activity is created (Android only). */ + activityCreated: 'activityCreated', + /** Fired when the activity is destroyed (Android only). */ + activityDestroyed: 'activityDestroyed', + /** Fired when the activity is started (Android only). */ + activityStarted: 'activityStarted', + /** Fired when the activity is paused (Android only). */ + activityPaused: 'activityPaused', + /** Fired when the activity is resumed (Android only). */ + activityResumed: 'activityResumed', + /** Fired when the activity is stopped (Android only). */ + activityStopped: 'activityStopped', + /** Fired when the activity state is being saved (Android only). */ + saveActivityState: 'saveActivityState', + /** Fired when the activity receives a result (Android only). */ + activityResult: 'activityResult', + /** Fired when the back button is pressed (Android only). */ + activityBackPressed: 'activityBackPressed', + /** Fired when the activity receives a new intent (Android only). */ + activityNewIntent: 'activityNewIntent', + /** Fired when permission results are received (Android only). */ + activityRequestPermissions: 'activityRequestPermissions', } as const; export type NativeWindowEventName = (typeof NativeWindowEvents)[keyof typeof NativeWindowEvents]; @@ -40,7 +76,7 @@ export const WindowEvents = { */ export interface NativeWindowEventData extends EventData { /** The NativeWindow that emitted the event. */ - window: INativeWindow; + window: NativeWindow; } /** @@ -48,7 +84,7 @@ export interface NativeWindowEventData extends EventData { */ export interface WindowOpenEventData extends EventData { /** The NativeWindow that was opened. */ - window: INativeWindow; + window: NativeWindow; } /** @@ -56,83 +92,7 @@ export interface WindowOpenEventData extends EventData { */ export interface WindowCloseEventData extends EventData { /** The NativeWindow that was closed. */ - window: INativeWindow; -} - -/** - * Cross-platform NativeWindow interface. - * - * A NativeWindow represents a platform window surface: - * - iOS: UIWindowScene + UIWindow - * - Android: Activity - * - * Each NativeWindow manages its own root view, lifecycle events, - * and platform-specific accessors. - */ -export interface INativeWindow { - /** - * A stable identifier for this window. - * On iOS: derived from the UISceneSession persistentIdentifier. - * On Android: derived from the Activity hashCode. - */ - readonly id: string; - - /** - * Whether this is the primary (main) window. - * The first window created is typically the primary window. - * Application-level lifecycle events are bridged from the primary window. - */ - readonly isPrimary: boolean; - - /** - * The current root view of this window. - */ - readonly rootView: View; - - /** - * Set the content of this window. - * @param content A View instance, a NavigationEntry, or a module name string. - */ - setContent(content: View | NavigationEntry | string): void; - - /** - * Close this window. - * The primary window cannot be closed. - * - * iOS: requests scene session destruction. - * Android: finishes the activity. - */ - close(): void; - - /** - * The current orientation of this window. - */ - orientation(): 'portrait' | 'landscape' | 'unknown'; - - /** - * The current system appearance (light/dark) for this window. - */ - systemAppearance(): 'light' | 'dark' | null; - - /** - * The current layout direction for this window. - */ - layoutDirection(): CoreTypes.LayoutDirectionType | null; - - /** - * iOS-specific accessors. Only available when running on iOS. - */ - readonly iosWindow?: { - readonly scene: UIWindowScene; - readonly window: UIWindow; - }; - - /** - * Android-specific accessors. Only available when running on Android. - */ - readonly androidWindow?: { - readonly activity: androidx.appcompat.app.AppCompatActivity; - }; + window?: NativeWindow; } /** diff --git a/packages/core/native-window/native-window.android.ts b/packages/core/native-window/native-window.android.ts index 8676a843bd..727bf9c8cd 100644 --- a/packages/core/native-window/native-window.android.ts +++ b/packages/core/native-window/native-window.android.ts @@ -2,14 +2,13 @@ import type { View } from '../ui/core/view'; import { CoreTypes } from '../core-types'; import { SDK_VERSION } from '../utils/constants'; import { AndroidActivityCallbacks, NavigationEntry } from '../ui/frame/frame-common'; -import { NativeWindowCommon } from './native-window-common'; -import type { INativeWindow } from './native-window-interfaces'; +import { NativeWindow } from './native-window-common'; /** * Android implementation of NativeWindow. * Wraps an AppCompatActivity. */ -export class NativeWindow extends NativeWindowCommon { +export class AndroidNativeWindow extends NativeWindow { private _activity: WeakRef; constructor(activity: androidx.appcompat.app.AppCompatActivity, id: string, isPrimary = false) { @@ -24,7 +23,7 @@ export class NativeWindow extends NativeWindowCommon { return this._activity?.deref(); } - get androidWindow(): INativeWindow['androidWindow'] { + get androidWindow() { const activity = this.activity; if (!activity) { return undefined; diff --git a/packages/core/native-window/native-window.d.ts b/packages/core/native-window/native-window.d.ts deleted file mode 100644 index a089657564..0000000000 --- a/packages/core/native-window/native-window.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NativeWindowCommon } from './native-window-common'; -import type { INativeWindow, NativeWindowEventData } from './native-window-interfaces'; - -export { NativeWindowCommon } from './native-window-common'; -export * from './native-window-interfaces'; - -/** - * Cross-platform NativeWindow class. - * - * On iOS: wraps a UIWindowScene + UIWindow. - * On Android: wraps an AppCompatActivity. - * - * Use `Application.primaryWindow` to get the main window, - * or `Application.getWindows()` to get all active windows. - */ -export class NativeWindow extends NativeWindowCommon implements INativeWindow { - readonly iosWindow: - | { - readonly scene: UIWindowScene; - readonly window: UIWindow; - } - | undefined; - - readonly androidWindow: - | { - readonly activity: androidx.appcompat.app.AppCompatActivity; - } - | undefined; - // Event methods (inherited from Observable) - on(eventName: string, callback: (data: NativeWindowEventData) => void, thisArg?: any): void; - once(eventName: string, callback: (data: NativeWindowEventData) => void, thisArg?: any): void; - off(eventName: string, callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; -} diff --git a/packages/core/native-window/native-window.ios.ts b/packages/core/native-window/native-window.ios.ts index f97709729a..90f55e8a2d 100644 --- a/packages/core/native-window/native-window.ios.ts +++ b/packages/core/native-window/native-window.ios.ts @@ -2,15 +2,14 @@ import type { View } from '../ui/core/view'; import { IOSHelper } from '../ui/core/view/view-helper'; import { SDK_VERSION } from '../utils/constants'; import { CoreTypes } from '../core-types'; -import { NativeWindowCommon } from './native-window-common'; +import { NativeWindow } from './native-window-common'; import { NativeWindowEvents } from './native-window-interfaces'; -import type { INativeWindow } from './native-window-interfaces'; /** * iOS implementation of NativeWindow. * Wraps a UIWindowScene + UIWindow pair. */ -export class NativeWindow extends NativeWindowCommon { +export class IOSNativeWindow extends NativeWindow { private _scene: UIWindowScene; private _window: UIWindow; @@ -20,7 +19,7 @@ export class NativeWindow extends NativeWindowCommon { this._window = window; } - get iosWindow(): INativeWindow['iosWindow'] { + get iosWindow() { return { scene: this._scene, window: this._window, diff --git a/packages/core/ui/frame/index.android.ts b/packages/core/ui/frame/index.android.ts index 11a61e64db..dd3ec0478e 100644 --- a/packages/core/ui/frame/index.android.ts +++ b/packages/core/ui/frame/index.android.ts @@ -15,6 +15,7 @@ import { getAppMainEntry } from '../../application/helpers-common'; import { AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, AndroidActivityResultEventData } from '../../application/application-interfaces'; import { Application } from '../../application/application'; +import { NativeWindowEvents } from '../../native-window/native-window-interfaces'; import { isEmbedded, setEmbeddedView } from '../embedding'; import { CALLBACKS, FRAMEID, framesCache, setFragmentCallbacks } from './frame-helper-for-android'; import { SDK_VERSION } from '../../utils'; @@ -736,13 +737,23 @@ if (SDK_VERSION >= 33) { } const args = { - eventName: 'activityBackPressed', + eventName: NativeWindowEvents.activityBackPressed, object: Application, android: Application.android, activity: activity, cancel: false, }; + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...args, + object: nativeWindow, + } as AndroidActivityBackPressedEventData); + } + + // @deprecated - Bridge to Application.android for backward compat Application.android.notify(args); if (args.cancel) { @@ -757,7 +768,7 @@ if (SDK_VERSION >= 33) { if (view) { const viewArgs = { - eventName: 'activityBackPressed', + eventName: NativeWindowEvents.activityBackPressed, object: view, activity: activity, cancel: false, @@ -852,12 +863,24 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } if (intent && intent.getAction()) { - Application.android.notify({ - eventName: Application.AndroidApplication.activityNewIntentEvent, + const newIntentArgs = { + eventName: NativeWindowEvents.activityNewIntent, object: Application.android, activity, intent, - }); + }; + + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...newIntentArgs, + object: nativeWindow, + } as AndroidActivityNewIntentEventData); + } + + // @deprecated - Bridge to Application.android for backward compat + Application.android.notify(newIntentArgs); } this.setActivityContent(activity, savedInstanceState, true); @@ -883,12 +906,24 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks superFunc.call(activity, intent); superSetIntentFunc.call(activity, intent); - Application.android.notify({ - eventName: Application.AndroidApplication.activityNewIntentEvent, + const newIntentArgs = { + eventName: NativeWindowEvents.activityNewIntent, object: Application.android, activity, intent, - }); + }; + + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...newIntentArgs, + object: nativeWindow, + } as AndroidActivityNewIntentEventData); + } + + // @deprecated - Bridge to Application.android for backward compat + Application.android.notify(newIntentArgs); } @profile @@ -977,12 +1012,23 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } const args = { - eventName: 'activityBackPressed', + eventName: NativeWindowEvents.activityBackPressed, object: Application, android: Application.android, activity: activity, cancel: false, }; + + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...args, + object: nativeWindow, + } as AndroidActivityBackPressedEventData); + } + + // @deprecated - Bridge to Application.android for backward compat Application.android.notify(args); if (args.cancel) { return; @@ -992,7 +1038,7 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks let callSuper = false; const viewArgs = { - eventName: 'activityBackPressed', + eventName: NativeWindowEvents.activityBackPressed, object: view, activity: activity, cancel: false, @@ -1015,15 +1061,27 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks Trace.write('NativeScriptActivity.onRequestPermissionsResult;', Trace.categories.NativeLifecycle); } - Application.android.notify({ - eventName: 'activityRequestPermissions', + const permArgs = { + eventName: NativeWindowEvents.activityRequestPermissions, object: Application, android: Application.android, activity: activity, requestCode: requestCode, permissions: permissions, grantResults: grantResults, - }); + }; + + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...permArgs, + object: nativeWindow, + } as AndroidActivityRequestPermissionsEventData); + } + + // @deprecated - Bridge to Application.android for backward compat + Application.android.notify(permArgs); } @profile @@ -1033,15 +1091,27 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks Trace.write(`NativeScriptActivity.onActivityResult(${requestCode}, ${resultCode}, ${data})`, Trace.categories.NativeLifecycle); } - Application.android.notify({ - eventName: 'activityResult', + const resultArgs = { + eventName: NativeWindowEvents.activityResult, object: Application, android: Application.android, activity: activity, requestCode: requestCode, resultCode: resultCode, intent: data, - }); + }; + + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...resultArgs, + object: nativeWindow, + } as AndroidActivityResultEventData); + } + + // @deprecated - Bridge to Application.android for backward compat + Application.android.notify(resultArgs); } public resetActivityContent(activity: androidx.appcompat.app.AppCompatActivity): void { From cdd182b7f3a113277669e8d14abca9c17ad62e94 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 13 Apr 2026 16:26:47 -0300 Subject: [PATCH 03/23] chore: remove unneeded scene config --- tools/assets/App_Resources/iOS/Info.plist | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tools/assets/App_Resources/iOS/Info.plist b/tools/assets/App_Resources/iOS/Info.plist index 78642afa97..53e75e7496 100644 --- a/tools/assets/App_Resources/iOS/Info.plist +++ b/tools/assets/App_Resources/iOS/Info.plist @@ -60,18 +60,5 @@ UIWindowSceneSessionRoleApplication UIApplicationSupportsMultipleScenes - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - SceneDelegate - - - - From dbc0d9213e9df04b39050c60532e8d4d2f9cccd4 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 16:15:22 -0300 Subject: [PATCH 04/23] fix(core): mechanical NativeWindow bug fixes - restore missing in toolbox Info.plist scene manifest - stop _destroy() emitting a second 'close' event (callers already notify) - promote primary windows via _setIsPrimary() instead of an any-cast - _getWindows() returns a copy instead of the live internal array - read activity callbacks via the CALLBACKS constant - make WindowCloseEventData.window required - fix sceneContentSetup deprecation text referencing a nonexistent event --- packages/core/application/application-common.ts | 2 +- packages/core/application/application.android.ts | 4 ++-- packages/core/application/application.ios.ts | 4 ++-- packages/core/native-window/native-window-common.ts | 3 +-- packages/core/native-window/native-window-interfaces.ts | 2 +- packages/core/native-window/native-window.android.ts | 3 ++- tools/assets/App_Resources/iOS/Info.plist | 1 + 7 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index 591da837ee..5a4dc168b1 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -56,7 +56,7 @@ export const SceneEvents = { sceneDidEnterBackground: 'sceneDidEnterBackground', /** @deprecated Use `NativeWindowEvents.sceneDidDisconnect` instead. */ sceneDidDisconnect: 'sceneDidDisconnect', - /** @deprecated Use `NativeWindowEvents.sceneContentSetup` instead. */ + /** @deprecated Use the Application 'windowOpen' event and NativeWindow.setContent() instead. */ sceneContentSetup: 'sceneContentSetup', }; diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index f2ad0054b4..6a17ff2343 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -660,7 +660,7 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp // If primary was removed, promote next window if (nativeWindow.isPrimary && this._windows.length > 0) { - (this._windows[0] as any)._isPrimary = true; + this._windows[0]._setIsPrimary(true); } } @@ -668,7 +668,7 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp * @internal - Get all registered NativeWindows. */ _getWindows(): NativeWindow[] { - return this._windows; + return [...this._windows]; } /** diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index b6daa3ee8f..821e8c8715 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -1118,7 +1118,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // If primary was removed, promote next window if (nativeWindow.isPrimary && this._windows.length > 0) { - (this._windows[0] as any)._isPrimary = true; + this._windows[0]._setIsPrimary(true); const promotedWindow = this._windows[0].iosWindow?.window; if (promotedWindow) { setiOSWindow(promotedWindow); @@ -1130,7 +1130,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication * @internal - Get all registered NativeWindows. */ _getWindows(): NativeWindow[] { - return this._windows; + return [...this._windows]; } /** diff --git a/packages/core/native-window/native-window-common.ts b/packages/core/native-window/native-window-common.ts index b8a89fe7d6..736ca31ed7 100644 --- a/packages/core/native-window/native-window-common.ts +++ b/packages/core/native-window/native-window-common.ts @@ -325,7 +325,7 @@ export abstract class NativeWindow extends Observable { _notifyEvent(eventName: NativeWindowEventName): void { this.notify({ eventName, - window: this as any, + window: this, object: this, }); } @@ -334,7 +334,6 @@ export abstract class NativeWindow extends Observable { * @internal – called when the window is being torn down. */ _destroy(): void { - this._notifyEvent(NativeWindowEvents.close); if (this._rootView) { this._rootView._onRootViewReset(); this._rootView = null; diff --git a/packages/core/native-window/native-window-interfaces.ts b/packages/core/native-window/native-window-interfaces.ts index fce4a35a22..84d892cb02 100644 --- a/packages/core/native-window/native-window-interfaces.ts +++ b/packages/core/native-window/native-window-interfaces.ts @@ -92,7 +92,7 @@ export interface WindowOpenEventData extends EventData { */ export interface WindowCloseEventData extends EventData { /** The NativeWindow that was closed. */ - window?: NativeWindow; + window: NativeWindow; } /** diff --git a/packages/core/native-window/native-window.android.ts b/packages/core/native-window/native-window.android.ts index 727bf9c8cd..69c71c25e1 100644 --- a/packages/core/native-window/native-window.android.ts +++ b/packages/core/native-window/native-window.android.ts @@ -2,6 +2,7 @@ import type { View } from '../ui/core/view'; import { CoreTypes } from '../core-types'; import { SDK_VERSION } from '../utils/constants'; import { AndroidActivityCallbacks, NavigationEntry } from '../ui/frame/frame-common'; +import { CALLBACKS } from '../ui/frame/frame-helper-for-android'; import { NativeWindow } from './native-window-common'; /** @@ -40,7 +41,7 @@ export class AndroidNativeWindow extends NativeWindow { throw new Error('NativeWindow: Activity is no longer available.'); } - const callbacks: AndroidActivityCallbacks = (activity as any)['_callbacks']; + const callbacks: AndroidActivityCallbacks = (activity as any)[CALLBACKS]; if (!callbacks) { throw new Error('NativeWindow: Cannot find activity callbacks.'); } diff --git a/tools/assets/App_Resources/iOS/Info.plist b/tools/assets/App_Resources/iOS/Info.plist index 53e75e7496..86d724c392 100644 --- a/tools/assets/App_Resources/iOS/Info.plist +++ b/tools/assets/App_Resources/iOS/Info.plist @@ -60,5 +60,6 @@ UIWindowSceneSessionRoleApplication UIApplicationSupportsMultipleScenes + From 95ed0ae5294231f11ac733c78b9847bee13be519 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 16:27:39 -0300 Subject: [PATCH 05/23] revert(core): un-deprecate Application event bridges; add window to their payloads The activity*/scene* events on Application are aggregate APIs that fire for every window; args.window identifies which one. SceneEventData's native UIWindow moves to uiWindow so that a 'window' payload key always means a NativeWindow. --- apps/toolbox/src/main-page.ts | 2 +- apps/toolbox/src/pages/multiple-scenes.ts | 6 +- .../application/application-interfaces.ts | 13 +- .../core/application/application.android.ts | 21 ++-- packages/core/application/application.d.ts | 117 ------------------ packages/core/application/application.ios.ts | 57 +++++---- packages/core/ui/frame/index.android.ts | 24 ++-- 7 files changed, 76 insertions(+), 164 deletions(-) diff --git a/apps/toolbox/src/main-page.ts b/apps/toolbox/src/main-page.ts index da045f370e..4c04eb38a8 100644 --- a/apps/toolbox/src/main-page.ts +++ b/apps/toolbox/src/main-page.ts @@ -41,7 +41,7 @@ function setupSceneEvents() { // Listen to scene events Application.on(SceneEvents.sceneWillConnect, (args: SceneEventData) => { console.log('New scene connecting:', args.scene); - console.log('Window:', args.window); + console.log('Window:', args.uiWindow); console.log('Connection options:', args.connectionOptions); }); diff --git a/apps/toolbox/src/pages/multiple-scenes.ts b/apps/toolbox/src/pages/multiple-scenes.ts index 34718a69d2..3c87438436 100644 --- a/apps/toolbox/src/pages/multiple-scenes.ts +++ b/apps/toolbox/src/pages/multiple-scenes.ts @@ -200,7 +200,7 @@ export class MultipleScenesModel extends Observable { } private setupSceneContent(nativeWindow: NativeWindow, args: SceneEventData) { - if (!args.scene || !args.window || !__APPLE__) return; + if (!args.scene || !args.uiWindow || !__APPLE__) return; // Skip the primary scene (it already has content) if (nativeWindow === Application.ios.primaryWindow) return; @@ -215,10 +215,10 @@ export class MultipleScenesModel extends Observable { let page: Page; switch (nsViewId) { case 'newSceneBasic': - page = this._createPageForScene(args.scene, args.window); + page = this._createPageForScene(args.scene, args.uiWindow); break; case 'newSceneAlt': - page = this._createAltPageForScene(args.scene, args.window); + page = this._createAltPageForScene(args.scene, args.uiWindow); break; // Note: can implement any number of other scene views } diff --git a/packages/core/application/application-interfaces.ts b/packages/core/application/application-interfaces.ts index f597a63bd5..2242b23378 100644 --- a/packages/core/application/application-interfaces.ts +++ b/packages/core/application/application-interfaces.ts @@ -1,6 +1,7 @@ import type { EventData, Observable } from '../data/observable'; import type { View } from '../ui/core/view'; import type { CoreTypes } from '../core-types'; +import type { NativeWindow } from '../native-window/native-window-common'; /** * An extended JavaScript Error which will have the nativeError property initialized in case the error is caused by executing platform-specific code. @@ -143,6 +144,11 @@ export interface AndroidActivityEventData { */ activity: androidx.appcompat.app.AppCompatActivity; + /** + * The NativeWindow the activity belongs to, when one is registered for it. + */ + window?: NativeWindow; + /** * The name of the event. */ @@ -240,7 +246,12 @@ export interface SceneEventData extends ApplicationEventData { /** * The UIWindow associated with this scene (if applicable). */ - window?: UIWindow; + uiWindow?: UIWindow; + + /** + * The NativeWindow the scene belongs to, when one is registered for it. + */ + window?: NativeWindow; /** * Scene connection options (for sceneWillConnect event). diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index 6a17ff2343..a3cfc68f4b 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -122,15 +122,16 @@ function initNativeScriptLifecycleCallbacks() { nativeWindow.notify({ eventName: NativeWindowEvents.activityDestroyed, object: nativeWindow, + window: nativeWindow, activity, } as AndroidActivityEventData); Application.android._unregisterWindow(nativeWindow); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityDestroyedEvent, object: Application.android, + window: nativeWindow, activity, } as AndroidActivityEventData); @@ -156,14 +157,15 @@ function initNativeScriptLifecycleCallbacks() { nativeWindow.notify({ eventName: NativeWindowEvents.activityPaused, object: nativeWindow, + window: nativeWindow, activity, } as AndroidActivityEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityPausedEvent, object: Application.android, + window: nativeWindow, activity, } as AndroidActivityEventData); } @@ -180,6 +182,7 @@ function initNativeScriptLifecycleCallbacks() { nativeWindow.notify({ eventName: NativeWindowEvents.activityResumed, object: nativeWindow, + window: nativeWindow, activity, } as AndroidActivityEventData); } @@ -187,10 +190,10 @@ function initNativeScriptLifecycleCallbacks() { // NOTE: setSuspended(false) is called in frame/index.android.ts inside onPostResume // This is done to ensure proper timing for the event to be raised - // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityResumedEvent, object: Application.android, + window: nativeWindow, activity, } as AndroidActivityEventData); } @@ -205,15 +208,16 @@ function initNativeScriptLifecycleCallbacks() { nativeWindow.notify({ eventName: NativeWindowEvents.saveActivityState, object: nativeWindow, + window: nativeWindow, activity, bundle, } as AndroidActivityBundleEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.saveActivityStateEvent, object: Application.android, + window: nativeWindow, activity, bundle, } as AndroidActivityBundleEventData); @@ -239,14 +243,15 @@ function initNativeScriptLifecycleCallbacks() { nativeWindow.notify({ eventName: NativeWindowEvents.activityStarted, object: nativeWindow, + window: nativeWindow, activity, } as AndroidActivityEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityStartedEvent, object: Application.android, + window: nativeWindow, activity, } as AndroidActivityEventData); } @@ -270,14 +275,15 @@ function initNativeScriptLifecycleCallbacks() { nativeWindow.notify({ eventName: NativeWindowEvents.activityStopped, object: nativeWindow, + window: nativeWindow, activity, } as AndroidActivityEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityStoppedEvent, object: Application.android, + window: nativeWindow, activity, } as AndroidActivityEventData); } @@ -301,14 +307,15 @@ function initNativeScriptLifecycleCallbacks() { nativeWindow.notify({ eventName: NativeWindowEvents.activityCreated, object: nativeWindow, + window: nativeWindow, activity, bundle, } as AndroidActivityBundleEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityCreatedEvent, object: Application.android, + window: nativeWindow, activity, bundle, } as AndroidActivityBundleEventData); diff --git a/packages/core/application/application.d.ts b/packages/core/application/application.d.ts index 6c59b93e33..0bbda570f8 100644 --- a/packages/core/application/application.d.ts +++ b/packages/core/application/application.d.ts @@ -9,94 +9,28 @@ export * from './application-interfaces'; export const Application: ApplicationCommon; export class AndroidApplication extends ApplicationCommon { - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly activityCreatedEvent = 'activityCreated'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly activityDestroyedEvent = 'activityDestroyed'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly activityStartedEvent = 'activityStarted'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly activityPausedEvent = 'activityPaused'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly activityResumedEvent = 'activityResumed'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly activityStoppedEvent = 'activityStopped'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly saveActivityStateEvent = 'saveActivityState'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly activityResultEvent = 'activityResult'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly activityBackPressedEvent = 'activityBackPressed'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly activityNewIntentEvent = 'activityNewIntent'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ static readonly activityRequestPermissionsEvent = 'activityRequestPermissions'; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly activityCreatedEvent = AndroidApplication.activityCreatedEvent; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly activityDestroyedEvent = AndroidApplication.activityDestroyedEvent; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly activityStartedEvent = AndroidApplication.activityStartedEvent; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly activityPausedEvent = AndroidApplication.activityPausedEvent; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly activityResumedEvent = AndroidApplication.activityResumedEvent; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly activityStoppedEvent = AndroidApplication.activityStoppedEvent; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly saveActivityStateEvent = AndroidApplication.saveActivityStateEvent; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly activityResultEvent = AndroidApplication.activityResultEvent; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly activityBackPressedEvent = AndroidApplication.activityBackPressedEvent; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly activityNewIntentEvent = AndroidApplication.activityNewIntentEvent; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ readonly activityRequestPermissionsEvent = AndroidApplication.activityRequestPermissionsEvent; getNativeApplication(): android.app.Application; @@ -171,49 +105,16 @@ export class AndroidApplication extends ApplicationCommon { */ getRegisteredBroadcastReceivers(intentFilter: string): android.content.BroadcastReceiver[]; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; @@ -381,29 +282,11 @@ export class iOSApplication extends ApplicationCommon { */ onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; - /** - * @deprecated Listen on a NativeWindow instance instead. - */ on(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index 821e8c8715..dadde02ab8 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -251,17 +251,18 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { nativeWindow.notify({ eventName: NativeWindowEvents.sceneWillConnect, object: nativeWindow, + window: nativeWindow, scene: windowScene, - window: this._window, + uiWindow: this._window, connectionOptions: connectionOptions, } as SceneEventData); - // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ eventName: NativeWindowEvents.sceneWillConnect, object: Application.ios, + window: nativeWindow, scene: windowScene, - window: this._window, + uiWindow: this._window, connectionOptions: connectionOptions, } as SceneEventData); @@ -280,22 +281,24 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { } sceneDidBecomeActive(scene: UIScene): void { - const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + const windowScene = scene as UIWindowScene; + const nativeWindow = Application.ios._getWindowForScene(windowScene); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.activate); // Emit sceneDidActivate on NativeWindow nativeWindow.notify({ eventName: NativeWindowEvents.sceneDidActivate, object: nativeWindow, - scene: scene, + window: nativeWindow, + scene: windowScene, } as SceneEventData); } - // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ eventName: NativeWindowEvents.sceneDidActivate, object: Application.ios, - scene: scene, + window: nativeWindow, + scene: windowScene, } as SceneEventData); // If this is the primary scene, trigger traditional app lifecycle @@ -315,62 +318,68 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { } sceneWillResignActive(scene: UIScene): void { - const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + const windowScene = scene as UIWindowScene; + const nativeWindow = Application.ios._getWindowForScene(windowScene); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.deactivate); // Emit sceneWillResignActive on NativeWindow nativeWindow.notify({ eventName: NativeWindowEvents.sceneWillResignActive, object: nativeWindow, - scene: scene, + window: nativeWindow, + scene: windowScene, } as SceneEventData); } - // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ eventName: NativeWindowEvents.sceneWillResignActive, object: Application.ios, - scene: scene, + window: nativeWindow, + scene: windowScene, } as SceneEventData); } sceneWillEnterForeground(scene: UIScene): void { - const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + const windowScene = scene as UIWindowScene; + const nativeWindow = Application.ios._getWindowForScene(windowScene); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.foreground); // Emit sceneWillEnterForeground on NativeWindow nativeWindow.notify({ eventName: NativeWindowEvents.sceneWillEnterForeground, object: nativeWindow, - scene: scene, + window: nativeWindow, + scene: windowScene, } as SceneEventData); } - // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ eventName: NativeWindowEvents.sceneWillEnterForeground, object: Application.ios, - scene: scene, + window: nativeWindow, + scene: windowScene, } as SceneEventData); } sceneDidEnterBackground(scene: UIScene): void { - const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + const windowScene = scene as UIWindowScene; + const nativeWindow = Application.ios._getWindowForScene(windowScene); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.background); // Emit sceneDidEnterBackground on NativeWindow nativeWindow.notify({ eventName: NativeWindowEvents.sceneDidEnterBackground, object: nativeWindow, - scene: scene, + window: nativeWindow, + scene: windowScene, } as SceneEventData); } - // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ eventName: NativeWindowEvents.sceneDidEnterBackground, object: Application.ios, - scene: scene, + window: nativeWindow, + scene: windowScene, } as SceneEventData); // If this is the primary scene, trigger traditional app lifecycle @@ -390,23 +399,25 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { } sceneDidDisconnect(scene: UIScene): void { - const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + const windowScene = scene as UIWindowScene; + const nativeWindow = Application.ios._getWindowForScene(windowScene); if (nativeWindow) { nativeWindow._notifyEvent(NativeWindowEvents.close); // Emit sceneDidDisconnect on NativeWindow nativeWindow.notify({ eventName: NativeWindowEvents.sceneDidDisconnect, object: nativeWindow, - scene: scene, + window: nativeWindow, + scene: windowScene, } as SceneEventData); Application.ios._unregisterWindow(nativeWindow); } - // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ eventName: NativeWindowEvents.sceneDidDisconnect, object: Application.ios, - scene: scene, + window: nativeWindow, + scene: windowScene, } as SceneEventData); } } diff --git a/packages/core/ui/frame/index.android.ts b/packages/core/ui/frame/index.android.ts index dd3ec0478e..d586a5ef21 100644 --- a/packages/core/ui/frame/index.android.ts +++ b/packages/core/ui/frame/index.android.ts @@ -736,16 +736,17 @@ if (SDK_VERSION >= 33) { return; } + const nativeWindow = Application.android._getWindowForActivity(activity); const args = { eventName: NativeWindowEvents.activityBackPressed, object: Application, android: Application.android, + window: nativeWindow, activity: activity, cancel: false, }; // Emit on NativeWindow first - const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow.notify({ ...args, @@ -753,7 +754,6 @@ if (SDK_VERSION >= 33) { } as AndroidActivityBackPressedEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify(args); if (args.cancel) { @@ -863,15 +863,16 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } if (intent && intent.getAction()) { + const nativeWindow = Application.android._getWindowForActivity(activity); const newIntentArgs = { eventName: NativeWindowEvents.activityNewIntent, object: Application.android, + window: nativeWindow, activity, intent, }; // Emit on NativeWindow first - const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow.notify({ ...newIntentArgs, @@ -879,7 +880,6 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } as AndroidActivityNewIntentEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify(newIntentArgs); } @@ -906,15 +906,16 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks superFunc.call(activity, intent); superSetIntentFunc.call(activity, intent); + const nativeWindow = Application.android._getWindowForActivity(activity); const newIntentArgs = { eventName: NativeWindowEvents.activityNewIntent, object: Application.android, + window: nativeWindow, activity, intent, }; // Emit on NativeWindow first - const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow.notify({ ...newIntentArgs, @@ -922,7 +923,6 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } as AndroidActivityNewIntentEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify(newIntentArgs); } @@ -1011,16 +1011,17 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks Trace.write('NativeScriptActivity.onBackPressed;', Trace.categories.NativeLifecycle); } + const nativeWindow = Application.android._getWindowForActivity(activity); const args = { eventName: NativeWindowEvents.activityBackPressed, object: Application, android: Application.android, + window: nativeWindow, activity: activity, cancel: false, }; // Emit on NativeWindow first - const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow.notify({ ...args, @@ -1028,7 +1029,6 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } as AndroidActivityBackPressedEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify(args); if (args.cancel) { return; @@ -1061,10 +1061,12 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks Trace.write('NativeScriptActivity.onRequestPermissionsResult;', Trace.categories.NativeLifecycle); } + const nativeWindow = Application.android._getWindowForActivity(activity); const permArgs = { eventName: NativeWindowEvents.activityRequestPermissions, object: Application, android: Application.android, + window: nativeWindow, activity: activity, requestCode: requestCode, permissions: permissions, @@ -1072,7 +1074,6 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks }; // Emit on NativeWindow first - const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow.notify({ ...permArgs, @@ -1080,7 +1081,6 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } as AndroidActivityRequestPermissionsEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify(permArgs); } @@ -1091,10 +1091,12 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks Trace.write(`NativeScriptActivity.onActivityResult(${requestCode}, ${resultCode}, ${data})`, Trace.categories.NativeLifecycle); } + const nativeWindow = Application.android._getWindowForActivity(activity); const resultArgs = { eventName: NativeWindowEvents.activityResult, object: Application, android: Application.android, + window: nativeWindow, activity: activity, requestCode: requestCode, resultCode: resultCode, @@ -1102,7 +1104,6 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks }; // Emit on NativeWindow first - const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { nativeWindow.notify({ ...resultArgs, @@ -1110,7 +1111,6 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } as AndroidActivityResultEventData); } - // @deprecated - Bridge to Application.android for backward compat Application.android.notify(resultArgs); } From 017078a07b25dfe034d4ce96f476a90d691546be Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 16:36:09 -0300 Subject: [PATCH 06/23] refactor(core)!: split WindowBase from NativeWindow; add role; fix naming collisions WindowBase carries identity, role, state, lifecycle events and the native accessors; NativeWindow adds the view-carrying half. Surfaces without a NativeScript view tree (CarPlay, external displays) can extend WindowBase. BREAKING CHANGE: NativeWindow.iosWindow is now .ios and its window property is .uiWindow; NativeWindow.androidWindow is now .android. getWindows() is role-filtered and defaults to application and embedded windows. --- .../core/application/application.android.ts | 17 ++- packages/core/application/application.d.ts | 23 ++- packages/core/application/application.ios.ts | 45 +++--- packages/core/native-window/index.ts | 1 + .../native-window/native-window-common.ts | 65 ++------- .../native-window/native-window-interfaces.ts | 11 +- .../native-window/native-window.android.ts | 2 +- .../core/native-window/native-window.ios.ts | 4 +- packages/core/native-window/window-base.ts | 135 ++++++++++++++++++ packages/core/utils/native-helper.d.ts | 4 +- 10 files changed, 222 insertions(+), 85 deletions(-) create mode 100644 packages/core/native-window/window-base.ts diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index a3cfc68f4b..6a11cbde45 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -10,6 +10,7 @@ import { Observable } from '../data/observable'; import { Trace } from '../trace'; import { AndroidNativeWindow } from '../native-window/native-window.android'; import { NativeWindow } from '../native-window/native-window-common'; +import type { WindowBase, WindowRole } from '../native-window/window-base'; import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; import { CommonA11YServiceEnabledObservable, @@ -700,10 +701,20 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp } /** - * Get all active NativeWindows. + * Get the active windows, filtered by role. + * + * Defaults to the view-carrying app windows (`application` and `embedded`). + * Pass `'all'` to include every registered surface, including ones that carry no view tree. */ - getWindows(): NativeWindow[] { - return [...this._windows]; + getWindows(role: 'all'): WindowBase[]; + getWindows(role?: WindowRole | WindowRole[]): NativeWindow[]; + getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[]; + getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[] { + if (role === 'all') { + return [...this._windows]; + } + const roles: WindowRole[] = role ? (Array.isArray(role) ? role : [role]) : ['application', 'embedded']; + return this._windows.filter((nw) => roles.indexOf(nw.role) !== -1); } getRootView(): View { diff --git a/packages/core/application/application.d.ts b/packages/core/application/application.d.ts index 0bbda570f8..c0461bbfef 100644 --- a/packages/core/application/application.d.ts +++ b/packages/core/application/application.d.ts @@ -1,6 +1,7 @@ import { ApplicationCommon } from './application-common'; import { FontScaleCategory } from '../accessibility/font-scale-common'; import type { NativeWindow } from '../native-window/native-window-common'; +import type { WindowBase, WindowRole } from '../native-window/window-base'; import type { WindowOpenEventData, WindowCloseEventData } from '../native-window/native-window-interfaces'; export * from './application-common'; @@ -131,9 +132,14 @@ export class AndroidApplication extends ApplicationCommon { get primaryWindow(): NativeWindow | undefined; /** - * Get all active NativeWindows. + * Get the active windows, filtered by role. + * + * Defaults to the view-carrying app windows (`application` and `embedded`). + * Pass `'all'` to include every registered surface, including ones that carry no view tree. */ - getWindows(): NativeWindow[]; + getWindows(role: 'all'): WindowBase[]; + getWindows(role?: WindowRole | WindowRole[]): NativeWindow[]; + getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[]; } export class iOSApplication extends ApplicationCommon { @@ -235,13 +241,13 @@ export class iOSApplication extends ApplicationCommon { /** * Gets the primary window for the application. - * @deprecated Use `primaryWindow?.iosWindow?.window` instead. + * @deprecated Use `primaryWindow?.ios?.uiWindow` instead. */ getPrimaryWindow(): UIWindow; /** * Gets the primary scene for the application. - * @deprecated Use `primaryWindow?.iosWindow?.scene` instead. + * @deprecated Use `primaryWindow?.ios?.scene` instead. */ getPrimaryScene(): UIWindowScene | null; @@ -298,9 +304,14 @@ export class iOSApplication extends ApplicationCommon { get primaryWindow(): NativeWindow | undefined; /** - * Get all active NativeWindows. + * Get the active windows, filtered by role. + * + * Defaults to the view-carrying app windows (`application` and `embedded`). + * Pass `'all'` to include every registered surface, including ones that carry no view tree. */ - getWindows(): NativeWindow[]; + getWindows(role: 'all'): WindowBase[]; + getWindows(role?: WindowRole | WindowRole[]): NativeWindow[]; + getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[]; /** * Flag to be set when the launch event should be delayed until the application has become active. diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index dadde02ab8..3399f7aafe 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -13,6 +13,7 @@ import type { iOSApplication as IiOSApplication } from './application'; import { Trace } from '../trace'; import { IOSNativeWindow } from '../native-window/native-window.ios'; import { NativeWindow } from '../native-window/native-window-common'; +import type { WindowBase, WindowRole } from '../native-window/window-base'; import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; import { AccessibilityServiceEnabledPropName, @@ -1130,7 +1131,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // If primary was removed, promote next window if (nativeWindow.isPrimary && this._windows.length > 0) { this._windows[0]._setIsPrimary(true); - const promotedWindow = this._windows[0].iosWindow?.window; + const promotedWindow = this._windows[0].ios?.uiWindow; if (promotedWindow) { setiOSWindow(promotedWindow); } @@ -1148,7 +1149,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication * @internal - Get a NativeWindow by its scene. */ _getWindowForScene(scene: UIWindowScene): IOSNativeWindow | undefined { - return this._windows.find((nw) => nw.iosWindow?.scene === scene); + return this._windows.find((nw) => nw.ios?.scene === scene); } /** @@ -1168,10 +1169,20 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } /** - * Get all active NativeWindows. + * Get the active windows, filtered by role. + * + * Defaults to the view-carrying app windows (`application` and `embedded`). + * Pass `'all'` to include every registered surface, including ones that carry no view tree. */ - getWindows(): NativeWindow[] { - return [...this._windows]; + getWindows(role: 'all'): WindowBase[]; + getWindows(role?: WindowRole | WindowRole[]): NativeWindow[]; + getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[]; + getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[] { + if (role === 'all') { + return [...this._windows]; + } + const roles: WindowRole[] = role ? (Array.isArray(role) ? role : [role]) : ['application', 'embedded']; + return this._windows.filter((nw) => roles.indexOf(nw.role) !== -1); } /** @@ -1248,8 +1259,8 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication const options = UISceneActivationRequestOptions.new(); const primary = this.primaryWindow; - if (primary?.iosWindow?.scene) { - options.requestingScene = primary.iosWindow.scene; + if (primary?.ios?.scene) { + options.requestingScene = primary.ios.scene; } request.options = options; @@ -1322,14 +1333,14 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication * @deprecated Use `getWindows()` instead. */ getAllWindows(): UIWindow[] { - return this._windows.map((nw) => nw.iosWindow?.window).filter(Boolean) as UIWindow[]; + return this._windows.map((nw) => nw.ios?.uiWindow).filter(Boolean) as UIWindow[]; } /** * @deprecated Use `getWindows()` instead. */ getAllScenes(): UIScene[] { - return this._windows.map((nw) => nw.iosWindow?.scene).filter(Boolean) as UIScene[]; + return this._windows.map((nw) => nw.ios?.scene).filter(Boolean) as UIScene[]; } /** @@ -1340,21 +1351,21 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } /** - * @deprecated Use `primaryWindow?.iosWindow?.window` instead. + * @deprecated Use `primaryWindow?.ios?.uiWindow` instead. */ getPrimaryWindow(): UIWindow { const primary = this.primaryWindow; - if (primary?.iosWindow?.window) { - return primary.iosWindow.window; + if (primary?.ios?.uiWindow) { + return primary.ios.uiWindow; } return getiOSWindow(); } /** - * @deprecated Use `primaryWindow?.iosWindow?.scene` instead. + * @deprecated Use `primaryWindow?.ios?.scene` instead. */ getPrimaryScene(): UIWindowScene | null { - return this.primaryWindow?.iosWindow?.scene || null; + return this.primaryWindow?.ios?.scene || null; } // Scene lifecycle management @@ -1386,7 +1397,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication if (!target) { // Try to pick a non-primary window's scene const nonPrimary = this._windows.filter((nw) => !nw.isPrimary); - return nonPrimary[0]?.iosWindow?.scene || this.primaryWindow?.iosWindow?.scene || null; + return nonPrimary[0]?.ios?.scene || this.primaryWindow?.ios?.scene || null; } if (target && typeof target === 'object') { // UIWindowScene @@ -1408,11 +1419,11 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication if (typeof target === 'string') { const found = this._getWindowById(target); if (found) { - return found.iosWindow?.scene || null; + return found.ios?.scene || null; } // Try matching among known scenes for (const nw of this._windows) { - const scene = nw.iosWindow?.scene; + const scene = nw.ios?.scene; if (scene && IOSNativeWindow.getSceneId(scene) === target) { return scene; } diff --git a/packages/core/native-window/index.ts b/packages/core/native-window/index.ts index 96264fa3ca..01f226697c 100644 --- a/packages/core/native-window/index.ts +++ b/packages/core/native-window/index.ts @@ -1,2 +1,3 @@ export * from './native-window-interfaces'; +export * from './window-base'; export * from './native-window-common'; diff --git a/packages/core/native-window/native-window-common.ts b/packages/core/native-window/native-window-common.ts index 736ca31ed7..6e50dbedf7 100644 --- a/packages/core/native-window/native-window-common.ts +++ b/packages/core/native-window/native-window-common.ts @@ -1,4 +1,3 @@ -import { Observable } from '../data/observable'; import { CoreTypes } from '../core-types'; import { CSSUtils } from '../css/system-classes'; import { Device } from '../platform'; @@ -10,9 +9,11 @@ import type { NavigationEntry } from '../ui/frame/frame-interfaces'; import type { StyleScope } from '../ui/styling/style-scope'; import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessibility/accessibility-common'; import { SDK_VERSION } from '../utils/constants'; -import type { NativeWindowEventData, NativeWindowEventName } from './native-window-interfaces'; -import { NativeWindowEvents } from './native-window-interfaces'; +import type { NativeWindowEventData } from './native-window-interfaces'; import type { AndroidActivityEventData, AndroidActivityBundleEventData, AndroidActivityResultEventData, AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, SceneEventData } from '../application/application-interfaces'; +import { NativeWindowEvents } from './native-window-interfaces'; +import type { WindowRole } from './window-base'; +import { WindowBase } from './window-base'; // prettier-ignore const ORIENTATION_CSS_CLASSES = [ @@ -33,8 +34,6 @@ const LAYOUT_DIRECTION_CSS_CLASSES = [ `${CSSUtils.CLASS_PREFIX}${CoreTypes.LayoutDirection.rtl}`, ]; -let _windowIdCounter = 0; - /** * Cross-platform NativeWindow base class. * @@ -43,33 +42,14 @@ let _windowIdCounter = 0; * * Platform-specific subclasses implement the abstract methods. */ -export abstract class NativeWindow extends Observable { - private _id: string; - private _isPrimary: boolean; +export abstract class NativeWindow extends WindowBase { protected _rootView: View; protected _orientation: 'portrait' | 'landscape' | 'unknown'; protected _systemAppearance: 'dark' | 'light' | null; protected _layoutDirection: CoreTypes.LayoutDirectionType | null; - constructor(id?: string, isPrimary = false) { - super(); - this._id = id || `window-${++_windowIdCounter}`; - this._isPrimary = isPrimary; - } - - get id(): string { - return this._id; - } - - get isPrimary(): boolean { - return this._isPrimary; - } - - /** - * @internal - used by the Application to promote a window to primary. - */ - _setIsPrimary(value: boolean): void { - this._isPrimary = value; + constructor(id?: string, isPrimary = false, role: WindowRole = 'application') { + super(id, isPrimary, role); } get rootView(): View { @@ -114,11 +94,6 @@ export abstract class NativeWindow extends Observable { */ protected abstract _setNativeContent(view: View): void; - /** - * Close this window. - */ - abstract close(): void; - /** * Get the current orientation of this window. */ @@ -140,23 +115,17 @@ export abstract class NativeWindow extends Observable { return (this._layoutDirection ??= this._getLayoutDirection()); } - get iosWindow(): { readonly scene: UIWindowScene; readonly window: UIWindow } | undefined { - return undefined; - } - - get androidWindow(): { readonly activity: androidx.appcompat.app.AppCompatActivity } | undefined { - return undefined; - } - // --- Typed event overloads --- + // The whole set is repeated here: TypeScript only accepts an override whose overloads + // cover every overload of the base signature. + on(event: 'contentLoaded', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'activate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'deactivate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'background', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'foreground', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'close', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'displayed', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; - on(event: 'contentLoaded', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; @@ -239,7 +208,7 @@ export abstract class NativeWindow extends Observable { if (Trace.isEnabled()) { const rootCssClasses = Array.from(rootView.cssClasses); - Trace.write(`NativeWindow [${this._id}] Setting root css classes: ${rootCssClasses.join(' ')}`, Trace.categories.Style); + Trace.write(`NativeWindow [${this.id}] Setting root css classes: ${rootCssClasses.join(' ')}`, Trace.categories.Style); } } @@ -319,21 +288,11 @@ export abstract class NativeWindow extends Observable { } } - /** - * @internal – emit a NativeWindow lifecycle event. - */ - _notifyEvent(eventName: NativeWindowEventName): void { - this.notify({ - eventName, - window: this, - object: this, - }); - } - /** * @internal – called when the window is being torn down. */ _destroy(): void { + super._destroy(); if (this._rootView) { this._rootView._onRootViewReset(); this._rootView = null; diff --git a/packages/core/native-window/native-window-interfaces.ts b/packages/core/native-window/native-window-interfaces.ts index 84d892cb02..9104a2c39a 100644 --- a/packages/core/native-window/native-window-interfaces.ts +++ b/packages/core/native-window/native-window-interfaces.ts @@ -1,5 +1,6 @@ import type { EventData } from '../data/observable'; import type { NativeWindow } from './native-window-common'; +import type { WindowBase } from './window-base'; /** * Events emitted by a NativeWindow instance. @@ -71,10 +72,18 @@ export const WindowEvents = { windowClose: 'windowClose', } as const; +/** + * Base event data for window surface events. + */ +export interface WindowBaseEventData extends EventData { + /** The window that emitted the event. */ + window: WindowBase; +} + /** * Base event data for NativeWindow events. */ -export interface NativeWindowEventData extends EventData { +export interface NativeWindowEventData extends WindowBaseEventData { /** The NativeWindow that emitted the event. */ window: NativeWindow; } diff --git a/packages/core/native-window/native-window.android.ts b/packages/core/native-window/native-window.android.ts index 69c71c25e1..cefbbb424d 100644 --- a/packages/core/native-window/native-window.android.ts +++ b/packages/core/native-window/native-window.android.ts @@ -24,7 +24,7 @@ export class AndroidNativeWindow extends NativeWindow { return this._activity?.deref(); } - get androidWindow() { + get android() { const activity = this.activity; if (!activity) { return undefined; diff --git a/packages/core/native-window/native-window.ios.ts b/packages/core/native-window/native-window.ios.ts index 90f55e8a2d..52c54af1ab 100644 --- a/packages/core/native-window/native-window.ios.ts +++ b/packages/core/native-window/native-window.ios.ts @@ -19,10 +19,10 @@ export class IOSNativeWindow extends NativeWindow { this._window = window; } - get iosWindow() { + get ios() { return { scene: this._scene, - window: this._window, + uiWindow: this._window, }; } diff --git a/packages/core/native-window/window-base.ts b/packages/core/native-window/window-base.ts new file mode 100644 index 0000000000..1d4d71cbc7 --- /dev/null +++ b/packages/core/native-window/window-base.ts @@ -0,0 +1,135 @@ +import { Observable } from '../data/observable'; +import type { NativeWindowEventName, WindowBaseEventData } from './native-window-interfaces'; +import type { AndroidActivityEventData, AndroidActivityBundleEventData, AndroidActivityResultEventData, AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, SceneEventData } from '../application/application-interfaces'; + +/** + * The purpose a window surface serves. + * + * - `application` – a regular app window (iOS application scene, Android activity). + * - `embedded` – a window hosted inside another app or container. + * - `carplay` – a CarPlay template scene. + * - `externalDisplay` – an external/secondary display scene. + */ +export type WindowRole = 'application' | 'embedded' | 'carplay' | 'externalDisplay'; + +/** + * The lifecycle state of a window surface. + * + * - `attached` – connected to a live native surface. + * - `detached` – the native surface went away but the window may be reconnected. + * - `closed` – permanently torn down. + */ +export type WindowState = 'attached' | 'detached' | 'closed'; + +let _windowIdCounter = 0; + +/** + * Cross-platform base for any window surface. + * + * Carries identity, role, state, lifecycle events and the native accessors. + * Surfaces that host a NativeScript view tree extend {@link NativeWindow} instead. + */ +export abstract class WindowBase extends Observable { + private _id: string; + private _role: WindowRole; + private _state: WindowState = 'attached'; + private _isPrimary: boolean; + + constructor(id?: string, isPrimary = false, role: WindowRole = 'application') { + super(); + this._id = id || `window-${++_windowIdCounter}`; + this._isPrimary = isPrimary; + this._role = role; + } + + get id(): string { + return this._id; + } + + get role(): WindowRole { + return this._role; + } + + get state(): WindowState { + return this._state; + } + + get isPrimary(): boolean { + return this._isPrimary; + } + + /** + * @internal - used by the Application to promote a window to primary. + */ + _setIsPrimary(value: boolean): void { + this._isPrimary = value; + } + + /** + * @internal + */ + _setState(value: WindowState): void { + this._state = value; + } + + get ios(): { readonly scene?: UIWindowScene; readonly uiWindow: UIWindow } | undefined { + return undefined; + } + + get android(): { readonly activity: androidx.appcompat.app.AppCompatActivity } | undefined { + return undefined; + } + + /** + * Close this window. + */ + abstract close(): void; + + // --- Typed event overloads --- + + on(event: 'activate', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + on(event: 'deactivate', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + on(event: 'background', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + on(event: 'foreground', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + on(event: 'close', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + on(event: 'displayed', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + on(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + on(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + on(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + on(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + on(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(eventName: string, callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + on(eventName: string, callback: (data: any) => void, thisArg?: any): void { + super.on(eventName, callback, thisArg); + } + + /** + * @internal – emit a window lifecycle event. + */ + _notifyEvent(eventName: NativeWindowEventName): void { + this.notify({ + eventName, + window: this, + object: this, + }); + } + + /** + * @internal – called when the window is being torn down. + */ + _destroy(): void { + this._state = 'closed'; + } +} diff --git a/packages/core/utils/native-helper.d.ts b/packages/core/utils/native-helper.d.ts index 02caa01595..ef3a803a82 100644 --- a/packages/core/utils/native-helper.d.ts +++ b/packages/core/utils/native-helper.d.ts @@ -14,11 +14,11 @@ export function dataDeserialize(nativeData?: any): any; */ export function isRealDevice(): boolean; -type NativeWindow = android.view.Window | UIWindow; +type PlatformWindow = android.view.Window | UIWindow; /** * Get the UIWindow or android.view.Window of the app */ -export function getWindow(): T; +export function getWindow(): T; /** * Utilities related to Android. From 59c8955e81239674912d2c871955f7864c29567b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 17:03:21 -0300 Subject: [PATCH 07/23] feat(core): session-scoped window identity, attach/detach, teardown, exit rewire A window now survives its native surface going away: iOS keys identity off UISceneSession.persistentIdentifier, Android mints a UUID persisted in savedInstanceState. A scene disconnect or an activity recreation detaches the window - it stays registered and keeps its listeners - while a real close ends the session, fires 'close' exactly once and then clears the instance listeners. Android 'exit' now fires when the last window goes away rather than for any finishing activity; iOS 'exit' remains process termination. --- .../core/application/application-common.ts | 10 ++- .../core/application/application.android.ts | 71 +++++++++++++--- packages/core/application/application.ios.ts | 85 ++++++++++++++++--- packages/core/data/observable/index.ts | 10 +++ .../native-window/native-window-common.ts | 32 ++++++- .../native-window/native-window-interfaces.ts | 23 ++++- .../native-window/native-window.android.ts | 24 ++++-- .../core/native-window/native-window.ios.ts | 73 +++++++++------- packages/core/native-window/window-base.ts | 18 +++- packages/core/ui/frame/index.android.ts | 13 --- 10 files changed, 276 insertions(+), 83 deletions(-) diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index 5a4dc168b1..2737faacfe 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -16,7 +16,7 @@ import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessib import { getAppMainEntry, isAppInBackground, setAppInBackground, setAppMainEntry } from './helpers-common'; import { getNativeScriptGlobals } from '../globals/global-utils'; import { SDK_VERSION } from '../utils/constants'; -import type { WindowCloseEventData, WindowOpenEventData } from '../native-window'; +import type { PrimaryWindowChangedEventData, WindowCloseEventData, WindowOpenEventData } from '../native-window'; // prettier-ignore const ORIENTATION_CSS_CLASSES = [ @@ -103,6 +103,9 @@ interface ApplicationEvents { /** * This event is raised when the Application is about to exit. + * + * On Android it is raised when the last window closes; the process may stay alive. + * On iOS it is raised when the process itself terminates. */ on(event: 'exit', callback: (args: ApplicationEventData) => void, thisArg?: any): void; @@ -143,6 +146,7 @@ interface ApplicationEvents { on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; on(event: 'windowClose', callback: (args: WindowCloseEventData) => void, thisArg?: any): void; + on(event: 'primaryWindowChanged', callback: (args: PrimaryWindowChangedEventData) => void, thisArg?: any): void; } export class ApplicationCommon { @@ -152,6 +156,10 @@ export class ApplicationCommon { readonly backgroundEvent = 'background'; readonly foregroundEvent = 'foreground'; readonly resumeEvent = 'resume'; + /** + * On Android, raised when the last window closes; the process may stay alive. + * On iOS, raised when the process itself terminates. + */ readonly exitEvent = 'exit'; readonly lowMemoryEvent = 'lowMemory'; readonly uncaughtErrorEvent = 'uncaughtError'; diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index 6a11cbde45..f6dbedce66 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -54,6 +54,8 @@ import lazy from '../utils/lazy'; declare class NativeScriptLifecycleCallbacks extends android.app.Application.ActivityLifecycleCallbacks {} +const WINDOW_ID_EXTRA = 'com.tns.activity.windowId'; + let NativeScriptLifecycleCallbacks_: typeof NativeScriptLifecycleCallbacks; function initNativeScriptLifecycleCallbacks() { if (NativeScriptLifecycleCallbacks_) { @@ -83,10 +85,22 @@ function initNativeScriptLifecycleCallbacks() { } // Create and register NativeWindow for this activity - const isPrimary = Application.android._getWindows().length === 0; - const nativeWindowId = AndroidNativeWindow.getActivityId(activity); - const nativeWindow = new AndroidNativeWindow(activity, nativeWindowId, isPrimary); - Application.android._registerWindow(nativeWindow); + const savedWindowId = savedInstanceState?.getString(WINDOW_ID_EXTRA); + const knownWindow = savedWindowId ? (Application.android._getWindowById(savedWindowId) as AndroidNativeWindow) : undefined; + let nativeWindow: AndroidNativeWindow; + + if (knownWindow?.state === 'detached') { + // The activity was recreated (rotation, theme change): the same window + // instance carries on, keeping its listeners and identity. + knownWindow._reattach(activity); + nativeWindow = knownWindow; + } else { + const isPrimary = Application.android._getWindows().length === 0; + nativeWindow = new AndroidNativeWindow(activity, savedWindowId || AndroidNativeWindow.newWindowId(), isPrimary); + Application.android._registerWindow(nativeWindow); + } + + nativeWindow._notifyEvent(NativeWindowEvents.attached); this.notifyActivityCreated(activity, savedInstanceState, nativeWindow); @@ -115,10 +129,16 @@ function initNativeScriptLifecycleCallbacks() { } } - // Unregister NativeWindow for this activity const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { - nativeWindow._notifyEvent(NativeWindowEvents.close); + // A destroyed activity only ends the window session when it is finishing — + // otherwise Android is recreating it and the same window is reused. + const isClosing = activity.isFinishing(); + + if (isClosing) { + nativeWindow._notifyEvent(NativeWindowEvents.close); + } + // Emit activityDestroyed on NativeWindow nativeWindow.notify({ eventName: NativeWindowEvents.activityDestroyed, @@ -126,7 +146,12 @@ function initNativeScriptLifecycleCallbacks() { window: nativeWindow, activity, } as AndroidActivityEventData); - Application.android._unregisterWindow(nativeWindow); + + if (isClosing) { + Application.android._unregisterWindow(nativeWindow); + } else { + nativeWindow._detach(); + } } Application.android.notify({ @@ -136,6 +161,16 @@ function initNativeScriptLifecycleCallbacks() { activity, } as AndroidActivityEventData); + // This callback runs for every activity in the process, not just NativeScript ones, + // so an empty registry here means the app really has no window left. + if (activity.isFinishing() && Application.android._getWindows().length === 0) { + Application.android.notify({ + eventName: Application.exitEvent, + object: Application.android, + android: activity, + }); + } + // TODO: This is a temporary workaround to force the V8's Garbage Collector, which will force the related Java Object to be collected. gc(); } @@ -206,6 +241,9 @@ function initNativeScriptLifecycleCallbacks() { // Emit on NativeWindow first const nativeWindow = Application.android._getWindowForActivity(activity); if (nativeWindow) { + // Carries the window identity across activity recreation. + bundle.putString(WINDOW_ID_EXTRA, nativeWindow.id); + nativeWindow.notify({ eventName: NativeWindowEvents.saveActivityState, object: nativeWindow, @@ -664,12 +702,23 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp object: this, window: nativeWindow, }); - nativeWindow._destroy(); - // If primary was removed, promote next window - if (nativeWindow.isPrimary && this._windows.length > 0) { - this._windows[0]._setIsPrimary(true); + // If primary was removed, promote the next window that can actually host content + if (nativeWindow.isPrimary) { + nativeWindow._setIsPrimary(false); + + const promoted = this.getWindows().find((nw) => nw.state === 'attached'); + if (promoted) { + promoted._setIsPrimary(true); + this.notify({ + eventName: WindowEvents.primaryWindowChanged, + object: this, + window: promoted, + }); + } } + + nativeWindow._destroy(); } /** diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index 3399f7aafe..9cb9f8ac76 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -193,8 +193,7 @@ if (supportsScenes()) { // scene session destruction handling (Responder.prototype as UIApplicationDelegate).applicationDidDiscardSceneSessions = function (application: UIApplication, sceneSessions: NSSet): void { - // Note: we could emit an event here if needed - // console.log('Scene sessions discarded:', sceneSessions.count); + Application.ios._onSceneSessionsDiscarded(sceneSessions); }; } @@ -236,12 +235,24 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { this._window.backgroundColor = SDK_VERSION <= 12 || !UIColor.systemBackgroundColor ? UIColor.whiteColor : UIColor.systemBackgroundColor; } - const isPrimary = isFirstScene || !Application.ios.primaryWindow; const nativeWindowId = IOSNativeWindow.getSceneId(windowScene); + const knownWindow = nativeWindowId ? (Application.ios._getWindowById(nativeWindowId) as IOSNativeWindow) : undefined; + let nativeWindow: IOSNativeWindow; + + if (knownWindow?.state === 'detached') { + // iOS reconnected a session we already have a window for: the same window + // instance carries on, keeping its listeners and identity. + nativeWindow = knownWindow; + nativeWindow._reattach(windowScene, this._window); + } else { + const isPrimary = isFirstScene || !Application.ios.primaryWindow; + nativeWindow = new IOSNativeWindow(windowScene, this._window, nativeWindowId, isPrimary); + Application.ios._registerWindow(nativeWindow); + } - // Create NativeWindow and register it - const nativeWindow = new IOSNativeWindow(windowScene, this._window, nativeWindowId, isPrimary); - Application.ios._registerWindow(nativeWindow); + const isPrimary = nativeWindow.isPrimary; + + nativeWindow._notifyEvent(NativeWindowEvents.attached); if (isPrimary) { // For primary, also set the legacy global window reference @@ -403,7 +414,15 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { const windowScene = scene as UIWindowScene; const nativeWindow = Application.ios._getWindowForScene(windowScene); if (nativeWindow) { - nativeWindow._notifyEvent(NativeWindowEvents.close); + // A disconnect only ends the window session when the app asked for it — + // otherwise iOS may reconnect the same session later. A window with no session + // identity is the exception: a reconnect could never be matched back to it. + const isClosing = nativeWindow._closeRequested || !nativeWindow._hasSessionIdentity; + + if (isClosing) { + nativeWindow._notifyEvent(NativeWindowEvents.close); + } + // Emit sceneDidDisconnect on NativeWindow nativeWindow.notify({ eventName: NativeWindowEvents.sceneDidDisconnect, @@ -411,7 +430,12 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { window: nativeWindow, scene: windowScene, } as SceneEventData); - Application.ios._unregisterWindow(nativeWindow); + + if (isClosing) { + Application.ios._unregisterWindow(nativeWindow); + } else { + nativeWindow._detach(); + } } Application.ios.notify({ @@ -1126,15 +1150,48 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication object: this, window: nativeWindow, }); + + // If primary was removed, promote the next window that can actually host content + if (nativeWindow.isPrimary) { + nativeWindow._setIsPrimary(false); + + const promoted = this.getWindows().find((nw) => nw.state === 'attached'); + if (promoted) { + promoted._setIsPrimary(true); + const promotedWindow = promoted.ios?.uiWindow; + if (promotedWindow) { + setiOSWindow(promotedWindow); + } + this.notify({ + eventName: WindowEvents.primaryWindowChanged, + object: this, + window: promoted, + }); + } + } + nativeWindow._destroy(); + } - // If primary was removed, promote next window - if (nativeWindow.isPrimary && this._windows.length > 0) { - this._windows[0]._setIsPrimary(true); - const promotedWindow = this._windows[0].ios?.uiWindow; - if (promotedWindow) { - setiOSWindow(promotedWindow); + /** + * @internal - iOS reports discarded sessions for windows this JS context may never + * have seen (they can arrive on a later launch), so unknown ids are ignored. + */ + _onSceneSessionsDiscarded(sessions: NSSet): void { + const all = sessions?.allObjects; + if (!all) { + return; + } + + for (let i = 0; i < all.count; i++) { + const persistentIdentifier = all.objectAtIndex(i)?.persistentIdentifier; + const nativeWindow = persistentIdentifier ? this._windows.find((nw) => nw.id === `${persistentIdentifier}`) : undefined; + if (!nativeWindow) { + continue; } + + nativeWindow._notifyEvent(NativeWindowEvents.close); + this._unregisterWindow(nativeWindow); } } diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index 40555644fd..323ce20149 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -266,6 +266,16 @@ export class Observable { } } + /** + * Removes every listener registered on this instance. + * @internal + */ + public _clearEventListeners(): void { + for (const eventName in this._observers) { + delete this._observers[eventName]; + } + } + /** * Please avoid using the static event-handling APIs as they will be removed * in future. diff --git a/packages/core/native-window/native-window-common.ts b/packages/core/native-window/native-window-common.ts index 6e50dbedf7..a9ec977b68 100644 --- a/packages/core/native-window/native-window-common.ts +++ b/packages/core/native-window/native-window-common.ts @@ -125,6 +125,8 @@ export abstract class NativeWindow extends WindowBase { on(event: 'background', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'foreground', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'close', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'attached', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'detached', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'displayed', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; @@ -289,11 +291,35 @@ export abstract class NativeWindow extends WindowBase { } /** - * @internal – called when the window is being torn down. + * @internal – the native surface went away but the window session lives on. + * + * The window stays registered and keeps its listeners, so app code that subscribed + * to it keeps working once a surface re-attaches. */ - _destroy(): void { - super._destroy(); + _detach(): void { if (this._rootView) { + if (this._rootView.isLoaded) { + this._rootView.callUnloaded(); + } + this._rootView._tearDownUI(true); + this._rootView._onRootViewReset(); + } + + // These traits belong to the native surface, so a re-attached window has to read them again. + this._orientation = null; + this._systemAppearance = null; + this._layoutDirection = null; + + this._setState('detached'); + this._notifyEvent(NativeWindowEvents.detached); + } + + protected _onDestroy(): void { + super._onDestroy(); + if (this._rootView) { + if (this._rootView.isLoaded) { + this._rootView.callUnloaded(); + } this._rootView._onRootViewReset(); this._rootView = null; } diff --git a/packages/core/native-window/native-window-interfaces.ts b/packages/core/native-window/native-window-interfaces.ts index 9104a2c39a..cba7fe2c8a 100644 --- a/packages/core/native-window/native-window-interfaces.ts +++ b/packages/core/native-window/native-window-interfaces.ts @@ -14,8 +14,19 @@ export const NativeWindowEvents = { background: 'background', /** Fired when the window enters the foreground. */ foreground: 'foreground', - /** Fired when the window is being closed/destroyed. */ + /** + * Fired when the window session ends for good. Fires at most once per window; + * every listener on the window is dropped right after it is dispatched. + */ close: 'close', + /** Fired when a native surface is bound to the window, both on first connect and on every re-attach. */ + attached: 'attached', + /** + * Fired when the native surface goes away while the window session stays alive + * (iOS scene disconnect, Android activity recreation). The window stays registered + * and keeps its listeners, so the same instance is reused when `attached` fires again. + */ + detached: 'detached', /** Fired after the window content has been displayed for the first time. */ displayed: 'displayed', /** Fired when the root view content is set or changed. */ @@ -70,6 +81,8 @@ export const WindowEvents = { windowOpen: 'windowOpen', /** Fired on Application when a NativeWindow is closed/destroyed. */ windowClose: 'windowClose', + /** Fired on Application when another window takes over the primary role. */ + primaryWindowChanged: 'primaryWindowChanged', } as const; /** @@ -104,6 +117,14 @@ export interface WindowCloseEventData extends EventData { window: NativeWindow; } +/** + * Event data fired on Application when the primary window changes. + */ +export interface PrimaryWindowChangedEventData extends EventData { + /** The NativeWindow that is now primary. */ + window: NativeWindow; +} + /** * Options for opening a new window. */ diff --git a/packages/core/native-window/native-window.android.ts b/packages/core/native-window/native-window.android.ts index cefbbb424d..52ded11e15 100644 --- a/packages/core/native-window/native-window.android.ts +++ b/packages/core/native-window/native-window.android.ts @@ -12,11 +12,19 @@ import { NativeWindow } from './native-window-common'; export class AndroidNativeWindow extends NativeWindow { private _activity: WeakRef; - constructor(activity: androidx.appcompat.app.AppCompatActivity, id: string, isPrimary = false) { + constructor(activity: androidx.appcompat.app.AppCompatActivity, id?: string, isPrimary = false) { super(id, isPrimary); this._activity = new WeakRef(activity); } + /** + * @internal – bind a recreated activity to this window session after a detach. + */ + _reattach(activity: androidx.appcompat.app.AppCompatActivity): void { + this._activity = new WeakRef(activity); + this._setState('attached'); + } + /** * The wrapped Android Activity (may be GC'd). */ @@ -127,18 +135,16 @@ export class AndroidNativeWindow extends NativeWindow { } } - /** - * @internal - */ - _destroy(): void { - super._destroy(); + protected _onDestroy(): void { + super._onDestroy(); this._activity = null; } /** - * Gets a stable identifier from an Activity. + * Mints a window identity. Android has no stable activity id, so the value is kept + * in the activity saved state to survive recreation (rotation, theme change). */ - static getActivityId(activity: androidx.appcompat.app.AppCompatActivity): string { - return `activity-${activity.hashCode()}`; + static newWindowId(): string { + return `window-${java.util.UUID.randomUUID().toString()}`; } } diff --git a/packages/core/native-window/native-window.ios.ts b/packages/core/native-window/native-window.ios.ts index 52c54af1ab..e59d49f46b 100644 --- a/packages/core/native-window/native-window.ios.ts +++ b/packages/core/native-window/native-window.ios.ts @@ -2,6 +2,7 @@ import type { View } from '../ui/core/view'; import { IOSHelper } from '../ui/core/view/view-helper'; import { SDK_VERSION } from '../utils/constants'; import { CoreTypes } from '../core-types'; +import { Trace } from '../trace'; import { NativeWindow } from './native-window-common'; import { NativeWindowEvents } from './native-window-interfaces'; @@ -13,12 +14,34 @@ export class IOSNativeWindow extends NativeWindow { private _scene: UIWindowScene; private _window: UIWindow; - constructor(scene: UIWindowScene, window: UIWindow, id: string, isPrimary = false) { + /** + * @internal – set while a scene session destruction request is in flight, so the + * following scene disconnect is read as a close rather than a detach. + */ + _closeRequested = false; + + /** + * @internal – whether the id comes from a scene session identity. Without one the + * window cannot be matched to a reconnecting session or to a discarded one. + */ + _hasSessionIdentity: boolean; + + constructor(scene: UIWindowScene, window: UIWindow, id?: string, isPrimary = false) { super(id, isPrimary); + this._hasSessionIdentity = !!id; this._scene = scene; this._window = window; } + /** + * @internal – bind a new scene/window pair to this window session after a detach. + */ + _reattach(scene: UIWindowScene, uiWindow: UIWindow): void { + this._scene = scene; + this._window = uiWindow; + this._setState('attached'); + } + get ios() { return { scene: this._scene, @@ -69,8 +92,10 @@ export class IOSNativeWindow extends NativeWindow { const app = UIApplication.sharedApplication; if (app.requestSceneSessionDestructionOptionsErrorHandler) { + this._closeRequested = true; app.requestSceneSessionDestructionOptionsErrorHandler(session, null, (error: NSError) => { if (error) { + this._closeRequested = false; console.log('NativeWindow: Error destroying scene session:', error.localizedDescription); } }); @@ -173,43 +198,33 @@ export class IOSNativeWindow extends NativeWindow { } } - /** - * @internal - */ - _destroy(): void { - // Remove trait collection listeners from root view before destroying + protected _onDestroy(): void { + // The trait collection listeners live on the root view, so they have to go + // before the base drops the reference to it. if (this._rootView) { this._rootView.off(IOSHelper.traitCollectionColorAppearanceChangedEvent); this._rootView.off(IOSHelper.traitCollectionLayoutDirectionChangedEvent); } - super._destroy(); + super._onDestroy(); this._scene = null; this._window = null; } /** - * Gets the stable scene identifier. + * The window identity of a scene: the session persistent identifier, which iOS keeps + * across a disconnect and hands back when it reconnects the same session. + * + * Returns `undefined` when the scene carries no session identity — such a window gets + * a minted id and will not be recognised on reconnect. */ - static getSceneId(scene: UIWindowScene): string { - try { - if (!scene) { - return 'unknown'; - } - const session = scene.session; - const persistentId = session?.persistentIdentifier; - if (persistentId) { - return `${persistentId}`; - } - if (scene.hash != null) { - return `${scene.hash}`; - } - const desc = scene.description; - if (desc) { - return `${desc}`; - } - } catch { - // ignore - } - return 'unknown'; + static getSceneId(scene: UIWindowScene): string | undefined { + const persistentIdentifier = scene?.session?.persistentIdentifier; + if (persistentIdentifier) { + return `${persistentIdentifier}`; + } + + Trace.write('NativeWindow: scene has no session persistentIdentifier; window identity will not survive a reconnect.', Trace.categories.NativeLifecycle, Trace.messageType.error); + + return undefined; } } diff --git a/packages/core/native-window/window-base.ts b/packages/core/native-window/window-base.ts index 1d4d71cbc7..b32741ab6c 100644 --- a/packages/core/native-window/window-base.ts +++ b/packages/core/native-window/window-base.ts @@ -92,6 +92,8 @@ export abstract class WindowBase extends Observable { on(event: 'background', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; on(event: 'foreground', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; on(event: 'close', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + on(event: 'attached', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + on(event: 'detached', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; on(event: 'displayed', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; @@ -127,9 +129,21 @@ export abstract class WindowBase extends Observable { } /** - * @internal – called when the window is being torn down. + * @internal – ends the window session for good. + * + * Listeners stay live through the whole teardown and are dropped last, so handlers + * registered on this instance can still observe `close` yet never outlive the window. */ _destroy(): void { - this._state = 'closed'; + this._setState('closed'); + this._onDestroy(); + this._clearEventListeners(); + } + + /** + * Teardown hook for subclasses. Runs while the listeners are still registered. + */ + protected _onDestroy(): void { + // noop } } diff --git a/packages/core/ui/frame/index.android.ts b/packages/core/ui/frame/index.android.ts index d586a5ef21..29ecedd451 100644 --- a/packages/core/ui/frame/index.android.ts +++ b/packages/core/ui/frame/index.android.ts @@ -987,19 +987,6 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks if (rootView) { rootView._tearDownUI(true); } - - // this may happen when the user changes the system theme - // In such case, isFinishing() is false (and isChangingConfigurations is true), and the app will start again (onCreate) with a savedInstanceState - // as a result, launchEvent will never be called - // possible alternative: always fire launchEvent and exitEvent, but pass extra flags to make it clear what kind of launch/destroy is happening - if (activity.isFinishing()) { - const exitArgs = { - eventName: Application.exitEvent, - object: Application.android, - android: activity, - }; - Application.notify(exitArgs); - } } finally { superFunc.call(activity); } From a70f4c539d205dd6dc21c54e89fad48dd1f67e87 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 17:13:24 -0300 Subject: [PATCH 08/23] refactor(core): route all iOS window content through NativeWindow (ownership inversion) Every iOS app now registers at least one window - scene, non-scene and embedded alike - and all content flows through NativeWindow.setContent(). The app-level root view state (getRootView(), the global root view and the initRootView event) mirrors whatever the primary window shows. Embedded windows hand their controller to the embedder delegate and never touch rootViewController or makeKeyAndVisible: the host app owns the window. --- packages/core/application/application.ios.ts | 162 +++++++++++++----- .../core/native-window/native-window.ios.ts | 23 ++- 2 files changed, 130 insertions(+), 55 deletions(-) diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index 9cb9f8ac76..9bc3c60f47 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -15,6 +15,7 @@ import { IOSNativeWindow } from '../native-window/native-window.ios'; import { NativeWindow } from '../native-window/native-window-common'; import type { WindowBase, WindowRole } from '../native-window/window-base'; import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; +import type { NativeWindowEventData } from '../native-window/native-window-interfaces'; import { AccessibilityServiceEnabledPropName, CommonA11YServiceEnabledObservable, @@ -97,6 +98,7 @@ class CADisplayLinkTarget extends NSObject { object: owner, ios: UIApplication.sharedApplication, }); + owner.primaryWindow?._notifyEvent(NativeWindowEvents.displayed); owner.displayedLinkTarget = null; owner.displayedLink = null; } @@ -467,6 +469,11 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // NativeWindow registry private _windows: IOSNativeWindow[] = []; + // The window the app-level root view state mirrors, and the root view currently + // carrying the app-level trait collection listeners. + private _mirroredWindow: NativeWindow; + private _appTraitListenerView: View; + private _notificationObservers: NotificationObserver[] = []; // Strong references to delegates recreated after an in-process soft reboot @@ -629,35 +636,18 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication return; } - const controller = this.getViewController(rootView); - - rootView._setupAsRootView({}); - - rootView.on(IOSHelper.traitCollectionColorAppearanceChangedEvent, () => { - const userInterfaceStyle = controller.traitCollection.userInterfaceStyle; - const newSystemAppearance = this.getSystemAppearanceValue(userInterfaceStyle); - this.setSystemAppearance(newSystemAppearance); - }); - - rootView.on(IOSHelper.traitCollectionLayoutDirectionChangedEvent, () => { - const layoutDirection = controller.traitCollection.layoutDirection; - const newLayoutDirection = this.getLayoutDirectionValue(layoutDirection); - this.setLayoutDirection(newLayoutDirection); - }); - - if (embedderDelegate) { - // Embed into host app. - // present over the host's existing root view controller. - this.setViewControllerView(rootView); - embedderDelegate.presentNativeScriptApp(controller); - } else { - // No embedder delegate = NativeScript owns the UIApplication. - // Attach the root to the window. - this.setViewControllerView(rootView); - this.setWindowRootView(window, rootView); + let hostWindow = this.primaryWindow as IOSNativeWindow; + if (!hostWindow) { + // Only an embedder delegate makes this window a guest: without one NativeScript + // owns the UIApplication and the window has to attach its own content. + const role: WindowRole = embedderDelegate ? 'embedded' : 'application'; + hostWindow = new IOSNativeWindow(window.windowScene ?? undefined, window, 'embedded-main', true, role); + this._registerWindow(hostWindow); + hostWindow._notifyEvent(NativeWindowEvents.attached); } - this.initRootView(rootView); + hostWindow.setContent(rootView); + this.notifyAppStarted(); } @@ -975,15 +965,27 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } private setWindowContent(view?: View): void { + const rootView = this.createRootView(view); + const primaryWindow = this.primaryWindow; + + if (primaryWindow) { + primaryWindow.setContent(rootView); + return; + } + + this.setWindowContentFallback(rootView); + } + + /** + * Attaches content to the raw `UIWindow`. Every launch path registers a primary + * NativeWindow, so this only runs when no window is left to own the content. + */ + private setWindowContentFallback(rootView: View): void { if (this._rootView) { - // if we already have a root view, we reset it. this._rootView._onRootViewReset(); } - const rootView = this.createRootView(view); - const controller = this.getViewController(rootView); - this._rootView = rootView; - setRootView(rootView); + const controller = this.getViewController(rootView); // setup view as styleScopeHost rootView._setupAsRootView({}); @@ -991,7 +993,6 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication this.setViewControllerView(rootView); const win = this.window; - const haveController = win.rootViewController !== null; win.rootViewController = controller; @@ -999,20 +1000,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication win.makeKeyAndVisible(); } - this.initRootView(rootView); - - rootView.on(IOSHelper.traitCollectionColorAppearanceChangedEvent, () => { - const userInterfaceStyle = controller.traitCollection.userInterfaceStyle; - const newSystemAppearance = this.getSystemAppearanceValue(userInterfaceStyle); - - this.setSystemAppearance(newSystemAppearance); - }); - - rootView.on(IOSHelper.traitCollectionLayoutDirectionChangedEvent, () => { - const layoutDirection = controller.traitCollection.layoutDirection; - const newLayoutDirection = this.getLayoutDirectionValue(layoutDirection); - this.setLayoutDirection(newLayoutDirection); - }); + this.adoptRootView(rootView); } // Observers @@ -1049,6 +1037,12 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication this.window.backgroundColor = SDK_VERSION <= 12 || !UIColor.systemBackgroundColor ? UIColor.whiteColor : UIColor.systemBackgroundColor; } + if (!this.primaryWindow) { + const nativeWindow = new IOSNativeWindow(undefined, this.window, 'main', true, 'application'); + this._registerWindow(nativeWindow); + nativeWindow._notifyEvent(NativeWindowEvents.attached); + } + this.launchEventCalled = false; if (!this.shouldDelayLaunchEvent) { this.notifyAppStarted(notification); @@ -1123,6 +1117,74 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication this.setOrientation(newOrientation); } + // --- App-level root view mirror --- + + /** + * Keeps the app-level root view state (`getRootView()`, the global root view and the + * `initRootView` event) following whatever the primary window shows. + */ + private mirrorPrimaryWindow(nativeWindow: NativeWindow): void { + if (this._mirroredWindow === nativeWindow) { + return; + } + + this._mirroredWindow?.off(NativeWindowEvents.contentLoaded, this.onPrimaryWindowContentLoaded, this); + this._mirroredWindow = nativeWindow; + nativeWindow.on(NativeWindowEvents.contentLoaded, this.onPrimaryWindowContentLoaded, this); + + if (nativeWindow.rootView && nativeWindow.rootView !== this._rootView) { + this.adoptRootView(nativeWindow.rootView); + } + } + + private onPrimaryWindowContentLoaded(data: NativeWindowEventData): void { + this.adoptRootView(data.window.rootView); + } + + private adoptRootView(rootView: View): void { + if (!rootView) { + return; + } + + const previous = this._appTraitListenerView; + if (previous && previous !== rootView) { + previous.off(IOSHelper.traitCollectionColorAppearanceChangedEvent, this.onRootViewColorAppearanceChanged, this); + previous.off(IOSHelper.traitCollectionLayoutDirectionChangedEvent, this.onRootViewLayoutDirectionChanged, this); + this._appTraitListenerView = null; + } + + this._rootView = rootView; + setRootView(rootView); + this.initRootView(rootView); + + if (this._appTraitListenerView !== rootView) { + rootView.on(IOSHelper.traitCollectionColorAppearanceChangedEvent, this.onRootViewColorAppearanceChanged, this); + rootView.on(IOSHelper.traitCollectionLayoutDirectionChangedEvent, this.onRootViewLayoutDirectionChanged, this); + this._appTraitListenerView = rootView; + } + } + + private onRootViewColorAppearanceChanged(): void { + const controller = this.rootViewController(); + if (!controller) { + return; + } + this.setSystemAppearance(this.getSystemAppearanceValue(controller.traitCollection.userInterfaceStyle)); + } + + private onRootViewLayoutDirectionChanged(): void { + const controller = this.rootViewController(); + if (!controller) { + return; + } + this.setLayoutDirection(this.getLayoutDirectionValue(controller.traitCollection.layoutDirection)); + } + + private rootViewController(): UIViewController { + const rootView = this._rootView; + return rootView ? ((rootView.viewController || rootView.ios) as UIViewController) : null; + } + // --- NativeWindow registry --- /** @@ -1130,6 +1192,11 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication */ _registerWindow(nativeWindow: IOSNativeWindow): void { this._windows.push(nativeWindow); + + if (nativeWindow.isPrimary) { + this.mirrorPrimaryWindow(nativeWindow); + } + this.notify({ eventName: WindowEvents.windowOpen, object: this, @@ -1162,6 +1229,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication if (promotedWindow) { setiOSWindow(promotedWindow); } + this.mirrorPrimaryWindow(promoted); this.notify({ eventName: WindowEvents.primaryWindowChanged, object: this, diff --git a/packages/core/native-window/native-window.ios.ts b/packages/core/native-window/native-window.ios.ts index e59d49f46b..b52f06df30 100644 --- a/packages/core/native-window/native-window.ios.ts +++ b/packages/core/native-window/native-window.ios.ts @@ -5,13 +5,14 @@ import { CoreTypes } from '../core-types'; import { Trace } from '../trace'; import { NativeWindow } from './native-window-common'; import { NativeWindowEvents } from './native-window-interfaces'; +import type { WindowRole } from './window-base'; /** * iOS implementation of NativeWindow. - * Wraps a UIWindowScene + UIWindow pair. + * Wraps a UIWindow and, when the app is scene-based, the UIWindowScene hosting it. */ export class IOSNativeWindow extends NativeWindow { - private _scene: UIWindowScene; + private _scene: UIWindowScene | undefined; private _window: UIWindow; /** @@ -26,8 +27,8 @@ export class IOSNativeWindow extends NativeWindow { */ _hasSessionIdentity: boolean; - constructor(scene: UIWindowScene, window: UIWindow, id?: string, isPrimary = false) { - super(id, isPrimary); + constructor(scene: UIWindowScene | undefined, window: UIWindow, id?: string, isPrimary = false, role: WindowRole = 'application') { + super(id, isPrimary, role); this._hasSessionIdentity = !!id; this._scene = scene; this._window = window; @@ -56,11 +57,17 @@ export class IOSNativeWindow extends NativeWindow { const controller = this._getViewController(view); this._setViewControllerView(view); - const haveController = this._window.rootViewController !== null; - this._window.rootViewController = controller; + if (this.role === 'embedded') { + // The host app owns this UIWindow: its rootViewController and key/visible state + // are not ours to change, so the content is handed over as a view controller. + NativeScriptEmbedder.sharedInstance().delegate?.presentNativeScriptApp(controller); + } else { + const haveController = this._window.rootViewController !== null; + this._window.rootViewController = controller; - if (!haveController) { - this._window.makeKeyAndVisible(); + if (!haveController) { + this._window.makeKeyAndVisible(); + } } // Listen for trait collection changes per-window From 472a72a810aef812ec917216568d8aab833df032 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 17:20:00 -0300 Subject: [PATCH 09/23] refactor(core): route Android activity content through NativeWindow resetActivityContent() accepts an explicit view, so NativeWindow.setContent() no longer has its argument discarded and rebuilt from the main entry. Root views built by the activity pipeline are handed to their window through _adoptRootView(), which records the view and fires contentLoaded without redoing the setup the pipeline already performed. Embedded hosts get a window with role 'embedded'. --- .../core/application/application.android.ts | 5 ++++- packages/core/application/application.d.ts | 10 +++++++++ .../native-window/native-window-common.ts | 17 +++++++++++++++ .../native-window/native-window.android.ts | 7 ++++--- packages/core/ui/frame/frame-interfaces.ts | 2 +- packages/core/ui/frame/index.android.ts | 21 +++++++++++++++---- packages/core/ui/frame/index.d.ts | 2 +- 7 files changed, 54 insertions(+), 10 deletions(-) diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index f6dbedce66..81f74214c7 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -2,6 +2,7 @@ import { CoreTypes } from '../core-types'; import { profile } from '../profiling'; import type { View } from '../ui/core/view'; import { AndroidActivityCallbacks, NavigationEntry } from '../ui/frame/frame-common'; +import { isEmbedded } from '../ui/embedding'; import { SDK_VERSION } from '../utils/constants'; import { android as androidUtils } from '../utils'; import { ApplicationCommon } from './application-common'; @@ -96,7 +97,9 @@ function initNativeScriptLifecycleCallbacks() { nativeWindow = knownWindow; } else { const isPrimary = Application.android._getWindows().length === 0; - nativeWindow = new AndroidNativeWindow(activity, savedWindowId || AndroidNativeWindow.newWindowId(), isPrimary); + // The role is fixed at creation because it is immutable, and deciding it later would + // mean either a second window for this activity or a window with the wrong role. + nativeWindow = new AndroidNativeWindow(activity, savedWindowId || AndroidNativeWindow.newWindowId(), isPrimary, isEmbedded() ? 'embedded' : 'application'); Application.android._registerWindow(nativeWindow); } diff --git a/packages/core/application/application.d.ts b/packages/core/application/application.d.ts index c0461bbfef..b206c5cede 100644 --- a/packages/core/application/application.d.ts +++ b/packages/core/application/application.d.ts @@ -126,6 +126,16 @@ export class AndroidApplication extends ApplicationCommon { */ _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): NativeWindow | undefined; + /** + * @internal - Get all registered NativeWindows. + */ + _getWindows(): NativeWindow[]; + + /** + * @internal - Register a NativeWindow. + */ + _registerWindow(nativeWindow: NativeWindow): void; + /** * Get the primary NativeWindow. */ diff --git a/packages/core/native-window/native-window-common.ts b/packages/core/native-window/native-window-common.ts index a9ec977b68..eb9b6669a5 100644 --- a/packages/core/native-window/native-window-common.ts +++ b/packages/core/native-window/native-window-common.ts @@ -89,6 +89,23 @@ export abstract class NativeWindow extends WindowBase { this._notifyEvent(NativeWindowEvents.contentLoaded); } + /** + * @internal – take ownership of a root view the platform pipeline built and attached itself. + * + * The pipeline already ran `_setupAsRootView` and `Application.initRootView` on this view and + * installed it on the native surface, so neither `_applyRootViewSettings` nor `_setNativeContent` + * may run here — both would redo that work. + */ + _adoptRootView(view: View): void { + if (!view || this._rootView === view) { + return; + } + + this._rootView = view; + + this._notifyEvent(NativeWindowEvents.contentLoaded); + } + /** * Platform-specific: apply the view to the native window surface. */ diff --git a/packages/core/native-window/native-window.android.ts b/packages/core/native-window/native-window.android.ts index 52ded11e15..af35044cf0 100644 --- a/packages/core/native-window/native-window.android.ts +++ b/packages/core/native-window/native-window.android.ts @@ -4,6 +4,7 @@ import { SDK_VERSION } from '../utils/constants'; import { AndroidActivityCallbacks, NavigationEntry } from '../ui/frame/frame-common'; import { CALLBACKS } from '../ui/frame/frame-helper-for-android'; import { NativeWindow } from './native-window-common'; +import type { WindowRole } from './window-base'; /** * Android implementation of NativeWindow. @@ -12,8 +13,8 @@ import { NativeWindow } from './native-window-common'; export class AndroidNativeWindow extends NativeWindow { private _activity: WeakRef; - constructor(activity: androidx.appcompat.app.AppCompatActivity, id?: string, isPrimary = false) { - super(id, isPrimary); + constructor(activity: androidx.appcompat.app.AppCompatActivity, id?: string, isPrimary = false, role: WindowRole = 'application') { + super(id, isPrimary, role); this._activity = new WeakRef(activity); } @@ -53,7 +54,7 @@ export class AndroidNativeWindow extends NativeWindow { if (!callbacks) { throw new Error('NativeWindow: Cannot find activity callbacks.'); } - callbacks.resetActivityContent(activity); + callbacks.resetActivityContent(activity, view); } /** diff --git a/packages/core/ui/frame/frame-interfaces.ts b/packages/core/ui/frame/frame-interfaces.ts index 8e958816a6..78781f61bc 100644 --- a/packages/core/ui/frame/frame-interfaces.ts +++ b/packages/core/ui/frame/frame-interfaces.ts @@ -113,7 +113,7 @@ export interface AndroidFrame extends Observable { export interface AndroidActivityCallbacks { getRootView(): View; - resetActivityContent(activity: any): void; + resetActivityContent(activity: any, view?: View): void; onCreate(activity: any, savedInstanceState: any, intent: any, superFunc: Function): void; onSaveInstanceState(activity: any, outState: any, superFunc: Function): void; diff --git a/packages/core/ui/frame/index.android.ts b/packages/core/ui/frame/index.android.ts index 29ecedd451..2b8c3734fa 100644 --- a/packages/core/ui/frame/index.android.ts +++ b/packages/core/ui/frame/index.android.ts @@ -16,6 +16,7 @@ import { getAppMainEntry } from '../../application/helpers-common'; import { AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, AndroidActivityResultEventData } from '../../application/application-interfaces'; import { Application } from '../../application/application'; import { NativeWindowEvents } from '../../native-window/native-window-interfaces'; +import { AndroidNativeWindow } from '../../native-window/native-window.android'; import { isEmbedded, setEmbeddedView } from '../embedding'; import { CALLBACKS, FRAMEID, framesCache, setFragmentCallbacks } from './frame-helper-for-android'; import { SDK_VERSION } from '../../utils'; @@ -1101,7 +1102,7 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks Application.android.notify(resultArgs); } - public resetActivityContent(activity: androidx.appcompat.app.AppCompatActivity): void { + public resetActivityContent(activity: androidx.appcompat.app.AppCompatActivity, view?: View): void { if (this._rootView) { const manager = this._rootView._getFragmentManager(); manager.executePendingTransactions(); @@ -1112,7 +1113,7 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } // Delete previously cached root view in order to recreate it. this._rootView = null; - this.setActivityContent(activity, null, false); + this.setActivityContent(activity, null, false, view); this._rootView.callLoaded(); } @@ -1121,8 +1122,9 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks // 2. Application revived after Activity is destroyed. this._rootView should have been restored by id in onCreate. // 3. Livesync if rootView has no custom _onLivesync. this._rootView should have been cleared upfront. Launch event should not fired // 4. resetRootView method. this._rootView should have been cleared upfront. Launch event should not fired - private setActivityContent(activity: androidx.appcompat.app.AppCompatActivity, savedInstanceState: android.os.Bundle, fireLaunchEvent: boolean): void { - let rootView = this._rootView; + // 5. NativeWindow.setContent - the caller supplies the view, so nothing is resolved from the main entry. + private setActivityContent(activity: androidx.appcompat.app.AppCompatActivity, savedInstanceState: android.os.Bundle, fireLaunchEvent: boolean, view?: View): void { + let rootView = view ?? this._rootView; if (Trace.isEnabled()) { Trace.write(`Frame.setActivityContent rootView: ${rootView} shouldCreateRootFrame: false fireLaunchEvent: ${fireLaunchEvent}`, Trace.categories.NativeLifecycle); @@ -1156,6 +1158,17 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks // sets root classes once rootView is ready... Application.initRootView(rootView); + + let nativeWindow = Application.android._getWindowForActivity(activity); + + if (!nativeWindow && isEmbedded()) { + // When embedded, the host owns the activity and may never install our lifecycle + // callbacks, so this is the only place the window can come into existence. + nativeWindow = new AndroidNativeWindow(activity, AndroidNativeWindow.newWindowId(), Application.android._getWindows().length === 0, 'embedded'); + Application.android._registerWindow(nativeWindow); + } + + nativeWindow?._adoptRootView(rootView); } } diff --git a/packages/core/ui/frame/index.d.ts b/packages/core/ui/frame/index.d.ts index 41796031f2..caf7323d88 100644 --- a/packages/core/ui/frame/index.d.ts +++ b/packages/core/ui/frame/index.d.ts @@ -496,7 +496,7 @@ export interface AndroidFrame extends Observable { export interface AndroidActivityCallbacks { getRootView(): View; - resetActivityContent(activity: any): void; + resetActivityContent(activity: any, view?: View): void; onCreate(activity: any, savedInstanceState: any, intent: any, superFunc: Function): void; onSaveInstanceState(activity: any, outState: any, superFunc: Function): void; From ef5488bf0fe4f8eea9a40863e9107ec95059ae0b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 17:38:34 -0300 Subject: [PATCH 10/23] feat(core): 'ready' event, window content resolver, launch soft-deprecation 'launch' conflated process initialization with building the first window's UI, which cannot work for additional windows or for background launches. 'ready' now covers initialization - fired exactly once per JS context, never deferred, always before the first windowOpen - while setWindowContentResolver() supplies each window's UI on demand. 'launch' keeps working as a bridge for the first view-carrying window, including its 'root' three-state contract. shouldDelayLaunchEvent is a deprecated no-op. --- .../core/application/application-common.ts | 149 +++++++++++++++++- .../core/application/application.android.ts | 4 + packages/core/application/application.ios.ts | 81 +++++++--- packages/core/global-types.d.ts | 72 +++++---- packages/core/globals/global-utils.ts | 16 +- .../native-window/native-window-interfaces.ts | 30 ++++ packages/core/ui/frame/index.android.ts | 42 +++-- packages/core/vitest.setup.ts | 1 + 8 files changed, 323 insertions(+), 72 deletions(-) diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index 2737faacfe..f3fbb1f025 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -16,7 +16,7 @@ import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessib import { getAppMainEntry, isAppInBackground, setAppInBackground, setAppMainEntry } from './helpers-common'; import { getNativeScriptGlobals } from '../globals/global-utils'; import { SDK_VERSION } from '../utils/constants'; -import type { PrimaryWindowChangedEventData, WindowCloseEventData, WindowOpenEventData } from '../native-window'; +import type { NativeWindow, PrimaryWindowChangedEventData, WindowCloseEventData, WindowContentRequest, WindowContentResolver, WindowOpenEventData } from '../native-window'; // prettier-ignore const ORIENTATION_CSS_CLASSES = [ @@ -84,6 +84,11 @@ interface ApplicationEvents { */ on(event: 'launch', callback: (args: LaunchEventData) => void, thisArg?: any): void; + /** + * This event is raised once the JS context is initialized. + */ + on(event: 'ready', callback: (args: ApplicationEventData) => void, thisArg?: any): void; + /** * This event is raised after the application has performed most of its startup actions. * Its intent is to be suitable for measuring app startup times. @@ -150,7 +155,19 @@ 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. + */ readonly launchEvent = 'launch'; + /** + * Raised once per JS context, as soon as the context is initialized. It is never deferred, + * so it also fires on a background launch where no window is created. + * + * Guaranteed ordering: `ready` -> `windowOpen` -> raw connect/create events -> content + * resolution (the legacy `launch` bridge runs here, for the first window only) -> + * `contentLoaded` -> `activate`/`displayed`. + */ + readonly readyEvent = 'ready'; readonly suspendEvent = 'suspend'; readonly displayedEvent = 'displayed'; readonly backgroundEvent = 'background'; @@ -208,6 +225,10 @@ export class ApplicationCommon { private _inBackground: boolean = false; private _suspended: boolean = false; private _cssFile = './app.css'; + private _readyNotified = false; + private _appCssLoaded = false; + private _launchBridgeConsumed = false; + private _windowContentResolver: WindowContentResolver | null = null; protected mainEntry: NavigationEntry; @@ -388,8 +409,132 @@ export class ApplicationCommon { return getAppMainEntry(); } + /** + * Sets the callback that supplies the UI for windows that need content. + * Pass `null` to remove a previously set resolver. + */ + setWindowContentResolver(resolver: WindowContentResolver | null): void { + this._windowContentResolver = resolver ?? null; + } + + /** + * @returns The callback currently supplying window content, if any. + */ + getWindowContentResolver(): WindowContentResolver | null { + return this._windowContentResolver; + } + + /** + * @internal - raises `ready` at most once per JS context. + */ + notifyReady(): void { + if (this._readyNotified) { + return; + } + this._readyNotified = true; + getNativeScriptGlobals().setLaunched(); + + this.notify({ + eventName: this.readyEvent, + object: this, + ios: this.ios, + android: this.android, + }); + } + + /** + * @internal - produces the content for a window that has none. + * + * Resolution order: the window content resolver, then the legacy `launch` event + * (offered to the first window that asks for content and to no other), then the + * application main entry. A resolver or a `launch` handler returning `null` takes + * ownership of the content, so nothing else is tried. A missing main entry leaves + * the window empty instead of throwing, because content can still arrive later + * through `run()`/`resetRootView()`. + * + * @param options.install `false` returns the resolved view instead of applying it, + * for platform pipelines that install the root view on the native surface themselves. + * @param options.launchData platform payload merged into the legacy `launch` event args. + * @returns The resolved view, or `null` when no content was produced. + */ + _resolveWindowContent(window: NativeWindow, request: WindowContentRequest, options?: { install?: boolean; launchData?: any }): View | null { + const content = this.resolveWindowContent(request, options?.launchData); + + if (content == null) { + return null; + } + + if (options?.install === false) { + return this.buildContentView(content); + } + + window.setContent(content); + + return window.rootView; + } + + private resolveWindowContent(request: WindowContentRequest, launchData?: any): View | NavigationEntry | string | null | undefined { + const launchBridgeAvailable = !this._launchBridgeConsumed; + this._launchBridgeConsumed = true; + + const resolver = this._windowContentResolver; + if (resolver) { + const resolved = resolver(request); + + // `null` means the resolver supplies the content itself; only `undefined` falls through. + if (resolved !== undefined) { + this._ensureAppCssLoaded(); + + return resolved; + } + } + + if (launchBridgeAvailable) { + const root = this.notifyLaunch(launchData); + + if (root === null) { + return null; + } + + if (root) { + return root; + } + } + + this._ensureAppCssLoaded(); + + const mainEntry = getAppMainEntry(); + + return mainEntry ? Builder.createViewFromEntry(mainEntry) : undefined; + } + + private buildContentView(content: View | NavigationEntry | string): View { + if (typeof content === 'string') { + return Builder.createViewFromEntry({ moduleName: content }); + } + + const entry = content as NavigationEntry; + + return entry.moduleName || entry.create ? Builder.createViewFromEntry(entry) : (content as View); + } + + /** + * Loads the app CSS once per JS context. On the legacy `launch` path this has to run + * after the handlers, which are allowed to call `setCssFileName()`. + */ + private _ensureAppCssLoaded(): void { + if (this._appCssLoaded) { + return; + } + this._appCssLoaded = true; + + this.loadAppCss(); + } + @profile protected notifyLaunch(additionalLanchEventData?: any): View | null { + this._launchBridgeConsumed = true; + const launchArgs: LaunchEventData = { eventName: this.launchEvent, object: this, @@ -398,7 +543,7 @@ export class ApplicationCommon { ...additionalLanchEventData, }; this.notify(launchArgs); - this.loadAppCss(); + this._ensureAppCssLoaded(); return launchArgs.root; } diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index 81f74214c7..198760d7e8 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -575,6 +575,10 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp const nativeApp = this.getNativeApplication(); this.init(nativeApp); } + + // The activity lifecycle callbacks are registered but no activity has been created yet, + // so this always precedes the first `windowOpen`. + this.notifyReady(); } get startActivity() { diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index 9bc3c60f47..b900127268 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -5,7 +5,7 @@ import { IOSHelper } from '../ui/core/view/view-helper'; import type { NavigationEntry } from '../ui/frame/frame-interfaces'; import { getWindow } from '../utils/native-helper'; import { SDK_VERSION } from '../utils/constants'; -import { ios as iosUtils, dataSerialize } from '../utils/native-helper'; +import { ios as iosUtils, dataSerialize, dataDeserialize } from '../utils/native-helper'; import { ApplicationCommon } from './application-common'; import { ApplicationEventData, SceneEventData } from './application-interfaces'; import { Observable } from '../data/observable'; @@ -199,6 +199,21 @@ if (supportsScenes()) { }; } +/** + * Reads the payload `openWindow()` put on the activating NSUserActivity. + */ +function getSceneConnectionData(connectionOptions: UISceneConnectionOptions): Record | undefined { + const activities = connectionOptions?.userActivities; + + if (!activities || activities.count === 0) { + return undefined; + } + + const activity = activities.allObjects.objectAtIndex(0) as NSUserActivity; + + return activity?.userInfo ? (dataDeserialize(activity.userInfo) as Record) : undefined; +} + @NativeClass class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { static ObjCProtocols = [UIWindowSceneDelegate]; @@ -225,7 +240,7 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { } const windowScene = scene as UIWindowScene; - const isFirstScene = Application.ios._getWindows().length === 0 && !Application.hasLaunched(); + const isFirstScene = Application.ios._getWindows().length === 0; this._scene = windowScene; @@ -285,12 +300,15 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { this._window.makeKeyAndVisible(); } - // If this is the first scene, trigger app startup - if (isFirstScene) { - Application.ios._notifySceneAppStarted(); - } else if (isPrimary && Application.ios.hasLaunched()) { - // Primary scene reconnecting after disconnect — restore content - (Application.ios as any).setWindowContent(); + if (nativeWindow.role === 'application') { + // A re-attached window carries a torn down root view on a brand new UIWindow, + // so it needs its content resolved again just like a fresh one. + Application.ios._resolveWindowContent(nativeWindow, { + window: nativeWindow, + isPrimary, + data: getSceneConnectionData(connectionOptions), + ios: { connectionOptions }, + }); } } @@ -455,7 +473,8 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication private _delegate: UIApplicationDelegate; private _delegateHandlers = new Map>(); private _rootView: View; - private launchEventCalled = false; + /** Set when a background launch defers the primary window's content until the app first becomes active. */ + private _pendingWindowContentResolve: (() => void) | null; private _sceneDelegate: UIWindowSceneDelegate; /** * User-provided callback to intercept scene configuration. @@ -487,6 +506,9 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication displayedLinkTarget: CADisplayLinkTarget; displayedLink: CADisplayLink; + /** + * @deprecated Has no effect. Application initialization is signalled by the 'ready' event, which is never deferred. + */ shouldDelayLaunchEvent = false; /** @@ -564,6 +586,8 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } private runAsEmbeddedApp() { + this.notifyReady(); + this._reattachNativeDelegatesAfterSoftReboot(); // TODO: this rootView should be held alive until rootController dismissViewController is called. @@ -930,7 +954,6 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } private notifyAppStarted(notification?: NSNotification) { - this.launchEventCalled = true; const root = this.notifyLaunch({ ios: notification?.userInfo?.objectForKey('UIApplicationLaunchOptionsLocalNotificationKey') ?? null, }); @@ -944,11 +967,6 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } } - // Public method for scene-based app startup - _notifySceneAppStarted() { - this.notifyAppStarted(); - } - public _onLivesync(context?: ModuleContext): void { // Handle application root module const isAppRootModuleChanged = context && context.path && context.path.includes(this.getMainEntry().moduleName) && context.type !== 'style'; @@ -1020,6 +1038,10 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } } } + + // Must precede every window registration below and every scene connect that follows. + this.notifyReady(); + this.setMaxRefreshRate(); // Only set up window if NOT using scene-based lifecycle @@ -1043,9 +1065,26 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication nativeWindow._notifyEvent(NativeWindowEvents.attached); } - this.launchEventCalled = false; - if (!this.shouldDelayLaunchEvent) { - this.notifyAppStarted(notification); + const primaryWindow = this.primaryWindow; + const resolveContent = () => + this._resolveWindowContent( + primaryWindow, + { + window: primaryWindow, + isPrimary: true, + }, + { + launchData: { + ios: notification?.userInfo?.objectForKey('UIApplicationLaunchOptionsLocalNotificationKey') ?? null, + }, + }, + ); + + if (UIApplication.sharedApplication.applicationState === UIApplicationState.Background) { + // A background launch has no UI to build yet, so content waits for the first activation. + this._pendingWindowContentResolve = resolveContent; + } else { + resolveContent(); } } else { // Scene-based app - window creation will happen in scene delegate @@ -1054,8 +1093,10 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication @profile private didBecomeActive(notification: NSNotification) { - if (!this.launchEventCalled) { - this.notifyAppStarted(notification); + const pendingWindowContentResolve = this._pendingWindowContentResolve; + if (pendingWindowContentResolve) { + this._pendingWindowContentResolve = null; + pendingWindowContentResolve(); } // Only handle lifecycle here when NOT using scenes diff --git a/packages/core/global-types.d.ts b/packages/core/global-types.d.ts index 78ca864489..f97ee4bd24 100644 --- a/packages/core/global-types.d.ts +++ b/packages/core/global-types.d.ts @@ -45,6 +45,12 @@ declare module globalThis { * @param callback wire up any global event handling inside the callback */ addEventWiring(callback: () => void): void; + + /** + * Marks the app as launched. Idempotent, and safe to call directly: not every launch + * path raises the legacy `launch` event, so the subscription cannot be relied on. + */ + setLaunched(): void; }; // var android: any; function require(id: string): any; @@ -165,39 +171,39 @@ interface NodeModule { } declare enum RequestContext { - 'audio', - 'beacon', - 'cspreport', - 'download', - 'embed', - 'eventsource', - 'favicon', - 'fetch', - 'font', - 'form', - 'frame', - 'hyperlink', - 'iframe', - 'image', - 'imageset', - 'import', - 'internal', - 'location', - 'manifest', - 'object', - 'ping', - 'plugin', - 'prefetch', - 'script', - 'serviceworker', - 'sharedworker', - 'subresource', - 'style', - 'track', - 'video', - 'worker', - 'xmlhttprequest', - 'xslt', + audio, + beacon, + cspreport, + download, + embed, + eventsource, + favicon, + fetch, + font, + form, + frame, + hyperlink, + iframe, + image, + imageset, + import, + internal, + location, + manifest, + object, + ping, + plugin, + prefetch, + script, + serviceworker, + sharedworker, + subresource, + style, + track, + video, + worker, + xmlhttprequest, + xslt, } // Extend the lib.dom.d.ts Body interface with `formData` diff --git a/packages/core/globals/global-utils.ts b/packages/core/globals/global-utils.ts index d01dd8c7be..c3fb32fa55 100644 --- a/packages/core/globals/global-utils.ts +++ b/packages/core/globals/global-utils.ts @@ -15,7 +15,7 @@ export class NativeScriptGlobalState { constructor() { // console.log('creating NativeScriptGlobals...') this.events = new Observable(); - this._setLaunched = this._setLaunchedFn.bind(this); + this._setLaunched = () => this.setLaunched(); this.events.on('launch', this._setLaunched); if (profilingLevel() > 0) { this.events.on('displayed', () => { @@ -58,11 +58,17 @@ export class NativeScriptGlobalState { } } - private _setLaunchedFn() { - // console.log('NativeScriptGlobals launch fired!'); + /** + * Marks the app as launched. Idempotent, and safe to call directly: not every launch + * path raises the legacy `launch` event, so the subscription cannot be relied on. + */ + setLaunched() { this.launched = true; - this.events.off('launch', this._setLaunched); - this._setLaunched = null; + + if (this._setLaunched) { + this.events.off('launch', this._setLaunched); + this._setLaunched = null; + } } } export function getNativeScriptGlobals() { diff --git a/packages/core/native-window/native-window-interfaces.ts b/packages/core/native-window/native-window-interfaces.ts index cba7fe2c8a..2efe62e7f0 100644 --- a/packages/core/native-window/native-window-interfaces.ts +++ b/packages/core/native-window/native-window-interfaces.ts @@ -1,4 +1,6 @@ import type { EventData } from '../data/observable'; +import type { View } from '../ui/core/view'; +import type { NavigationEntry } from '../ui/frame/frame-interfaces'; import type { NativeWindow } from './native-window-common'; import type { WindowBase } from './window-base'; @@ -136,3 +138,31 @@ export interface WindowOpenOptions { */ data?: Record; } + +/** + * Supplies the UI for a window that needs content. Called once per window that needs it. + * + * Return a `View`, a `NavigationEntry` or a module name to set the window content, + * `null` to take ownership and set the content asynchronously later, or `undefined` + * to fall back to the application main entry. + */ +export type WindowContentResolver = (request: WindowContentRequest) => View | NavigationEntry | string | null | undefined; + +/** + * Describes the window asking for content. + */ +export interface WindowContentRequest { + /** The window that needs content. */ + window: NativeWindow; + /** Whether the window is the application's primary window. */ + isPrimary: boolean; + /** NSUserActivity.userInfo on iOS, intent extras on Android. */ + data?: Record; + ios?: { + connectionOptions?: UISceneConnectionOptions; + }; + android?: { + intent?: android.content.Intent; + savedInstanceState?: android.os.Bundle; + }; +} diff --git a/packages/core/ui/frame/index.android.ts b/packages/core/ui/frame/index.android.ts index 2b8c3734fa..669b5af11f 100644 --- a/packages/core/ui/frame/index.android.ts +++ b/packages/core/ui/frame/index.android.ts @@ -8,7 +8,7 @@ import { View } from '../core/view'; import { _stack, FrameBase, NavigationType } from './frame-common'; import { _clearEntry, _clearFragment, _getAnimatedEntries, _getTransitionState, _restoreTransitionState, _reverseTransitions, _setAndroidFragmentTransitions, _updateTransitions } from './fragment.transitions'; import { profile } from '../../profiling'; -import { android as androidUtils } from '../../utils/native-helper'; +import { android as androidUtils, dataDeserialize } from '../../utils/native-helper'; import type { ExpandedEntry } from './fragment.transitions.android'; import { ensureFragmentClass, fragmentClass } from './fragment'; import { getAppMainEntry } from '../../application/helpers-common'; @@ -1131,12 +1131,39 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } const intent = activity.getIntent(); - rootView = Application.createRootView(rootView, fireLaunchEvent, { + const launchData = { // todo: deprecate in favor of args.intent? android: intent, intent, savedInstanceState, - }); + }; + + let nativeWindow = Application.android._getWindowForActivity(activity); + + if (!nativeWindow && isEmbedded()) { + // When embedded, the host owns the activity and may never install our lifecycle + // callbacks, so this is the only place the window can come into existence. + nativeWindow = new AndroidNativeWindow(activity, AndroidNativeWindow.newWindowId(), Application.android._getWindows().length === 0, 'embedded'); + Application.android._registerWindow(nativeWindow); + } + + if (!rootView && fireLaunchEvent && nativeWindow) { + // This method installs the root view on the activity itself, so the resolved view is + // handed back rather than applied through NativeWindow.setContent(), which would + // re-enter here through resetActivityContent(). + rootView = Application._resolveWindowContent( + nativeWindow, + { + window: nativeWindow, + isPrimary: nativeWindow.isPrimary, + data: dataDeserialize(intent?.getExtras()) ?? undefined, + android: { intent, savedInstanceState }, + }, + { install: false, launchData }, + ); + } else { + rootView = Application.createRootView(rootView, fireLaunchEvent, launchData); + } if (!rootView) { // no root view created @@ -1159,15 +1186,6 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks // sets root classes once rootView is ready... Application.initRootView(rootView); - let nativeWindow = Application.android._getWindowForActivity(activity); - - if (!nativeWindow && isEmbedded()) { - // When embedded, the host owns the activity and may never install our lifecycle - // callbacks, so this is the only place the window can come into existence. - nativeWindow = new AndroidNativeWindow(activity, AndroidNativeWindow.newWindowId(), Application.android._getWindows().length === 0, 'embedded'); - Application.android._registerWindow(nativeWindow); - } - nativeWindow?._adoptRootView(rootView); } } diff --git a/packages/core/vitest.setup.ts b/packages/core/vitest.setup.ts index e116da77a9..f28d5d4266 100644 --- a/packages/core/vitest.setup.ts +++ b/packages/core/vitest.setup.ts @@ -157,6 +157,7 @@ global.NativeScriptGlobals = { notify: (args) => {}, hasListeners: (args) => {}, }, + setLaunched: () => {}, }; global.CADisplayLink = function () {}; From c512b096ccf68817054a9c308eb8fe2745ee6023 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 17:39:56 -0300 Subject: [PATCH 11/23] fix(core): drive app-level suspend/resume from UIApplication notifications in scene mode App-level suspend/resume were driven by the primary scene, so a multi-scene app reported itself suspended while other scenes were still on screen. They now follow UIApplication's own foreground/background notifications, which describe the whole app. Per-window state stays available through NativeWindow events. --- .../core/application/application-common.ts | 5 ++ packages/core/application/application.ios.ts | 60 ++++++------------- 2 files changed, 24 insertions(+), 41 deletions(-) diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index f3fbb1f025..e303195149 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -168,6 +168,11 @@ export class ApplicationCommon { * `contentLoaded` -> `activate`/`displayed`. */ readonly readyEvent = 'ready'; + /** + * Reflects whole-app state: with multiple windows it is raised once the app itself is + * no longer in the foreground, not when an individual window backgrounds. Listen on a + * NativeWindow for per-window state. + */ readonly suspendEvent = 'suspend'; readonly displayedEvent = 'displayed'; readonly backgroundEvent = 'background'; diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index b900127268..6d449f389f 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -333,19 +333,9 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { scene: windowScene, } as SceneEventData); - // If this is the primary scene, trigger traditional app lifecycle - if (nativeWindow?.isPrimary) { - const additionalData = { - ios: UIApplication.sharedApplication, - scene: scene, - }; - Application.ios.setInBackground(false, additionalData); - Application.ios.setSuspended(false, additionalData); - - const rootView = nativeWindow.rootView; - if (rootView && !rootView.isLoaded) { - rootView.callLoaded(); - } + const rootView = nativeWindow?.rootView; + if (rootView && !rootView.isLoaded) { + rootView.callLoaded(); } } @@ -414,19 +404,9 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { scene: windowScene, } as SceneEventData); - // If this is the primary scene, trigger traditional app lifecycle - if (nativeWindow?.isPrimary) { - const additionalData = { - ios: UIApplication.sharedApplication, - scene: scene, - }; - Application.ios.setInBackground(true, additionalData); - Application.ios.setSuspended(true, additionalData); - - const rootView = nativeWindow.rootView; - if (rootView && rootView.isLoaded) { - rootView.callUnloaded(); - } + const rootView = nativeWindow?.rootView; + if (rootView && rootView.isLoaded) { + rootView.callUnloaded(); } } @@ -1099,15 +1079,14 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication pendingWindowContentResolve(); } - // Only handle lifecycle here when NOT using scenes - // (scene lifecycle is handled by SceneDelegate methods) - if (!this.supportsScenes()) { - const additionalData = { - ios: UIApplication.sharedApplication, - }; - this.setInBackground(false, additionalData); - this.setSuspended(false, additionalData); + const additionalData = { + ios: UIApplication.sharedApplication, + }; + this.setInBackground(false, additionalData); + this.setSuspended(false, additionalData); + // In scene mode the root view belongs to a window, so the scene delegate loads it. + if (!this.supportsScenes()) { const rootView = this._rootView; if (rootView && !rootView.isLoaded) { rootView.callLoaded(); @@ -1116,14 +1095,13 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } private didEnterBackground(notification: NSNotification) { - // Only handle lifecycle here when NOT using scenes - if (!this.supportsScenes()) { - const additionalData = { - ios: UIApplication.sharedApplication, - }; - this.setInBackground(true, additionalData); - this.setSuspended(true, additionalData); + const additionalData = { + ios: UIApplication.sharedApplication, + }; + this.setInBackground(true, additionalData); + this.setSuspended(true, additionalData); + if (!this.supportsScenes()) { const rootView = this._rootView; if (rootView && rootView.isLoaded) { rootView.callUnloaded(); From 4a4a876a359ed8422ac9acd3f417c3e36d210397 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 17:49:36 -0300 Subject: [PATCH 12/23] feat(core): hoist window registry to ApplicationCommon; cross-platform openWindow (Android experimental) The registry, its events and primary promotion had one copy per platform; they now live on ApplicationCommon, with protected hooks for the iOS-specific bookkeeping. openWindow() takes WindowOpenOptions on both platforms. Android openWindow() launches a real second activity. It is experimental: whether a new window appears depends on the manifest launchMode and on OEM recents behavior. --- apps/toolbox/src/pages/multiple-scenes.ts | 4 +- .../core/application/application-common.ts | 122 ++++++++++++++- .../core/application/application.android.ts | 133 +++++++---------- packages/core/application/application.d.ts | 56 ++----- packages/core/application/application.ios.ts | 139 ++++-------------- 5 files changed, 220 insertions(+), 234 deletions(-) diff --git a/apps/toolbox/src/pages/multiple-scenes.ts b/apps/toolbox/src/pages/multiple-scenes.ts index 3c87438436..84f90d1657 100644 --- a/apps/toolbox/src/pages/multiple-scenes.ts +++ b/apps/toolbox/src/pages/multiple-scenes.ts @@ -404,11 +404,11 @@ export class MultipleScenesModel extends Observable { } onCreateNewScene() { - Application.ios.openWindow({ id: 'newSceneBasic' }); + Application.ios.openWindow({ data: { id: 'newSceneBasic' } }); } onCreateNewSceneAlt() { - Application.ios.openWindow({ id: 'newSceneAlt' }); + Application.ios.openWindow({ data: { id: 'newSceneAlt' } }); } onRefreshSceneInfo() { diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index e303195149..5fa516992e 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -16,7 +16,9 @@ import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessib import { getAppMainEntry, isAppInBackground, setAppInBackground, setAppMainEntry } from './helpers-common'; import { getNativeScriptGlobals } from '../globals/global-utils'; import { SDK_VERSION } from '../utils/constants'; -import type { NativeWindow, PrimaryWindowChangedEventData, WindowCloseEventData, WindowContentRequest, WindowContentResolver, WindowOpenEventData } from '../native-window'; +import type { NativeWindow, PrimaryWindowChangedEventData, WindowCloseEventData, WindowContentRequest, WindowContentResolver, WindowOpenEventData, WindowOpenOptions } from '../native-window'; +import type { WindowBase, WindowRole } from '../native-window/window-base'; +import { WindowEvents } from '../native-window/native-window-interfaces'; // prettier-ignore const ORIENTATION_CSS_CLASSES = [ @@ -194,6 +196,9 @@ export class ApplicationCommon { readonly loadAppCssEvent = 'loadAppCss'; readonly cssChangedEvent = 'cssChanged'; readonly initRootViewEvent = 'initRootView'; + readonly windowOpenEvent = WindowEvents.windowOpen; + readonly windowCloseEvent = WindowEvents.windowClose; + readonly primaryWindowChangedEvent = WindowEvents.primaryWindowChanged; // Expose statically for backwards compat on AndroidApplication.on etc. /** @@ -429,6 +434,121 @@ export class ApplicationCommon { return this._windowContentResolver; } + // --- NativeWindow registry --- + + protected _windows: NativeWindow[] = []; + + /** + * Get the primary NativeWindow. + */ + get primaryWindow(): NativeWindow | undefined { + return this._windows.find((nw) => nw.isPrimary); + } + + /** + * Get the active windows, filtered by role. + * + * Defaults to the view-carrying app windows (`application` and `embedded`). + * Pass `'all'` to include every registered surface, including ones that carry no view tree. + */ + getWindows(role: 'all'): WindowBase[]; + getWindows(role?: WindowRole | WindowRole[]): NativeWindow[]; + getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[]; + getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[] { + if (role === 'all') { + return [...this._windows]; + } + const roles: WindowRole[] = role ? (Array.isArray(role) ? role : [role]) : ['application', 'embedded']; + return this._windows.filter((nw) => roles.indexOf(nw.role) !== -1); + } + + /** + * Get a registered NativeWindow by its id. + */ + getWindowById(id: string): NativeWindow | undefined { + return this._windows.find((nw) => nw.id === id); + } + + /** + * Opens a new window. + * + * @param options Options for the new window, including data to hand to it. + */ + openWindow(options?: WindowOpenOptions): void { + throw new Error('openWindow() is not supported on this platform.'); + } + + /** + * @internal - Get all registered NativeWindows, whatever their role. + */ + _getWindows(): NativeWindow[] { + return [...this._windows]; + } + + /** + * @internal - Register a NativeWindow created by the platform lifecycle. + */ + _registerWindow(nativeWindow: NativeWindow): void { + this._windows.push(nativeWindow); + + this._onWindowRegistered(nativeWindow); + + this.notify({ + eventName: this.windowOpenEvent, + object: this, + window: nativeWindow, + }); + } + + /** + * @internal - Unregister a NativeWindow when its native surface is gone for good. + */ + _unregisterWindow(nativeWindow: NativeWindow): void { + const idx = this._windows.indexOf(nativeWindow); + if (idx >= 0) { + this._windows.splice(idx, 1); + } + this.notify({ + eventName: this.windowCloseEvent, + object: this, + window: nativeWindow, + }); + + // If primary was removed, promote the next window that can actually host content + if (nativeWindow.isPrimary) { + nativeWindow._setIsPrimary(false); + + const promoted = this.getWindows().find((nw) => nw.state === 'attached'); + if (promoted) { + promoted._setIsPrimary(true); + this._onPrimaryWindowPromoted(promoted); + this.notify({ + eventName: this.primaryWindowChangedEvent, + object: this, + window: promoted, + }); + } + } + + nativeWindow._destroy(); + } + + /** + * Hook for platform-specific bookkeeping right after a window joins the registry, + * before `windowOpen` is raised. + */ + protected _onWindowRegistered(nativeWindow: NativeWindow): void { + // noop + } + + /** + * Hook for platform-specific bookkeeping right after a window takes over the primary + * role, before `primaryWindowChanged` is raised. + */ + protected _onPrimaryWindowPromoted(nativeWindow: NativeWindow): void { + // noop + } + /** * @internal - raises `ready` at most once per JS context. */ diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index 198760d7e8..0817c9dcab 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -4,15 +4,15 @@ import type { View } from '../ui/core/view'; import { AndroidActivityCallbacks, NavigationEntry } from '../ui/frame/frame-common'; import { isEmbedded } from '../ui/embedding'; import { SDK_VERSION } from '../utils/constants'; -import { android as androidUtils } from '../utils'; +import { android as androidUtils, dataSerialize } from '../utils'; import { ApplicationCommon } from './application-common'; import type { AndroidActivityBackPressedEventData, AndroidActivityBundleEventData, AndroidActivityEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, AndroidActivityResultEventData, ApplicationEventData } from './application-interfaces'; import { Observable } from '../data/observable'; import { Trace } from '../trace'; import { AndroidNativeWindow } from '../native-window/native-window.android'; import { NativeWindow } from '../native-window/native-window-common'; -import type { WindowBase, WindowRole } from '../native-window/window-base'; -import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; +import { NativeWindowEvents } from '../native-window/native-window-interfaces'; +import type { WindowOpenOptions } from '../native-window/native-window-interfaces'; import { CommonA11YServiceEnabledObservable, SharedA11YObservable, @@ -57,6 +57,19 @@ declare class NativeScriptLifecycleCallbacks extends android.app.Application.Act const WINDOW_ID_EXTRA = 'com.tns.activity.windowId'; +let multiWindowWarned = false; + +function warnMultiWindowIsExperimental(): void { + if (multiWindowWarned) { + return; + } + multiWindowWarned = true; + + const message = 'Application.android.openWindow() is experimental: whether a new window opens depends on the activity launchMode declared in AndroidManifest.xml and on the device recents behavior.'; + Trace.write(message, Trace.categories.Debug, Trace.messageType.warn); + console.warn(message); +} + let NativeScriptLifecycleCallbacks_: typeof NativeScriptLifecycleCallbacks; function initNativeScriptLifecycleCallbacks() { if (NativeScriptLifecycleCallbacks_) { @@ -87,7 +100,7 @@ function initNativeScriptLifecycleCallbacks() { // Create and register NativeWindow for this activity const savedWindowId = savedInstanceState?.getString(WINDOW_ID_EXTRA); - const knownWindow = savedWindowId ? (Application.android._getWindowById(savedWindowId) as AndroidNativeWindow) : undefined; + const knownWindow = savedWindowId ? (Application.android.getWindowById(savedWindowId) as AndroidNativeWindow) : undefined; let nativeWindow: AndroidNativeWindow; if (knownWindow?.state === 'detached') { @@ -682,95 +695,55 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp } // --- NativeWindow registry --- - private _windows: AndroidNativeWindow[] = []; /** - * @internal - Register a NativeWindow created by the lifecycle callbacks. + * @internal - Get a NativeWindow by its activity. */ - _registerWindow(nativeWindow: AndroidNativeWindow): void { - this._windows.push(nativeWindow); - this.notify({ - eventName: WindowEvents.windowOpen, - object: this, - window: nativeWindow, - }); + _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): AndroidNativeWindow | undefined { + return this._windows.find((nw) => nw.android?.activity === activity) as AndroidNativeWindow | undefined; } - /** - * @internal - Unregister a NativeWindow when its activity is destroyed. - */ - _unregisterWindow(nativeWindow: AndroidNativeWindow): void { - const idx = this._windows.indexOf(nativeWindow); - if (idx >= 0) { - this._windows.splice(idx, 1); - } - this.notify({ - eventName: WindowEvents.windowClose, - object: this, - window: nativeWindow, - }); - - // If primary was removed, promote the next window that can actually host content - if (nativeWindow.isPrimary) { - nativeWindow._setIsPrimary(false); - - const promoted = this.getWindows().find((nw) => nw.state === 'attached'); - if (promoted) { - promoted._setIsPrimary(true); - this.notify({ - eventName: WindowEvents.primaryWindowChanged, - object: this, - window: promoted, - }); - } - } - - nativeWindow._destroy(); - } + // --- Multi-window support --- /** - * @internal - Get all registered NativeWindows. + * Opens a new window by launching the start activity into its own task. + * + * @param options Options for the new window. `options.data` is put on the launch + * intent as extras and surfaces as the window's `data`. + * + * @experimental Whether a second window actually appears depends on the activity's + * `launchMode` in AndroidManifest.xml (an activity that is `singleTask`/`singleInstance` + * is brought forward instead of duplicated) and on how the OEM's recents implementation + * treats new documents. */ - _getWindows(): NativeWindow[] { - return [...this._windows]; - } + openWindow(options?: WindowOpenOptions): void { + warnMultiWindowIsExperimental(); - /** - * @internal - Get a NativeWindow by its activity. - */ - _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): AndroidNativeWindow | undefined { - return this._windows.find((nw) => nw.activity === activity); - } + const context = this.context ?? this.getNativeApplication(); + const intent = new android.content.Intent(); + const startActivity = this.startActivity; - /** - * @internal - Get a NativeWindow by its id. - */ - _getWindowById(id: string): NativeWindow | undefined { - return this._windows.find((nw) => nw.id === id); - } + if (startActivity) { + intent.setClass(context, startActivity.getClass()); + } else { + intent.setClassName(context, 'org.nativescript.NativeScriptActivity'); + } - /** - * Get the primary NativeWindow. - */ - get primaryWindow(): NativeWindow | undefined { - return this._windows.find((nw) => nw.isPrimary); - } + intent.setFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK | android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK | android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT); - /** - * Get the active windows, filtered by role. - * - * Defaults to the view-carrying app windows (`application` and `embedded`). - * Pass `'all'` to include every registered surface, including ones that carry no view tree. - */ - getWindows(role: 'all'): WindowBase[]; - getWindows(role?: WindowRole | WindowRole[]): NativeWindow[]; - getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[]; - getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[] { - if (role === 'all') { - return [...this._windows]; + const data = options?.data; + if (data) { + for (const key of Object.keys(data)) { + intent.putExtra(key, dataSerialize(data[key], true)); + } + } + + const launcher = this.foregroundActivity ?? startActivity; + if (launcher) { + launcher.startActivity(intent); + } else { + context.startActivity(intent); } - const roles: WindowRole[] = role ? (Array.isArray(role) ? role : [role]) : ['application', 'embedded']; - return this._windows.filter((nw) => roles.indexOf(nw.role) !== -1); } getRootView(): View { diff --git a/packages/core/application/application.d.ts b/packages/core/application/application.d.ts index b206c5cede..9a6d4fc9fb 100644 --- a/packages/core/application/application.d.ts +++ b/packages/core/application/application.d.ts @@ -1,8 +1,7 @@ import { ApplicationCommon } from './application-common'; import { FontScaleCategory } from '../accessibility/font-scale-common'; import type { NativeWindow } from '../native-window/native-window-common'; -import type { WindowBase, WindowRole } from '../native-window/window-base'; -import type { WindowOpenEventData, WindowCloseEventData } from '../native-window/native-window-interfaces'; +import type { WindowOpenEventData, WindowCloseEventData, WindowOpenOptions } from '../native-window/native-window-interfaces'; export * from './application-common'; export * from './application-interfaces'; @@ -127,29 +126,17 @@ export class AndroidApplication extends ApplicationCommon { _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): NativeWindow | undefined; /** - * @internal - Get all registered NativeWindows. - */ - _getWindows(): NativeWindow[]; - - /** - * @internal - Register a NativeWindow. - */ - _registerWindow(nativeWindow: NativeWindow): void; - - /** - * Get the primary NativeWindow. - */ - get primaryWindow(): NativeWindow | undefined; - - /** - * Get the active windows, filtered by role. + * Opens a new window by launching the start activity into its own task. * - * Defaults to the view-carrying app windows (`application` and `embedded`). - * Pass `'all'` to include every registered surface, including ones that carry no view tree. + * @param options Options for the new window. `options.data` is put on the launch + * intent as extras and surfaces as the window's `data`. + * + * @experimental Whether a second window actually appears depends on the activity's + * `launchMode` in AndroidManifest.xml (an activity that is `singleTask`/`singleInstance` + * is brought forward instead of duplicated) and on how the OEM's recents implementation + * treats new documents. */ - getWindows(role: 'all'): WindowBase[]; - getWindows(role?: WindowRole | WindowRole[]): NativeWindow[]; - getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[]; + openWindow(options?: WindowOpenOptions): void; } export class iOSApplication extends ApplicationCommon { @@ -219,10 +206,12 @@ export class iOSApplication extends ApplicationCommon { isUsingSceneLifecycle(): boolean; /** - * Opens a new window with the specified data. - * @param data The data to pass to the new window. + * Opens a new window (scene). + * + * @param options Options for the new window. `options.data` is serialized into the + * activating scene's `NSUserActivity.userInfo`. */ - openWindow(data: Record): void; + openWindow(options?: WindowOpenOptions): void; /** * Closes a secondary window/scene. @@ -308,21 +297,6 @@ export class iOSApplication extends ApplicationCommon { on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; on(event: 'windowClose', callback: (args: WindowCloseEventData) => void, thisArg?: any): void; - /** - * Get the primary NativeWindow. - */ - get primaryWindow(): NativeWindow | undefined; - - /** - * Get the active windows, filtered by role. - * - * Defaults to the view-carrying app windows (`application` and `embedded`). - * Pass `'all'` to include every registered surface, including ones that carry no view tree. - */ - getWindows(role: 'all'): WindowBase[]; - getWindows(role?: WindowRole | WindowRole[]): NativeWindow[]; - getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[]; - /** * Flag to be set when the launch event should be delayed until the application has become active. * This is useful when you want to process notifications or data in the background without creating the UI. diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index 6d449f389f..8ba7b9e330 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -13,9 +13,9 @@ import type { iOSApplication as IiOSApplication } from './application'; import { Trace } from '../trace'; import { IOSNativeWindow } from '../native-window/native-window.ios'; import { NativeWindow } from '../native-window/native-window-common'; -import type { WindowBase, WindowRole } from '../native-window/window-base'; -import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; -import type { NativeWindowEventData } from '../native-window/native-window-interfaces'; +import type { WindowRole } from '../native-window/window-base'; +import { NativeWindowEvents } from '../native-window/native-window-interfaces'; +import type { NativeWindowEventData, WindowOpenOptions } from '../native-window/native-window-interfaces'; import { AccessibilityServiceEnabledPropName, CommonA11YServiceEnabledObservable, @@ -253,7 +253,7 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { } const nativeWindowId = IOSNativeWindow.getSceneId(windowScene); - const knownWindow = nativeWindowId ? (Application.ios._getWindowById(nativeWindowId) as IOSNativeWindow) : undefined; + const knownWindow = nativeWindowId ? (Application.ios.getWindowById(nativeWindowId) as IOSNativeWindow) : undefined; let nativeWindow: IOSNativeWindow; if (knownWindow?.state === 'detached') { @@ -465,9 +465,6 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication */ _onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; - // NativeWindow registry - private _windows: IOSNativeWindow[] = []; - // The window the app-level root view state mirrors, and the root view currently // carrying the app-level trait collection listeners. private _mirroredWindow: NativeWindow; @@ -1206,60 +1203,6 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // --- NativeWindow registry --- - /** - * @internal - Register a NativeWindow created by the SceneDelegate. - */ - _registerWindow(nativeWindow: IOSNativeWindow): void { - this._windows.push(nativeWindow); - - if (nativeWindow.isPrimary) { - this.mirrorPrimaryWindow(nativeWindow); - } - - this.notify({ - eventName: WindowEvents.windowOpen, - object: this, - window: nativeWindow, - }); - } - - /** - * @internal - Unregister a NativeWindow when its scene disconnects. - */ - _unregisterWindow(nativeWindow: IOSNativeWindow): void { - const idx = this._windows.indexOf(nativeWindow); - if (idx >= 0) { - this._windows.splice(idx, 1); - } - this.notify({ - eventName: WindowEvents.windowClose, - object: this, - window: nativeWindow, - }); - - // If primary was removed, promote the next window that can actually host content - if (nativeWindow.isPrimary) { - nativeWindow._setIsPrimary(false); - - const promoted = this.getWindows().find((nw) => nw.state === 'attached'); - if (promoted) { - promoted._setIsPrimary(true); - const promotedWindow = promoted.ios?.uiWindow; - if (promotedWindow) { - setiOSWindow(promotedWindow); - } - this.mirrorPrimaryWindow(promoted); - this.notify({ - eventName: WindowEvents.primaryWindowChanged, - object: this, - window: promoted, - }); - } - } - - nativeWindow._destroy(); - } - /** * @internal - iOS reports discarded sessions for windows this JS context may never * have seen (they can arrive on a later launch), so unknown ids are ignored. @@ -1272,7 +1215,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication for (let i = 0; i < all.count; i++) { const persistentIdentifier = all.objectAtIndex(i)?.persistentIdentifier; - const nativeWindow = persistentIdentifier ? this._windows.find((nw) => nw.id === `${persistentIdentifier}`) : undefined; + const nativeWindow = persistentIdentifier ? this.getWindowById(`${persistentIdentifier}`) : undefined; if (!nativeWindow) { continue; } @@ -1282,51 +1225,25 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } } - /** - * @internal - Get all registered NativeWindows. - */ - _getWindows(): NativeWindow[] { - return [...this._windows]; - } - /** * @internal - Get a NativeWindow by its scene. */ _getWindowForScene(scene: UIWindowScene): IOSNativeWindow | undefined { - return this._windows.find((nw) => nw.ios?.scene === scene); + return this._windows.find((nw) => nw.ios?.scene === scene) as IOSNativeWindow | undefined; } - /** - * @internal - Get a NativeWindow by its id. - */ - _getWindowById(id: string): NativeWindow | undefined { - return this._windows.find((nw) => nw.id === id); - } - - // --- Public NativeWindow API --- - - /** - * Get the primary NativeWindow. - */ - get primaryWindow(): NativeWindow | undefined { - return this._windows.find((nw) => nw.isPrimary); + protected _onWindowRegistered(nativeWindow: NativeWindow): void { + if (nativeWindow.isPrimary) { + this.mirrorPrimaryWindow(nativeWindow); + } } - /** - * Get the active windows, filtered by role. - * - * Defaults to the view-carrying app windows (`application` and `embedded`). - * Pass `'all'` to include every registered surface, including ones that carry no view tree. - */ - getWindows(role: 'all'): WindowBase[]; - getWindows(role?: WindowRole | WindowRole[]): NativeWindow[]; - getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[]; - getWindows(role?: WindowRole | WindowRole[] | 'all'): WindowBase[] { - if (role === 'all') { - return [...this._windows]; + protected _onPrimaryWindowPromoted(nativeWindow: NativeWindow): void { + const promotedWindow = nativeWindow.ios?.uiWindow; + if (promotedWindow) { + setiOSWindow(promotedWindow); } - const roles: WindowRole[] = role ? (Array.isArray(role) ? role : [role]) : ['application', 'embedded']; - return this._windows.filter((nw) => roles.indexOf(nw.role) !== -1); + this.mirrorPrimaryWindow(nativeWindow); } /** @@ -1378,10 +1295,12 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication */ /** - * Opens a new window with the specified data. - * @param data The data to pass to the new window. + * Opens a new window (scene). + * + * @param options Options for the new window. `options.data` is serialized into the + * activating scene's `NSUserActivity.userInfo`. */ - openWindow(data: Record) { + openWindow(options?: WindowOpenOptions) { if (!supportsMultipleScenes()) { console.log('Cannot create a new scene - not supported on this device.'); return; @@ -1398,16 +1317,16 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication request = UISceneSessionActivationRequest.requestWithRole(UIWindowSceneSessionRoleApplication); const activity = NSUserActivity.alloc().initWithActivityType(`${NSBundle.mainBundle.bundleIdentifier}.scene`); - activity.userInfo = dataSerialize(data); + activity.userInfo = dataSerialize(options?.data ?? {}); request.userActivity = activity; - const options = UISceneActivationRequestOptions.new(); + const activationOptions = UISceneActivationRequestOptions.new(); const primary = this.primaryWindow; if (primary?.ios?.scene) { - options.requestingScene = primary.ios.scene; + activationOptions.requestingScene = primary.ios.scene; } - request.options = options; + request.options = activationOptions; } catch (roleError) { console.log('Error creating request:', roleError); return; @@ -1422,9 +1341,9 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } if (error.localizedDescription.includes('role') && error.localizedDescription.includes('nil')) { - this.createSceneWithLegacyAPI(data); + this.createSceneWithLegacyAPI(options?.data); } else if (error.domain === 'FBSWorkspaceErrorDomain' && error.code === 2) { - this.createSceneWithLegacyAPI(data); + this.createSceneWithLegacyAPI(options?.data); } } }); @@ -1561,7 +1480,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } // String id lookup if (typeof target === 'string') { - const found = this._getWindowById(target); + const found = this.getWindowById(target); if (found) { return found.ios?.scene || null; } @@ -1576,7 +1495,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication return null; } - private createSceneWithLegacyAPI(data: Record) { + private createSceneWithLegacyAPI(data?: Record) { const windowScene = this.window?.windowScene; if (!windowScene) { @@ -1585,7 +1504,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // Create user activity for the new scene const userActivity = NSUserActivity.alloc().initWithActivityType(`${NSBundle.mainBundle.bundleIdentifier}.scene`); - userActivity.userInfo = dataSerialize(data); + userActivity.userInfo = dataSerialize(data ?? {}); // Use the legacy API const options = UISceneActivationRequestOptions.new(); From 453084ab9509447da32e7466c19a56dccef4b018 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 18:12:20 -0300 Subject: [PATCH 13/23] feat(core): per-window configuration plumbing Orientation, system appearance and layout direction are per-window: each NativeWindow reads them live from its own surface, raises its own changed events, and scopes its CSS classes to its own root view and that root's modals instead of the process-wide system class list. Application-level getters and events continue to reflect the primary window. Accessibility CSS is applied per root view, so windows beyond the first are no longer skipped. --- .../accessibility/accessibility-common.ts | 33 +++ .../core/application/application-common.ts | 220 +++++++++++++----- .../core/application/application.android.ts | 12 +- packages/core/application/application.ios.ts | 53 +---- packages/core/application/helpers-common.ts | 14 +- packages/core/css/system-classes.ts | 28 +++ .../native-window/native-window-common.ts | 144 ++++++++---- .../native-window/native-window-interfaces.ts | 31 +++ .../native-window/native-window.android.ts | 55 +++++ packages/core/native-window/window-base.ts | 5 +- packages/core/ui/core/view-base/index.ts | 9 +- packages/core/ui/core/view/index.d.ts | 7 + packages/core/ui/core/view/view-common.ts | 27 +++ packages/core/ui/frame/index.android.ts | 8 +- 14 files changed, 496 insertions(+), 150 deletions(-) diff --git a/packages/core/accessibility/accessibility-common.ts b/packages/core/accessibility/accessibility-common.ts index 5eaea0569b..bf377e547b 100644 --- a/packages/core/accessibility/accessibility-common.ts +++ b/packages/core/accessibility/accessibility-common.ts @@ -240,6 +240,39 @@ export function getCurrentA11YServiceClass() { return currentA11YServiceClass; } +/** + * Applies the current accessibility state — the a11y service class, the font scale + * classes and the inherited font scale — to a root view. + * + * Runs for every window's root view; the `readyInit*` helpers next to it only wire up + * the process-wide listeners that keep this state current, and do so once. + */ +export function applyAccessibilityCssToRoot(rootView: View): void { + if (!rootView) { + return; + } + + const a11yServiceClass = getCurrentA11YServiceClass(); + if (a11yServiceClass) { + rootView.cssClasses.add(a11yServiceClass); + } + + const fontScaleClass = getCurrentFontScaleClass(); + if (fontScaleClass) { + rootView.cssClasses.add(fontScaleClass); + } + + const fontScaleCategoryClass = getCurrentFontScaleCategory(); + if (fontScaleCategoryClass) { + rootView.cssClasses.add(fontScaleCategoryClass); + } + + const fontScale = getFontScale(); + if (fontScale) { + rootView.style.fontScaleInternal = fontScale; + } +} + export enum AccessibilityTrait { /** * The element allows direct touch interaction for VoiceOver users. diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index 5fa516992e..4e61a6b9eb 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -12,32 +12,17 @@ import type { NavigationEntry } from '../ui/frame/frame-interfaces'; import type { StyleScope } from '../ui/styling/style-scope'; import type { AndroidApplication as AndroidApplicationType, iOSApplication as iOSApplicationType } from '.'; import type { ApplicationEventData, CssChangedEventData, DiscardedErrorEventData, FontScaleChangedEventData, InitRootViewEventData, LaunchEventData, LoadAppCSSEventData, NativeScriptError, OrientationChangedEventData, SystemAppearanceChangedEventData, LayoutDirectionChangedEventData, UnhandledErrorEventData } from './application-interfaces'; -import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessibility/accessibility-common'; -import { getAppMainEntry, isAppInBackground, setAppInBackground, setAppMainEntry } from './helpers-common'; +import { applyAccessibilityCssToRoot, readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessibility/accessibility-common'; +import { getAppMainEntry, getAutoSystemAppearanceChanged, isAppInBackground, setAppInBackground, setAppMainEntry, setAutoSystemAppearanceChanged } from './helpers-common'; import { getNativeScriptGlobals } from '../globals/global-utils'; import { SDK_VERSION } from '../utils/constants'; -import type { NativeWindow, PrimaryWindowChangedEventData, WindowCloseEventData, WindowContentRequest, WindowContentResolver, WindowOpenEventData, WindowOpenOptions } from '../native-window'; +import type { NativeWindow, PrimaryWindowChangedEventData, WindowCloseEventData, WindowContentRequest, WindowContentResolver, WindowLayoutDirectionChangedEventData, WindowOpenEventData, WindowOpenOptions, WindowOrientationChangedEventData, WindowSystemAppearanceChangedEventData } from '../native-window'; import type { WindowBase, WindowRole } from '../native-window/window-base'; -import { WindowEvents } from '../native-window/native-window-interfaces'; - -// prettier-ignore -const ORIENTATION_CSS_CLASSES = [ - `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.portrait}`, - `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.landscape}`, - `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.unknown}`, -]; - -// prettier-ignore -const SYSTEM_APPEARANCE_CSS_CLASSES = [ - `${CSSUtils.CLASS_PREFIX}${CoreTypes.SystemAppearance.light}`, - `${CSSUtils.CLASS_PREFIX}${CoreTypes.SystemAppearance.dark}`, -]; - -// prettier-ignore -const LAYOUT_DIRECTION_CSS_CLASSES = [ - `${CSSUtils.CLASS_PREFIX}${CoreTypes.LayoutDirection.ltr}`, - `${CSSUtils.CLASS_PREFIX}${CoreTypes.LayoutDirection.rtl}`, -]; +import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; + +const ORIENTATION_CSS_CLASSES = CSSUtils.ORIENTATION_CSS_CLASSES; +const SYSTEM_APPEARANCE_CSS_CLASSES = CSSUtils.SYSTEM_APPEARANCE_CSS_CLASSES; +const LAYOUT_DIRECTION_CSS_CLASSES = CSSUtils.LAYOUT_DIRECTION_CSS_CLASSES; const globalEvents = getNativeScriptGlobals().events; @@ -246,7 +231,13 @@ export class ApplicationCommon { /** * Boolean to enable/disable systemAppearanceChanged */ - public autoSystemAppearanceChanged = true; + public get autoSystemAppearanceChanged(): boolean { + return getAutoSystemAppearanceChanged(); + } + + public set autoSystemAppearanceChanged(value: boolean) { + setAutoSystemAppearanceChanged(value); + } /** * @internal - should not be constructed by the user. @@ -341,6 +332,28 @@ export class ApplicationCommon { rootView.cssClasses.delete(cssClass); } + /** + * Same as {@link applyCssClass}, minus the system class list: window-scoped classes + * must not leak into it, because it seeds every window's root view. + */ + private applyWindowScopedCssClass(rootView: View, cssClasses: string[], newCssClass: string): void { + if (rootView.cssClasses.has(newCssClass)) { + return; + } + + cssClasses.forEach((cssClass) => rootView.cssClasses.delete(cssClass)); + rootView.cssClasses.add(newCssClass); + this.increaseStyleScopeApplicationCssSelectorVersion(rootView); + } + + /** + * The modal registry is process-wide, so only the modals presented over this root + * view may follow its window-scoped classes. + */ + private getOwnedModalViews(rootView: View): View[] { + return (>rootView._getRootModalViews()).filter((modalView) => modalView._getRootModalHost() === rootView); + } + private increaseStyleScopeApplicationCssSelectorVersion(rootView: View) { const styleScope: StyleScope = rootView._styleScope ?? (rootView as Frame)?.currentPage?._styleScope; @@ -349,12 +362,12 @@ export class ApplicationCommon { } } - private setRootViewCSSClasses(rootView: View): void { + private setRootViewCSSClasses(rootView: View, window?: NativeWindow): void { const platform = Device.os.toLowerCase(); const deviceType = Device.deviceType.toLowerCase(); - const orientation = this.orientation(); - const systemAppearance = this.systemAppearance(); - const layoutDirection = this.layoutDirection(); + const orientation = window ? window.orientation() : this.orientation(); + const systemAppearance = window ? window.systemAppearance() : this.systemAppearance(); + const layoutDirection = window ? window.layoutDirection() : this.layoutDirection(); if (platform) { CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${platform}`); @@ -370,22 +383,24 @@ export class ApplicationCommon { CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${deviceType}`); } + rootView.cssClasses.add(CSSUtils.ROOT_VIEW_CSS_CLASS); + const rootViewCssClasses = CSSUtils.getSystemCssClasses(); + rootViewCssClasses.forEach((c) => rootView.cssClasses.add(c)); + + // Two windows can disagree on these, so they never reach the process-wide + // system class list — see CSSUtils.WINDOW_SCOPED_CSS_CLASSES. if (orientation) { - CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${orientation}`); + rootView.cssClasses.add(`${CSSUtils.CLASS_PREFIX}${orientation}`); } if (systemAppearance) { - CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${systemAppearance}`); + rootView.cssClasses.add(`${CSSUtils.CLASS_PREFIX}${systemAppearance}`); } if (layoutDirection) { - CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${layoutDirection}`); + rootView.cssClasses.add(`${CSSUtils.CLASS_PREFIX}${layoutDirection}`); } - rootView.cssClasses.add(CSSUtils.ROOT_VIEW_CSS_CLASS); - const rootViewCssClasses = CSSUtils.getSystemCssClasses(); - rootViewCssClasses.forEach((c) => rootView.cssClasses.add(c)); - this.increaseStyleScopeApplicationCssSelectorVersion(rootView); rootView._onCssStateChange(); @@ -491,6 +506,10 @@ export class ApplicationCommon { _registerWindow(nativeWindow: NativeWindow): void { this._windows.push(nativeWindow); + if (nativeWindow.isPrimary) { + this.trackPrimaryWindowTraits(nativeWindow); + } + this._onWindowRegistered(nativeWindow); this.notify({ @@ -519,6 +538,8 @@ export class ApplicationCommon { nativeWindow._setIsPrimary(false); const promoted = this.getWindows().find((nw) => nw.state === 'attached'); + this.trackPrimaryWindowTraits(promoted); + if (promoted) { promoted._setIsPrimary(true); this._onPrimaryWindowPromoted(promoted); @@ -549,6 +570,86 @@ export class ApplicationCommon { // noop } + // --- Primary window traits --- + + private _traitsWindow: NativeWindow | null = null; + + /** + * Points the application-level orientation, appearance and layout direction at the + * primary window, which owns those values now that each window has its own. + */ + private trackPrimaryWindowTraits(nativeWindow: NativeWindow | undefined): void { + const target = nativeWindow ?? null; + if (this._traitsWindow === target) { + return; + } + + const previous = this._traitsWindow; + if (previous) { + previous.off(NativeWindowEvents.orientationChanged, this.onWindowOrientationChanged, this); + previous.off(NativeWindowEvents.systemAppearanceChanged, this.onWindowSystemAppearanceChanged, this); + previous.off(NativeWindowEvents.layoutDirectionChanged, this.onWindowLayoutDirectionChanged, this); + } + + this._traitsWindow = target; + + if (!target) { + return; + } + + target.on(NativeWindowEvents.orientationChanged, this.onWindowOrientationChanged, this); + target.on(NativeWindowEvents.systemAppearanceChanged, this.onWindowSystemAppearanceChanged, this); + target.on(NativeWindowEvents.layoutDirectionChanged, this.onWindowLayoutDirectionChanged, this); + + this.syncTraitsFromWindow(target); + } + + /** + * Adopts the window's values. The very first window seeds them quietly — there is no + * previous application state for it to differ from — while a later promotion raises + * the change events, because app code observed the outgoing window's values. + */ + private syncTraitsFromWindow(nativeWindow: NativeWindow): void { + const orientation = nativeWindow.orientation(); + if (orientation) { + if (this._orientation === undefined) { + this._orientation = orientation; + } else { + this.setOrientation(orientation); + } + } + + const systemAppearance = nativeWindow.systemAppearance(); + if (systemAppearance) { + if (this._systemAppearance === undefined) { + this._systemAppearance = systemAppearance; + } else { + this.setSystemAppearance(systemAppearance); + } + } + + const layoutDirection = nativeWindow.layoutDirection(); + if (layoutDirection) { + if (this._layoutDirection === undefined) { + this._layoutDirection = layoutDirection; + } else { + this.setLayoutDirection(layoutDirection); + } + } + } + + private onWindowOrientationChanged(data: WindowOrientationChangedEventData): void { + this.setOrientation(data.newValue); + } + + private onWindowSystemAppearanceChanged(data: WindowSystemAppearanceChangedEventData): void { + this.setSystemAppearance(data.newValue); + } + + private onWindowLayoutDirectionChanged(data: WindowLayoutDirectionChangedEventData): void { + this.setLayoutDirection(data.newValue); + } + /** * @internal - raises `ready` at most once per JS context. */ @@ -709,10 +810,15 @@ export class ApplicationCommon { // rest of implementation is platform specific } - initRootView(rootView: View) { - this.setRootViewCSSClasses(rootView); + /** + * @param window the window the root view belongs to. Supplies the window-scoped CSS + * classes; without it they come from the primary window. + */ + initRootView(rootView: View, window?: NativeWindow) { + this.setRootViewCSSClasses(rootView, window); readyInitAccessibilityCssHelper(); readyInitFontScale(); + applyAccessibilityCssToRoot(rootView); this.notify({ eventName: this.initRootViewEvent, rootView }); } @@ -815,8 +921,11 @@ export class ApplicationCommon { }); } + /** + * @deprecated Use Application.primaryWindow?.orientation() - or the NativeWindow of the relevant view - instead. Continues to reflect the primary window. + */ orientation(): 'portrait' | 'landscape' | 'unknown' { - return (this._orientation ??= this.getOrientation()); + return this.primaryWindow?.orientation() ?? (this._orientation ??= this.getOrientation()); } orientationChanged(rootView: View, newOrientation: 'portrait' | 'landscape' | 'unknown'): void { @@ -825,11 +934,10 @@ export class ApplicationCommon { } const newOrientationCssClass = `${CSSUtils.CLASS_PREFIX}${newOrientation}`; - this.applyCssClass(rootView, ORIENTATION_CSS_CLASSES, newOrientationCssClass, true); + this.applyWindowScopedCssClass(rootView, ORIENTATION_CSS_CLASSES, newOrientationCssClass); - const rootModalViews = >rootView._getRootModalViews(); - rootModalViews.forEach((rootModalView) => { - this.applyCssClass(rootModalView, ORIENTATION_CSS_CLASSES, newOrientationCssClass, true); + this.getOwnedModalViews(rootView).forEach((rootModalView) => { + this.applyWindowScopedCssClass(rootModalView, ORIENTATION_CSS_CLASSES, newOrientationCssClass); // Trigger state change for root modal view classes and media queries rootModalView._onCssStateChange(); @@ -868,16 +976,18 @@ export class ApplicationCommon { }); } + /** + * @deprecated Use Application.primaryWindow?.systemAppearance() - or the NativeWindow of the relevant view - instead. Continues to reflect the primary window. + */ systemAppearance(): 'dark' | 'light' | null { - // return cached value, or get it from the platform specific override - return (this._systemAppearance ??= this.getSystemAppearance()); + return this.primaryWindow?.systemAppearance() ?? (this._systemAppearance ??= this.getSystemAppearance()); } /** * enable/disable systemAppearanceChanged */ setAutoSystemAppearanceChanged(value: boolean): void { - this.autoSystemAppearanceChanged = value; + setAutoSystemAppearanceChanged(value); } /** @@ -891,11 +1001,10 @@ export class ApplicationCommon { } const newSystemAppearanceCssClass = `${CSSUtils.CLASS_PREFIX}${newSystemAppearance}`; - this.applyCssClass(rootView, SYSTEM_APPEARANCE_CSS_CLASSES, newSystemAppearanceCssClass, true); + this.applyWindowScopedCssClass(rootView, SYSTEM_APPEARANCE_CSS_CLASSES, newSystemAppearanceCssClass); - const rootModalViews = rootView._getRootModalViews(); - rootModalViews.forEach((rootModalView) => { - this.applyCssClass(rootModalView as View, SYSTEM_APPEARANCE_CSS_CLASSES, newSystemAppearanceCssClass, true); + this.getOwnedModalViews(rootView).forEach((rootModalView) => { + this.applyWindowScopedCssClass(rootModalView, SYSTEM_APPEARANCE_CSS_CLASSES, newSystemAppearanceCssClass); // Trigger state change for root modal view classes and media queries rootModalView._onCssStateChange(); @@ -925,9 +1034,11 @@ export class ApplicationCommon { }); } + /** + * @deprecated Use Application.primaryWindow?.layoutDirection() - or the NativeWindow of the relevant view - instead. Continues to reflect the primary window. + */ layoutDirection(): CoreTypes.LayoutDirectionType | null { - // return cached value, or get it from the platform specific override - return (this._layoutDirection ??= this.getLayoutDirection()); + return this.primaryWindow?.layoutDirection() ?? (this._layoutDirection ??= this.getLayoutDirection()); } /** @@ -941,11 +1052,10 @@ export class ApplicationCommon { } const newLayoutDirectionCssClass = `${CSSUtils.CLASS_PREFIX}${newLayoutDirection}`; - this.applyCssClass(rootView, LAYOUT_DIRECTION_CSS_CLASSES, newLayoutDirectionCssClass, true); + this.applyWindowScopedCssClass(rootView, LAYOUT_DIRECTION_CSS_CLASSES, newLayoutDirectionCssClass); - const rootModalViews = rootView._getRootModalViews(); - rootModalViews.forEach((rootModalView) => { - this.applyCssClass(rootModalView as View, LAYOUT_DIRECTION_CSS_CLASSES, newLayoutDirectionCssClass, true); + this.getOwnedModalViews(rootView).forEach((rootModalView) => { + this.applyWindowScopedCssClass(rootModalView, LAYOUT_DIRECTION_CSS_CLASSES, newLayoutDirectionCssClass); // Trigger state change for root modal view classes and media queries rootModalView._onCssStateChange(); diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index 0817c9dcab..668087ffd8 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -116,6 +116,7 @@ function initNativeScriptLifecycleCallbacks() { Application.android._registerWindow(nativeWindow); } + nativeWindow._registerConfigurationCallbacks(); nativeWindow._notifyEvent(NativeWindowEvents.attached); this.notifyActivityCreated(activity, savedInstanceState, nativeWindow); @@ -550,9 +551,14 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp } onConfigurationChanged(configuration: android.content.res.Configuration): void { - this.setOrientation(this.getOrientationValue(configuration)); - this.setSystemAppearance(this.getSystemAppearanceValue(configuration)); - this.setLayoutDirection(this.getLayoutDirectionValue(configuration)); + // The application context reports the app-wide configuration, which a window on a + // second display or in split-screen does not necessarily share, so the primary + // window is asked first. Each window tracks its own through its activity. + const primaryWindow = this.primaryWindow; + + this.setOrientation(primaryWindow?.orientation() ?? this.getOrientationValue(configuration)); + this.setSystemAppearance(primaryWindow?.systemAppearance() ?? this.getSystemAppearanceValue(configuration)); + this.setLayoutDirection(primaryWindow?.layoutDirection() ?? this.getLayoutDirectionValue(configuration)); } getNativeApplication() { diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index 8ba7b9e330..872c878a3b 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -465,10 +465,8 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication */ _onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; - // The window the app-level root view state mirrors, and the root view currently - // carrying the app-level trait collection listeners. + // The window whose root view the app-level root view state mirrors. private _mirroredWindow: NativeWindow; - private _appTraitListenerView: View; private _notificationObservers: NotificationObserver[] = []; @@ -1128,9 +1126,16 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } private didChangeStatusBarOrientation(notification: NSNotification) { - const statusBarOrientation = UIApplication.sharedApplication.statusBarOrientation; - const newOrientation = this.getOrientationValue(statusBarOrientation); - this.setOrientation(newOrientation); + // The notification is app-wide, but scenes rotate independently, so every attached + // window is refreshed from its own scene. + for (const nativeWindow of this._windows) { + if (nativeWindow.state !== 'attached') { + continue; + } + + const orientation = nativeWindow.ios?.scene?.interfaceOrientation ?? UIApplication.sharedApplication.statusBarOrientation; + nativeWindow._setOrientation(this.getOrientationValue(orientation)); + } } // --- App-level root view mirror --- @@ -1162,43 +1167,9 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication return; } - const previous = this._appTraitListenerView; - if (previous && previous !== rootView) { - previous.off(IOSHelper.traitCollectionColorAppearanceChangedEvent, this.onRootViewColorAppearanceChanged, this); - previous.off(IOSHelper.traitCollectionLayoutDirectionChangedEvent, this.onRootViewLayoutDirectionChanged, this); - this._appTraitListenerView = null; - } - this._rootView = rootView; setRootView(rootView); - this.initRootView(rootView); - - if (this._appTraitListenerView !== rootView) { - rootView.on(IOSHelper.traitCollectionColorAppearanceChangedEvent, this.onRootViewColorAppearanceChanged, this); - rootView.on(IOSHelper.traitCollectionLayoutDirectionChangedEvent, this.onRootViewLayoutDirectionChanged, this); - this._appTraitListenerView = rootView; - } - } - - private onRootViewColorAppearanceChanged(): void { - const controller = this.rootViewController(); - if (!controller) { - return; - } - this.setSystemAppearance(this.getSystemAppearanceValue(controller.traitCollection.userInterfaceStyle)); - } - - private onRootViewLayoutDirectionChanged(): void { - const controller = this.rootViewController(); - if (!controller) { - return; - } - this.setLayoutDirection(this.getLayoutDirectionValue(controller.traitCollection.layoutDirection)); - } - - private rootViewController(): UIViewController { - const rootView = this._rootView; - return rootView ? ((rootView.viewController || rootView.ios) as UIViewController) : null; + this.initRootView(rootView, this._mirroredWindow); } // --- NativeWindow registry --- diff --git a/packages/core/application/helpers-common.ts b/packages/core/application/helpers-common.ts index eb6209e1bd..67fc80e0d5 100644 --- a/packages/core/application/helpers-common.ts +++ b/packages/core/application/helpers-common.ts @@ -99,6 +99,18 @@ export function setAppInBackground(value: boolean) { _appInBackground = value; } +/** + * Backs `Application.autoSystemAppearanceChanged`. Lives here so windows can read it + * without importing the application module. + */ +let _autoSystemAppearanceChanged: boolean = true; +export function getAutoSystemAppearanceChanged(): boolean { + return _autoSystemAppearanceChanged; +} +export function setAutoSystemAppearanceChanged(value: boolean) { + _autoSystemAppearanceChanged = value; +} + let _iosWindow: UIWindow; export function getiOSWindow(): UIWindow { return _iosWindow; @@ -107,7 +119,7 @@ export function setiOSWindow(value: UIWindow) { _iosWindow = value; } -let _appMainEntry: any /* NavigationEntry */; +let _appMainEntry: any; /* NavigationEntry */ export function getAppMainEntry(): any /* NavigationEntry */ { return _appMainEntry; diff --git a/packages/core/css/system-classes.ts b/packages/core/css/system-classes.ts index 3c1edadcb9..dc5c35fe32 100644 --- a/packages/core/css/system-classes.ts +++ b/packages/core/css/system-classes.ts @@ -1,3 +1,5 @@ +import { CoreTypes } from '../core-types'; + const MODAL = 'modal'; const ROOT = 'root'; const cssClasses = []; @@ -7,6 +9,32 @@ export namespace CSSUtils { export const MODAL_ROOT_VIEW_CSS_CLASS = `${CLASS_PREFIX}${MODAL}`; export const ROOT_VIEW_CSS_CLASS = `${CLASS_PREFIX}${ROOT}`; + // prettier-ignore + export const ORIENTATION_CSS_CLASSES = [ + `${CLASS_PREFIX}${CoreTypes.DeviceOrientation.portrait}`, + `${CLASS_PREFIX}${CoreTypes.DeviceOrientation.landscape}`, + `${CLASS_PREFIX}${CoreTypes.DeviceOrientation.unknown}`, + ]; + + // prettier-ignore + export const SYSTEM_APPEARANCE_CSS_CLASSES = [ + `${CLASS_PREFIX}${CoreTypes.SystemAppearance.light}`, + `${CLASS_PREFIX}${CoreTypes.SystemAppearance.dark}`, + ]; + + // prettier-ignore + export const LAYOUT_DIRECTION_CSS_CLASSES = [ + `${CLASS_PREFIX}${CoreTypes.LayoutDirection.ltr}`, + `${CLASS_PREFIX}${CoreTypes.LayoutDirection.rtl}`, + ]; + + /** + * Classes describing the state of a single window. Two windows can legitimately + * disagree on all of them, so they live on each window's root view (and the modals + * presented over it) rather than in the process-wide system class list. + */ + export const WINDOW_SCOPED_CSS_CLASSES = [...ORIENTATION_CSS_CLASSES, ...SYSTEM_APPEARANCE_CSS_CLASSES, ...LAYOUT_DIRECTION_CSS_CLASSES]; + export function getSystemCssClasses(): string[] { return cssClasses; } diff --git a/packages/core/native-window/native-window-common.ts b/packages/core/native-window/native-window-common.ts index eb9b6669a5..c26afe3aff 100644 --- a/packages/core/native-window/native-window-common.ts +++ b/packages/core/native-window/native-window-common.ts @@ -7,32 +7,18 @@ import type { View } from '../ui/core/view'; import type { Frame } from '../ui/frame'; import type { NavigationEntry } from '../ui/frame/frame-interfaces'; import type { StyleScope } from '../ui/styling/style-scope'; -import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessibility/accessibility-common'; +import { applyAccessibilityCssToRoot, readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessibility/accessibility-common'; import { SDK_VERSION } from '../utils/constants'; -import type { NativeWindowEventData } from './native-window-interfaces'; +import type { NativeWindowEventData, NativeWindowEventName, WindowLayoutDirectionChangedEventData, WindowOrientationChangedEventData, WindowSystemAppearanceChangedEventData } from './native-window-interfaces'; import type { AndroidActivityEventData, AndroidActivityBundleEventData, AndroidActivityResultEventData, AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, SceneEventData } from '../application/application-interfaces'; import { NativeWindowEvents } from './native-window-interfaces'; +import { getAutoSystemAppearanceChanged } from '../application/helpers-common'; import type { WindowRole } from './window-base'; import { WindowBase } from './window-base'; -// prettier-ignore -const ORIENTATION_CSS_CLASSES = [ - `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.portrait}`, - `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.landscape}`, - `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.unknown}`, -]; - -// prettier-ignore -const SYSTEM_APPEARANCE_CSS_CLASSES = [ - `${CSSUtils.CLASS_PREFIX}${CoreTypes.SystemAppearance.light}`, - `${CSSUtils.CLASS_PREFIX}${CoreTypes.SystemAppearance.dark}`, -]; - -// prettier-ignore -const LAYOUT_DIRECTION_CSS_CLASSES = [ - `${CSSUtils.CLASS_PREFIX}${CoreTypes.LayoutDirection.ltr}`, - `${CSSUtils.CLASS_PREFIX}${CoreTypes.LayoutDirection.rtl}`, -]; +const ORIENTATION_CSS_CLASSES = CSSUtils.ORIENTATION_CSS_CLASSES; +const SYSTEM_APPEARANCE_CSS_CLASSES = CSSUtils.SYSTEM_APPEARANCE_CSS_CLASSES; +const LAYOUT_DIRECTION_CSS_CLASSES = CSSUtils.LAYOUT_DIRECTION_CSS_CLASSES; /** * Cross-platform NativeWindow base class. @@ -113,23 +99,68 @@ export abstract class NativeWindow extends WindowBase { /** * Get the current orientation of this window. + * + * Read from the native surface while the window is attached; a detached window + * reports the last value it saw. + * + * A read that catches a change the platform has not reported yet goes through + * {@link _setOrientation}, so the change is never swallowed by the reading. */ orientation(): 'portrait' | 'landscape' | 'unknown' { - return (this._orientation ??= this._getOrientation()); + if (this.state === 'attached') { + const value = this._getOrientation(); + if (this._orientation === undefined) { + this._orientation = value; + } else if (this._orientation !== value) { + this._setOrientation(value); + } + } + + return this._orientation; } /** * Get the current system appearance of this window. + * + * Read from the native surface while the window is attached; a detached window + * reports the last value it saw. + * + * A read that catches a change the platform has not reported yet goes through + * {@link _setSystemAppearance}, so the change is never swallowed by the reading. */ systemAppearance(): 'light' | 'dark' | null { - return (this._systemAppearance ??= this._getSystemAppearance()); + if (this.state === 'attached') { + const value = this._getSystemAppearance(); + if (this._systemAppearance === undefined) { + this._systemAppearance = value; + } else if (this._systemAppearance !== value && value !== null) { + this._setSystemAppearance(value); + } + } + + return this._systemAppearance; } /** * Get the current layout direction of this window. + * + * Read from the native surface while the window is attached; a detached window + * reports the last value it saw. + * + * A read that catches a change the platform has not reported yet goes through + * {@link _setLayoutDirection}, so the change is never swallowed by the reading. */ layoutDirection(): CoreTypes.LayoutDirectionType | null { - return (this._layoutDirection ??= this._getLayoutDirection()); + if (this.state === 'attached') { + const value = this._getLayoutDirection(); + if (this._layoutDirection === undefined) { + this._layoutDirection = value; + } else if (this._layoutDirection !== value && value !== null) { + this._setLayoutDirection(value); + } + } + + return this._layoutDirection; } // --- Typed event overloads --- @@ -145,6 +176,9 @@ export abstract class NativeWindow extends WindowBase { on(event: 'attached', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'detached', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; on(event: 'displayed', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'orientationChanged', callback: (data: WindowOrientationChangedEventData) => void, thisArg?: any): void; + on(event: 'systemAppearanceChanged', callback: (data: WindowSystemAppearanceChangedEventData) => void, thisArg?: any): void; + on(event: 'layoutDirectionChanged', callback: (data: WindowLayoutDirectionChangedEventData) => void, thisArg?: any): void; on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; @@ -183,14 +217,12 @@ export abstract class NativeWindow extends WindowBase { this._setRootViewCSSClasses(rootView); readyInitAccessibilityCssHelper(); readyInitFontScale(); + applyAccessibilityCssToRoot(rootView); } private _setRootViewCSSClasses(rootView: View): void { const platform = Device.os.toLowerCase(); const deviceType = Device.deviceType.toLowerCase(); - const orientationValue = this.orientation(); - const appearanceValue = this.systemAppearance(); - const directionValue = this.layoutDirection(); if (platform) { CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${platform}`); @@ -206,22 +238,28 @@ export abstract class NativeWindow extends WindowBase { CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${deviceType}`); } + rootView.cssClasses.add(CSSUtils.ROOT_VIEW_CSS_CLASS); + const rootViewCssClasses = CSSUtils.getSystemCssClasses(); + rootViewCssClasses.forEach((c) => rootView.cssClasses.add(c)); + + // Two windows can disagree on these, so they never reach the process-wide + // system class list — see CSSUtils.WINDOW_SCOPED_CSS_CLASSES. + const orientationValue = this.orientation(); + const appearanceValue = this.systemAppearance(); + const directionValue = this.layoutDirection(); + if (orientationValue) { - CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${orientationValue}`); + rootView.cssClasses.add(`${CSSUtils.CLASS_PREFIX}${orientationValue}`); } if (appearanceValue) { - CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${appearanceValue}`); + rootView.cssClasses.add(`${CSSUtils.CLASS_PREFIX}${appearanceValue}`); } if (directionValue) { - CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${directionValue}`); + rootView.cssClasses.add(`${CSSUtils.CLASS_PREFIX}${directionValue}`); } - rootView.cssClasses.add(CSSUtils.ROOT_VIEW_CSS_CLASS); - const rootViewCssClasses = CSSUtils.getSystemCssClasses(); - rootViewCssClasses.forEach((c) => rootView.cssClasses.add(c)); - this._increaseStyleScopeVersion(rootView); rootView._onCssStateChange(); @@ -245,6 +283,7 @@ export abstract class NativeWindow extends WindowBase { const cssClass = `${CSSUtils.CLASS_PREFIX}${value}`; this._applyCssClass(this._rootView, ORIENTATION_CSS_CLASSES, cssClass); } + this._notifyValueChanged(NativeWindowEvents.orientationChanged, value); } /** @@ -255,10 +294,13 @@ export abstract class NativeWindow extends WindowBase { return; } this._systemAppearance = value; - if (this._rootView) { + // `Application.autoSystemAppearanceChanged` opts out of the CSS classes only — + // the event still fires so apps driving their own theming can react to it. + if (this._rootView && getAutoSystemAppearanceChanged()) { const cssClass = `${CSSUtils.CLASS_PREFIX}${value}`; this._applyCssClass(this._rootView, SYSTEM_APPEARANCE_CSS_CLASSES, cssClass); } + this._notifyValueChanged(NativeWindowEvents.systemAppearanceChanged, value); } /** @@ -273,25 +315,36 @@ export abstract class NativeWindow extends WindowBase { const cssClass = `${CSSUtils.CLASS_PREFIX}${value}`; this._applyCssClass(this._rootView, LAYOUT_DIRECTION_CSS_CLASSES, cssClass); } + this._notifyValueChanged(NativeWindowEvents.layoutDirectionChanged, value); } // --- Internal helpers --- + private _notifyValueChanged(eventName: NativeWindowEventName, newValue: unknown): void { + this.notify({ + eventName, + object: this, + window: this, + newValue, + }); + } + private _applyCssClass(rootView: View, cssClasses: string[], newCssClass: string): void { if (!rootView.cssClasses.has(newCssClass)) { - cssClasses.forEach((cssClass) => { - CSSUtils.removeSystemCssClass(cssClass); - rootView.cssClasses.delete(cssClass); - }); - CSSUtils.pushToSystemCssClasses(newCssClass); + cssClasses.forEach((cssClass) => rootView.cssClasses.delete(cssClass)); rootView.cssClasses.add(newCssClass); this._increaseStyleScopeVersion(rootView); rootView._onCssStateChange(); } - // Apply to modal views + // The modal registry is process-wide, so only the modals presented over this + // window's root view may follow it. const rootModalViews = >rootView._getRootModalViews(); rootModalViews.forEach((modalView) => { + if (modalView._getRootModalHost() !== rootView) { + return; + } + if (!modalView.cssClasses.has(newCssClass)) { cssClasses.forEach((cssClass) => modalView.cssClasses.delete(cssClass)); modalView.cssClasses.add(newCssClass); @@ -314,6 +367,12 @@ export abstract class NativeWindow extends WindowBase { * to it keeps working once a surface re-attaches. */ _detach(): void { + // Take a final reading while the surface can still answer and the root view is + // still up: from here on these are what the window reports. + this.orientation(); + this.systemAppearance(); + this.layoutDirection(); + if (this._rootView) { if (this._rootView.isLoaded) { this._rootView.callUnloaded(); @@ -322,11 +381,6 @@ export abstract class NativeWindow extends WindowBase { this._rootView._onRootViewReset(); } - // These traits belong to the native surface, so a re-attached window has to read them again. - this._orientation = null; - this._systemAppearance = null; - this._layoutDirection = null; - this._setState('detached'); this._notifyEvent(NativeWindowEvents.detached); } diff --git a/packages/core/native-window/native-window-interfaces.ts b/packages/core/native-window/native-window-interfaces.ts index 2efe62e7f0..a7dce07cae 100644 --- a/packages/core/native-window/native-window-interfaces.ts +++ b/packages/core/native-window/native-window-interfaces.ts @@ -1,3 +1,4 @@ +import type { CoreTypes } from '../core-types'; import type { EventData } from '../data/observable'; import type { View } from '../ui/core/view'; import type { NavigationEntry } from '../ui/frame/frame-interfaces'; @@ -33,6 +34,12 @@ export const NativeWindowEvents = { displayed: 'displayed', /** Fired when the root view content is set or changed. */ contentLoaded: 'contentLoaded', + /** Fired when the orientation of this window changes. */ + orientationChanged: 'orientationChanged', + /** Fired when the system appearance of this window changes between light and dark. */ + systemAppearanceChanged: 'systemAppearanceChanged', + /** Fired when the layout direction of this window changes between ltr and rtl. */ + layoutDirectionChanged: 'layoutDirectionChanged', // iOS scene lifecycle events /** Fired when the scene is about to connect (iOS only). */ @@ -103,6 +110,30 @@ export interface NativeWindowEventData extends WindowBaseEventData { window: NativeWindow; } +/** + * Event data for the `orientationChanged` event of a NativeWindow. + */ +export interface WindowOrientationChangedEventData extends NativeWindowEventData { + /** The orientation the window is now in. */ + newValue: 'portrait' | 'landscape' | 'unknown'; +} + +/** + * Event data for the `systemAppearanceChanged` event of a NativeWindow. + */ +export interface WindowSystemAppearanceChangedEventData extends NativeWindowEventData { + /** The system appearance the window is now showing. */ + newValue: 'light' | 'dark'; +} + +/** + * Event data for the `layoutDirectionChanged` event of a NativeWindow. + */ +export interface WindowLayoutDirectionChangedEventData extends NativeWindowEventData { + /** The layout direction the window is now using. */ + newValue: CoreTypes.LayoutDirectionType; +} + /** * Event data fired on Application when a window opens. */ diff --git a/packages/core/native-window/native-window.android.ts b/packages/core/native-window/native-window.android.ts index af35044cf0..ee538a77a1 100644 --- a/packages/core/native-window/native-window.android.ts +++ b/packages/core/native-window/native-window.android.ts @@ -12,6 +12,8 @@ import type { WindowRole } from './window-base'; */ export class AndroidNativeWindow extends NativeWindow { private _activity: WeakRef; + private _componentCallbacks: android.content.ComponentCallbacks2; + private _componentCallbacksActivity: WeakRef; constructor(activity: androidx.appcompat.app.AppCompatActivity, id?: string, isPrimary = false, role: WindowRole = 'application') { super(id, isPrimary, role); @@ -26,6 +28,58 @@ export class AndroidNativeWindow extends NativeWindow { this._setState('attached'); } + /** + * @internal – observe the activity's own configuration. + * + * Registered on the activity rather than the application context: in multi-window + * and multi-display setups each activity gets its own configuration, and only these + * callbacks report the one this window actually renders with. + */ + _registerConfigurationCallbacks(): void { + const activity = this.activity; + if (!activity || this._componentCallbacks) { + return; + } + + const callbacks = new android.content.ComponentCallbacks2({ + onLowMemory(): void { + // Handled application-wide. + }, + onTrimMemory(level: number): void { + // Handled application-wide. + }, + onConfigurationChanged: (newConfiguration: android.content.res.Configuration): void => { + this._setOrientation(this._getOrientationValue(newConfiguration)); + this._setSystemAppearance(this._getSystemAppearanceValue(newConfiguration)); + this._setLayoutDirection(this._getLayoutDirectionValue(newConfiguration)); + }, + }); + + activity.registerComponentCallbacks(callbacks); + this._componentCallbacks = callbacks; + this._componentCallbacksActivity = new WeakRef(activity); + } + + /** + * @internal – drop the configuration callbacks, so a recreated activity does not + * leave the previous one registered. + */ + _unregisterConfigurationCallbacks(): void { + const callbacks = this._componentCallbacks; + if (!callbacks) { + return; + } + + this._componentCallbacks = null; + this._componentCallbacksActivity?.deref()?.unregisterComponentCallbacks(callbacks); + this._componentCallbacksActivity = null; + } + + _detach(): void { + this._unregisterConfigurationCallbacks(); + super._detach(); + } + /** * The wrapped Android Activity (may be GC'd). */ @@ -137,6 +191,7 @@ export class AndroidNativeWindow extends NativeWindow { } protected _onDestroy(): void { + this._unregisterConfigurationCallbacks(); super._onDestroy(); this._activity = null; } diff --git a/packages/core/native-window/window-base.ts b/packages/core/native-window/window-base.ts index b32741ab6c..e785d3b178 100644 --- a/packages/core/native-window/window-base.ts +++ b/packages/core/native-window/window-base.ts @@ -1,5 +1,5 @@ import { Observable } from '../data/observable'; -import type { NativeWindowEventName, WindowBaseEventData } from './native-window-interfaces'; +import type { NativeWindowEventName, WindowBaseEventData, WindowLayoutDirectionChangedEventData, WindowOrientationChangedEventData, WindowSystemAppearanceChangedEventData } from './native-window-interfaces'; import type { AndroidActivityEventData, AndroidActivityBundleEventData, AndroidActivityResultEventData, AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, SceneEventData } from '../application/application-interfaces'; /** @@ -95,6 +95,9 @@ export abstract class WindowBase extends Observable { on(event: 'attached', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; on(event: 'detached', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; on(event: 'displayed', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + on(event: 'orientationChanged', callback: (data: WindowOrientationChangedEventData) => void, thisArg?: any): void; + on(event: 'systemAppearanceChanged', callback: (data: WindowSystemAppearanceChangedEventData) => void, thisArg?: any): void; + on(event: 'layoutDirectionChanged', callback: (data: WindowLayoutDirectionChangedEventData) => void, thisArg?: any): void; on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; diff --git a/packages/core/ui/core/view-base/index.ts b/packages/core/ui/core/view-base/index.ts index adaa79ac4e..83c9c57fc1 100644 --- a/packages/core/ui/core/view-base/index.ts +++ b/packages/core/ui/core/view-base/index.ts @@ -439,7 +439,7 @@ export abstract class ViewBase extends Observable { public defaultVisualState: string = 'normal'; public _domId: number; - public _context: any /* android.content.Context */; + public _context: any; /* android.content.Context */ public _isAddedToNativeVisualTree: boolean; /* "ui/styling/style-scope" */ public _cssState: CssState = new CssState(new WeakRef(this)); public _styleScope: StyleScope; @@ -1615,6 +1615,9 @@ export const classNameProperty = new Property({ const shouldAddModalRootViewCssClasses = cssClasses.has(CSSUtils.MODAL_ROOT_VIEW_CSS_CLASS); const shouldAddRootViewCssClasses = cssClasses.has(CSSUtils.ROOT_VIEW_CSS_CLASS); + // Window-scoped classes are absent from the system class list, so they have to be + // carried over by hand or a root view would lose them on every className change. + const windowScopedCssClasses = shouldAddModalRootViewCssClasses || shouldAddRootViewCssClasses ? CSSUtils.WINDOW_SCOPED_CSS_CLASSES.filter((c) => cssClasses.has(c)) : []; cssClasses.clear(); @@ -1628,6 +1631,10 @@ export const classNameProperty = new Property({ cssClasses.add(rootViewsCssClasses[i]); } + for (let i = 0, length = windowScopedCssClasses.length; i < length; i++) { + cssClasses.add(windowScopedCssClasses[i]); + } + if (typeof newValue === 'string' && newValue !== '') { const classes = newValue.split(' '); for (let i = 0, length = classes.length; i < length; i++) { diff --git a/packages/core/ui/core/view/index.d.ts b/packages/core/ui/core/view/index.d.ts index d476659eb6..2226fd07de 100644 --- a/packages/core/ui/core/view/index.d.ts +++ b/packages/core/ui/core/view/index.d.ts @@ -954,6 +954,13 @@ export abstract class View extends ViewCommon { */ _getRootModalViews(): Array; + /** + * Internal method: + * Walks up the view tree — through the presenting view of any modal on the way — + * to the root this view ultimately lives under, which is the root view of its window. + */ + _getRootModalHost(): ViewBase; + _eachLayoutView(callback: (View) => void): void; /** diff --git a/packages/core/ui/core/view/view-common.ts b/packages/core/ui/core/view/view-common.ts index e10a23b776..38ff1cfb38 100644 --- a/packages/core/ui/core/view/view-common.ts +++ b/packages/core/ui/core/view/view-common.ts @@ -263,6 +263,22 @@ export abstract class ViewCommon extends ViewBase { return _rootModalViews; } + public _getRootModalHost(): ViewBase { + let view: ViewBase = this; + + while (view) { + // A modal root has no parent, so the chain continues through the view it was + // presented over; nested modals therefore resolve to the same window root. + const next = view.parent ?? (view)._modalParent; + if (!next) { + break; + } + view = next; + } + + return view; + } + public _onLivesync(context?: ModuleContext): boolean { if (Trace.isEnabled()) { Trace.write(`${this}._onLivesync(${JSON.stringify(context)})`, Trace.categories.Livesync); @@ -485,6 +501,17 @@ export abstract class ViewCommon extends ViewBase { const modalRootViewCssClasses = CSSUtils.getSystemCssClasses(); modalRootViewCssClasses.forEach((c) => this.cssClasses.add(c)); + // Orientation/appearance/direction are not in the system class list because they + // differ per window, so they are inherited from the root this modal opens over. + const host = parent._getRootModalHost(); + if (host) { + CSSUtils.WINDOW_SCOPED_CSS_CLASSES.forEach((c) => { + if (host.cssClasses.has(c)) { + this.cssClasses.add(c); + } + }); + } + parent._modal = this; this.style.fontScaleInternal = getFontScale(); this._modalParent = parent; diff --git a/packages/core/ui/frame/index.android.ts b/packages/core/ui/frame/index.android.ts index 669b5af11f..6c5d3fb4a4 100644 --- a/packages/core/ui/frame/index.android.ts +++ b/packages/core/ui/frame/index.android.ts @@ -1143,8 +1143,10 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks if (!nativeWindow && isEmbedded()) { // When embedded, the host owns the activity and may never install our lifecycle // callbacks, so this is the only place the window can come into existence. - nativeWindow = new AndroidNativeWindow(activity, AndroidNativeWindow.newWindowId(), Application.android._getWindows().length === 0, 'embedded'); - Application.android._registerWindow(nativeWindow); + const embeddedWindow = new AndroidNativeWindow(activity, AndroidNativeWindow.newWindowId(), Application.android._getWindows().length === 0, 'embedded'); + Application.android._registerWindow(embeddedWindow); + embeddedWindow._registerConfigurationCallbacks(); + nativeWindow = embeddedWindow; } if (!rootView && fireLaunchEvent && nativeWindow) { @@ -1184,7 +1186,7 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks this._rootView = rootView; // sets root classes once rootView is ready... - Application.initRootView(rootView); + Application.initRootView(rootView, nativeWindow); nativeWindow?._adoptRootView(rootView); } From 8ec37de10c87e66cc4ba70bb08a85033c2b5ed52 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 18:30:57 -0300 Subject: [PATCH 14/23] types(core): handwritten native-window typings following the frame pattern Adds a curated public surface for native-window - WindowBase, NativeWindow, the event constants and payload types, the resolver types - keeping internals and the concrete platform classes out of the published typings. Also brings application.d.ts back in line with the implementation: the platform classes declared on() as method overloads, which hid ApplicationCommon's own handler surface, and several referenced types were never imported and silently resolved to any. --- .../core/application/application-common.ts | 3 +- .../application/application-interfaces.ts | 2 +- packages/core/application/application.d.ts | 108 +++-- packages/core/native-window/index.d.ts | 405 ++++++++++++++++++ .../native-window/native-window-interfaces.ts | 3 +- 5 files changed, 471 insertions(+), 50 deletions(-) create mode 100644 packages/core/native-window/index.d.ts diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index 4e61a6b9eb..0a841f1c19 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -16,8 +16,7 @@ import { applyAccessibilityCssToRoot, readyInitAccessibilityCssHelper, readyInit import { getAppMainEntry, getAutoSystemAppearanceChanged, isAppInBackground, setAppInBackground, setAppMainEntry, setAutoSystemAppearanceChanged } from './helpers-common'; import { getNativeScriptGlobals } from '../globals/global-utils'; import { SDK_VERSION } from '../utils/constants'; -import type { NativeWindow, PrimaryWindowChangedEventData, WindowCloseEventData, WindowContentRequest, WindowContentResolver, WindowLayoutDirectionChangedEventData, WindowOpenEventData, WindowOpenOptions, WindowOrientationChangedEventData, WindowSystemAppearanceChangedEventData } from '../native-window'; -import type { WindowBase, WindowRole } from '../native-window/window-base'; +import type { NativeWindow, PrimaryWindowChangedEventData, WindowBase, WindowCloseEventData, WindowContentRequest, WindowContentResolver, WindowLayoutDirectionChangedEventData, WindowOpenEventData, WindowOpenOptions, WindowOrientationChangedEventData, WindowRole, WindowSystemAppearanceChangedEventData } from '../native-window'; import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; const ORIENTATION_CSS_CLASSES = CSSUtils.ORIENTATION_CSS_CLASSES; diff --git a/packages/core/application/application-interfaces.ts b/packages/core/application/application-interfaces.ts index 2242b23378..0d983bddcc 100644 --- a/packages/core/application/application-interfaces.ts +++ b/packages/core/application/application-interfaces.ts @@ -1,7 +1,7 @@ import type { EventData, Observable } from '../data/observable'; import type { View } from '../ui/core/view'; import type { CoreTypes } from '../core-types'; -import type { NativeWindow } from '../native-window/native-window-common'; +import type { NativeWindow } from '../native-window'; /** * An extended JavaScript Error which will have the nativeError property initialized in case the error is caused by executing platform-specific code. diff --git a/packages/core/application/application.d.ts b/packages/core/application/application.d.ts index 9a6d4fc9fb..bdb6e06d4b 100644 --- a/packages/core/application/application.d.ts +++ b/packages/core/application/application.d.ts @@ -1,13 +1,52 @@ import { ApplicationCommon } from './application-common'; -import { FontScaleCategory } from '../accessibility/font-scale-common'; -import type { NativeWindow } from '../native-window/native-window-common'; -import type { WindowOpenEventData, WindowCloseEventData, WindowOpenOptions } from '../native-window/native-window-interfaces'; +import type { AndroidAccessibilityEvent } from '../accessibility/accessibility-common'; +import type { View } from '../ui/core/view'; +import type { Page } from '../ui/page'; +import type { AndroidActivityEventData, AndroidActivityBundleEventData, AndroidActivityResultEventData, AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, SceneEventData } from './application-interfaces'; +import type { NativeWindow, WindowOpenOptions } from '../native-window'; export * from './application-common'; export * from './application-interfaces'; export const Application: ApplicationCommon; +/** + * The Application `on` overloads, widened with the Android activity bridges. + * + * An intersection rather than method overloads: overriding `on` as a method would replace + * the inherited {@link ApplicationCommon} overloads instead of adding to them, hiding every + * app-level event from `Application.android.on()`. + */ +type AndroidApplicationOn = ApplicationCommon['on'] & { + (event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + (event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + (event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + (event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + (event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + (event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + (event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + (event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + (event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + (event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + (event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; +}; + +/** + * The Application `on` overloads, widened with the iOS scene bridges. + * + * An intersection rather than method overloads: overriding `on` as a method would replace + * the inherited {@link ApplicationCommon} overloads instead of adding to them, hiding every + * app-level event from `Application.ios.on()`. + */ +type IOSApplicationOn = ApplicationCommon['on'] & { + (event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + (event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; + (event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; + (event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; + (event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; + (event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; +}; + export class AndroidApplication extends ApplicationCommon { static readonly activityCreatedEvent = 'activityCreated'; static readonly activityDestroyedEvent = 'activityDestroyed'; @@ -21,17 +60,19 @@ export class AndroidApplication extends ApplicationCommon { static readonly activityNewIntentEvent = 'activityNewIntent'; static readonly activityRequestPermissionsEvent = 'activityRequestPermissions'; - readonly activityCreatedEvent = AndroidApplication.activityCreatedEvent; - readonly activityDestroyedEvent = AndroidApplication.activityDestroyedEvent; - readonly activityStartedEvent = AndroidApplication.activityStartedEvent; - readonly activityPausedEvent = AndroidApplication.activityPausedEvent; - readonly activityResumedEvent = AndroidApplication.activityResumedEvent; - readonly activityStoppedEvent = AndroidApplication.activityStoppedEvent; - readonly saveActivityStateEvent = AndroidApplication.saveActivityStateEvent; - readonly activityResultEvent = AndroidApplication.activityResultEvent; - readonly activityBackPressedEvent = AndroidApplication.activityBackPressedEvent; - readonly activityNewIntentEvent = AndroidApplication.activityNewIntentEvent; - readonly activityRequestPermissionsEvent = AndroidApplication.activityRequestPermissionsEvent; + readonly activityCreatedEvent = 'activityCreated'; + readonly activityDestroyedEvent = 'activityDestroyed'; + readonly activityStartedEvent = 'activityStarted'; + readonly activityPausedEvent = 'activityPaused'; + readonly activityResumedEvent = 'activityResumed'; + readonly activityStoppedEvent = 'activityStopped'; + readonly saveActivityStateEvent = 'saveActivityState'; + readonly activityResultEvent = 'activityResult'; + readonly activityBackPressedEvent = 'activityBackPressed'; + readonly activityNewIntentEvent = 'activityNewIntent'; + readonly activityRequestPermissionsEvent = 'activityRequestPermissions'; + + on: AndroidApplicationOn; getNativeApplication(): android.app.Application; @@ -82,9 +123,10 @@ export class AndroidApplication extends ApplicationCommon { * For more information, please visit 'http://developer.android.com/reference/android/content/Context.html#registerReceiver%28android.content.BroadcastReceiver,%20android.content.IntentFilter%29' * @param intentFilter A string containing the intent filter. * @param onReceiveCallback A callback function that will be called each time the receiver receives a broadcast. + * @param flags Any combination of `RECEIVER_VISIBLE_TO_INSTANT_APPS` (1), `RECEIVER_EXPORTED` (2) and `RECEIVER_NOT_EXPORTED` (4). Defaults to `RECEIVER_EXPORTED`. Only honored from API 26 onwards. * @return A function that can be called to unregister the receiver. */ - registerBroadcastReceiver(intentFilter: string, onReceiveCallback: (context: android.content.Context, intent: android.content.Intent) => void): () => void; + registerBroadcastReceiver(intentFilter: string, onReceiveCallback: (context: android.content.Context, intent: android.content.Intent) => void, flags?: number): () => void; /** * Unregister a previously registered BroadcastReceiver. @@ -98,28 +140,13 @@ export class AndroidApplication extends ApplicationCommon { * @param intentFilter A string containing the intent filter. * @deprecated Use `getRegisteredBroadcastReceivers` instead. */ - getRegisteredBroadcastReceiver(intentFilter: string): android.content.BroadcastReceiver; + getRegisteredBroadcastReceiver(intentFilter: string): android.content.BroadcastReceiver | undefined; /** * Get all registered BroadcastReceivers for a specific intent filter. * @param intentFilter a string containing the intent filter */ getRegisteredBroadcastReceivers(intentFilter: string): android.content.BroadcastReceiver[]; - on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; - on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; - on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; - on(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; - on(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; - on(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; - on(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; - on(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; - on(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; - on(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; - on(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; - - on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; - on(event: 'windowClose', callback: (args: WindowCloseEventData) => void, thisArg?: any): void; - /** * @internal - Get a NativeWindow by its activity. */ @@ -140,6 +167,8 @@ export class AndroidApplication extends ApplicationCommon { } export class iOSApplication extends ApplicationCommon { + on: IOSApplicationOn; + /** * The root view controller for the application. */ @@ -216,9 +245,9 @@ export class iOSApplication extends ApplicationCommon { /** * Closes a secondary window/scene. * If no target is provided, attempts to close a non-primary active scene. - * @param target Optional target to resolve the scene to close. Can be a View, UIWindow, UIWindowScene, or a string scene identifier. + * @param target Optional target to resolve the window to close. Can be a NativeWindow, a View, a UIWindow, a UIWindowScene, or a string scene identifier. */ - closeWindow(target?: View | UIWindow | UIWindowScene | string): void; + closeWindow(target?: NativeWindow | View | UIWindow | UIWindowScene | string): void; /** * Gets all windows for the application. @@ -287,19 +316,8 @@ export class iOSApplication extends ApplicationCommon { */ onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; - on(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; - on(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; - on(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; - on(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; - on(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; - on(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; - - on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; - on(event: 'windowClose', callback: (args: WindowCloseEventData) => void, thisArg?: any): void; - /** - * Flag to be set when the launch event should be delayed until the application has become active. - * This is useful when you want to process notifications or data in the background without creating the UI. + * @deprecated Has no effect. Application initialization is signalled by the 'ready' event, which is never deferred. */ shouldDelayLaunchEvent: boolean; } diff --git a/packages/core/native-window/index.d.ts b/packages/core/native-window/index.d.ts new file mode 100644 index 0000000000..22095c03e5 --- /dev/null +++ b/packages/core/native-window/index.d.ts @@ -0,0 +1,405 @@ +import { Observable } from '../data/observable'; +import type { CoreTypes } from '../core-types'; +import type { View } from '../ui/core/view'; +import type { NavigationEntry } from '../ui/frame/frame-interfaces'; +import type { AndroidActivityEventData, AndroidActivityBundleEventData, AndroidActivityResultEventData, AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, SceneEventData } from '../application/application-interfaces'; +import type { NativeWindowEventData, WindowBaseEventData, WindowLayoutDirectionChangedEventData, WindowOrientationChangedEventData, WindowSystemAppearanceChangedEventData } from './native-window-interfaces'; + +export * from './native-window-interfaces'; + +/** + * The purpose a window surface serves. + * + * - `application` – a regular app window (iOS application scene, Android activity). + * - `embedded` – a window hosted inside another app or container. + * - `carplay` – a CarPlay template scene. + * - `externalDisplay` – an external/secondary display scene. + */ +export type WindowRole = 'application' | 'embedded' | 'carplay' | 'externalDisplay'; + +/** + * The lifecycle state of a window surface. + * + * - `attached` – connected to a live native surface. + * - `detached` – the native surface went away but the window may be reconnected. + * - `closed` – permanently torn down. + */ +export type WindowState = 'attached' | 'detached' | 'closed'; + +/** + * Cross-platform base for any window surface. + * + * Carries identity, role, state, lifecycle events and the native accessors. + * Surfaces that host a NativeScript view tree are {@link NativeWindow} instances. + */ +export abstract class WindowBase extends Observable { + /** + * Stable identifier of this window, unique for the lifetime of the JS context. + * Survives a detach/re-attach, so it can be used to correlate a window across + * an iOS scene reconnect or an Android activity recreation. + */ + readonly id: string; + + /** + * The purpose this window surface serves. + */ + readonly role: WindowRole; + + /** + * Where this window currently sits in its lifecycle. + */ + readonly state: WindowState; + + /** + * Whether this is the application's primary window. + * + * At most one window is primary at a time. When the primary window closes another + * attached window is promoted and `primaryWindowChanged` is raised on the Application. + */ + readonly isPrimary: boolean; + + /** + * The iOS surface backing this window, or `undefined` when not running on iOS. + * `scene` is absent for apps still on the pre-scene (window-only) lifecycle. + */ + readonly ios?: { readonly scene?: UIWindowScene; readonly uiWindow: UIWindow }; + + /** + * The Android surface backing this window, or `undefined` when not running on Android + * or when the activity is already gone. + */ + readonly android?: { readonly activity: androidx.appcompat.app.AppCompatActivity }; + + /** + * Closes this window. + * + * Ends the window session: the native surface is dismissed, `close` is raised and the + * window is dropped from the Application registry. + */ + abstract close(): void; + + /** + * Raised when the window becomes the active/focused window. + */ + on(event: 'activate', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + + /** + * Raised when the window loses focus. + */ + on(event: 'deactivate', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + + /** + * Raised when the window enters the background. + */ + on(event: 'background', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + + /** + * Raised when the window enters the foreground. + */ + on(event: 'foreground', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + + /** + * Raised when a native surface is bound to the window - both on the first connect and + * on every re-attach after a `detached`. + */ + on(event: 'attached', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + + /** + * Raised when the native surface goes away while the window session stays alive + * (iOS scene disconnect, Android activity recreation). + * + * The window stays registered on the Application and keeps its listeners, so the same + * instance is handed back when a surface re-attaches and handlers registered before the + * detach keep working afterwards. + */ + on(event: 'detached', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + + /** + * Raised when the window session ends for good. + * + * Fires exactly once per window. Listeners stay registered for the whole teardown, so a + * handler added at any earlier point still observes it; immediately after it is + * dispatched the framework drops every listener on the instance, so nothing registered + * on a window outlives the window. + */ + on(event: 'close', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + + /** + * Raised after the window content has been displayed for the first time. + */ + on(event: 'displayed', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + + /** + * Raised when the orientation of this window changes. + */ + on(event: 'orientationChanged', callback: (data: WindowOrientationChangedEventData) => void, thisArg?: any): void; + + /** + * Raised when the system appearance of this window changes between light and dark. + */ + on(event: 'systemAppearanceChanged', callback: (data: WindowSystemAppearanceChangedEventData) => void, thisArg?: any): void; + + /** + * Raised when the layout direction of this window changes between ltr and rtl. + */ + on(event: 'layoutDirectionChanged', callback: (data: WindowLayoutDirectionChangedEventData) => void, thisArg?: any): void; + + on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + on(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + on(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + on(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + on(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + on(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + + /** + * Adds a listener for the specified event name. + * + * @param eventName The name of the event. + * @param callback The event listener to add. Will be called when an event of + * the given name is raised. + * @param thisArg An optional parameter which, when set, will be bound as the + * `this` context when the callback is called. Falsy values will be not be + * bound. + */ + on(eventName: string, callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + + once(event: 'activate', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + once(event: 'deactivate', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + once(event: 'background', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + once(event: 'foreground', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + once(event: 'attached', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + once(event: 'detached', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + once(event: 'close', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + once(event: 'displayed', callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + once(event: 'orientationChanged', callback: (data: WindowOrientationChangedEventData) => void, thisArg?: any): void; + once(event: 'systemAppearanceChanged', callback: (data: WindowSystemAppearanceChangedEventData) => void, thisArg?: any): void; + once(event: 'layoutDirectionChanged', callback: (data: WindowLayoutDirectionChangedEventData) => void, thisArg?: any): void; + once(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + once(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + once(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + once(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + once(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + once(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + once(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + once(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + once(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + once(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + once(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + once(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + + /** + * Adds a listener for the specified event name that is removed as soon as it is raised + * once. + */ + once(eventName: string, callback: (data: WindowBaseEventData) => void, thisArg?: any): void; + + off(event: 'activate', callback?: (data: WindowBaseEventData) => void, thisArg?: any): void; + off(event: 'deactivate', callback?: (data: WindowBaseEventData) => void, thisArg?: any): void; + off(event: 'background', callback?: (data: WindowBaseEventData) => void, thisArg?: any): void; + off(event: 'foreground', callback?: (data: WindowBaseEventData) => void, thisArg?: any): void; + off(event: 'attached', callback?: (data: WindowBaseEventData) => void, thisArg?: any): void; + off(event: 'detached', callback?: (data: WindowBaseEventData) => void, thisArg?: any): void; + off(event: 'close', callback?: (data: WindowBaseEventData) => void, thisArg?: any): void; + off(event: 'displayed', callback?: (data: WindowBaseEventData) => void, thisArg?: any): void; + off(event: 'orientationChanged', callback?: (data: WindowOrientationChangedEventData) => void, thisArg?: any): void; + off(event: 'systemAppearanceChanged', callback?: (data: WindowSystemAppearanceChangedEventData) => void, thisArg?: any): void; + off(event: 'layoutDirectionChanged', callback?: (data: WindowLayoutDirectionChangedEventData) => void, thisArg?: any): void; + off(event: 'activityCreated', callback?: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + off(event: 'activityDestroyed', callback?: (args: AndroidActivityEventData) => void, thisArg?: any): void; + off(event: 'activityStarted', callback?: (args: AndroidActivityEventData) => void, thisArg?: any): void; + off(event: 'activityPaused', callback?: (args: AndroidActivityEventData) => void, thisArg?: any): void; + off(event: 'activityResumed', callback?: (args: AndroidActivityEventData) => void, thisArg?: any): void; + off(event: 'activityStopped', callback?: (args: AndroidActivityEventData) => void, thisArg?: any): void; + off(event: 'saveActivityState', callback?: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + off(event: 'activityResult', callback?: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + off(event: 'activityBackPressed', callback?: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + off(event: 'activityNewIntent', callback?: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + off(event: 'activityRequestPermissions', callback?: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + off(event: 'sceneWillConnect', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(event: 'sceneDidActivate', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(event: 'sceneWillResignActive', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(event: 'sceneWillEnterForeground', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(event: 'sceneDidEnterBackground', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(event: 'sceneDidDisconnect', callback?: (args: SceneEventData) => void, thisArg?: any): void; + + /** + * Removes a listener for the specified event name. Omitting the callback removes every + * listener registered for that event on this window. + */ + off(eventName: string, callback?: (data: WindowBaseEventData) => void, thisArg?: any): void; +} + +/** + * A window surface that hosts a NativeScript view tree. + * + * Wraps a platform window (iOS UIWindowScene + UIWindow, Android Activity) and owns the + * per-window root view, its CSS classes and its lifecycle events. Instances are created by + * the framework as the platform connects surfaces; reach them through + * `Application.primaryWindow`, `Application.getWindows()` or `Application.getWindowById()`, + * and use `ios` / `android` to get at the native objects. + */ +export abstract class NativeWindow extends WindowBase { + /** + * The root view currently hosted by this window, if any. + */ + readonly rootView: View; + + /** + * Sets the content of this window. + * + * Replaces any content set earlier, tears the previous root view down and raises + * `contentLoaded`. + * + * @param content A View, a NavigationEntry, or the name of a module to load. + */ + setContent(content: View | NavigationEntry | string): void; + + /** + * The current orientation of this window. + * + * Read from the native surface while the window is attached; a detached window reports + * the last value it saw. A read that catches a change the platform has not reported yet + * also raises `orientationChanged`, so a change is never swallowed by the reading. + */ + orientation(): 'portrait' | 'landscape' | 'unknown'; + + /** + * The current system appearance of this window. + * + * Read from the native surface while the window is attached; a detached window reports + * the last value it saw. A read that catches a change the platform has not reported yet + * also raises `systemAppearanceChanged`, so a change is never swallowed by the reading. + */ + systemAppearance(): 'light' | 'dark' | null; + + /** + * The current layout direction of this window. + * + * Read from the native surface while the window is attached; a detached window reports + * the last value it saw. A read that catches a change the platform has not reported yet + * also raises `layoutDirectionChanged`, so a change is never swallowed by the reading. + */ + layoutDirection(): CoreTypes.LayoutDirectionType | null; + + /** + * Raised when the root view content of this window is set or changed. + * + * Bringing a window up follows a fixed order that app code can rely on: + * Application `ready` -> Application `windowOpen` -> the raw platform connect/create + * event (`sceneWillConnect` / `activityCreated`) -> content resolution (the window + * content resolver, or the legacy `launch` bridge for the first window) -> + * `contentLoaded` -> `activate` and `displayed`. + */ + on(event: 'contentLoaded', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + + // The whole inherited set is repeated below: an override only covers the overloads it + // lists, so declaring `contentLoaded` alone would hide every WindowBase event. See + // WindowBase for what each of them means. + on(event: 'activate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'deactivate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'background', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'foreground', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'attached', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'detached', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'close', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'displayed', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'orientationChanged', callback: (data: WindowOrientationChangedEventData) => void, thisArg?: any): void; + on(event: 'systemAppearanceChanged', callback: (data: WindowSystemAppearanceChangedEventData) => void, thisArg?: any): void; + on(event: 'layoutDirectionChanged', callback: (data: WindowLayoutDirectionChangedEventData) => void, thisArg?: any): void; + on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + on(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + on(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + on(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + on(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + on(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(eventName: string, callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + + once(event: 'contentLoaded', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + once(event: 'activate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + once(event: 'deactivate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + once(event: 'background', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + once(event: 'foreground', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + once(event: 'attached', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + once(event: 'detached', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + once(event: 'close', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + once(event: 'displayed', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + once(event: 'orientationChanged', callback: (data: WindowOrientationChangedEventData) => void, thisArg?: any): void; + once(event: 'systemAppearanceChanged', callback: (data: WindowSystemAppearanceChangedEventData) => void, thisArg?: any): void; + once(event: 'layoutDirectionChanged', callback: (data: WindowLayoutDirectionChangedEventData) => void, thisArg?: any): void; + once(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + once(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + once(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + once(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + once(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + once(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + once(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + once(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + once(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + once(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + once(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + once(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + once(eventName: string, callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + + off(event: 'contentLoaded', callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; + off(event: 'activate', callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; + off(event: 'deactivate', callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; + off(event: 'background', callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; + off(event: 'foreground', callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; + off(event: 'attached', callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; + off(event: 'detached', callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; + off(event: 'close', callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; + off(event: 'displayed', callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; + off(event: 'orientationChanged', callback?: (data: WindowOrientationChangedEventData) => void, thisArg?: any): void; + off(event: 'systemAppearanceChanged', callback?: (data: WindowSystemAppearanceChangedEventData) => void, thisArg?: any): void; + off(event: 'layoutDirectionChanged', callback?: (data: WindowLayoutDirectionChangedEventData) => void, thisArg?: any): void; + off(event: 'activityCreated', callback?: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + off(event: 'activityDestroyed', callback?: (args: AndroidActivityEventData) => void, thisArg?: any): void; + off(event: 'activityStarted', callback?: (args: AndroidActivityEventData) => void, thisArg?: any): void; + off(event: 'activityPaused', callback?: (args: AndroidActivityEventData) => void, thisArg?: any): void; + off(event: 'activityResumed', callback?: (args: AndroidActivityEventData) => void, thisArg?: any): void; + off(event: 'activityStopped', callback?: (args: AndroidActivityEventData) => void, thisArg?: any): void; + off(event: 'saveActivityState', callback?: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + off(event: 'activityResult', callback?: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + off(event: 'activityBackPressed', callback?: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + off(event: 'activityNewIntent', callback?: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + off(event: 'activityRequestPermissions', callback?: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + off(event: 'sceneWillConnect', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(event: 'sceneDidActivate', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(event: 'sceneWillResignActive', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(event: 'sceneWillEnterForeground', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(event: 'sceneDidEnterBackground', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(event: 'sceneDidDisconnect', callback?: (args: SceneEventData) => void, thisArg?: any): void; + off(eventName: string, callback?: (data: NativeWindowEventData) => void, thisArg?: any): void; +} diff --git a/packages/core/native-window/native-window-interfaces.ts b/packages/core/native-window/native-window-interfaces.ts index a7dce07cae..a2b094f788 100644 --- a/packages/core/native-window/native-window-interfaces.ts +++ b/packages/core/native-window/native-window-interfaces.ts @@ -2,8 +2,7 @@ import type { CoreTypes } from '../core-types'; import type { EventData } from '../data/observable'; import type { View } from '../ui/core/view'; import type { NavigationEntry } from '../ui/frame/frame-interfaces'; -import type { NativeWindow } from './native-window-common'; -import type { WindowBase } from './window-base'; +import type { NativeWindow, WindowBase } from '.'; /** * Events emitted by a NativeWindow instance. From 852d8da9b788c078bf0b711d4c7d053af53584d6 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 18:41:53 -0300 Subject: [PATCH 15/23] test(core): NativeWindow + Application specs Covers the window lifecycle contract (attach/detach/re-attach/close, close firing once, listeners cleared only after close and surviving a detach), the registry's role filtering and primary promotion, the content resolution chain including the launch bridge's three-state root and app CSS loading on every path, and the Android trait converters and session-id round trip. --- .../application/application-registry.spec.ts | 280 +++++++++++++ .../window-content-resolver.spec.ts | 388 +++++++++++++++++ .../native-window.android.spec.ts | 272 ++++++++++++ .../core/native-window/window-base.spec.ts | 390 ++++++++++++++++++ 4 files changed, 1330 insertions(+) create mode 100644 packages/core/application/application-registry.spec.ts create mode 100644 packages/core/application/window-content-resolver.spec.ts create mode 100644 packages/core/native-window/native-window.android.spec.ts create mode 100644 packages/core/native-window/window-base.spec.ts diff --git a/packages/core/application/application-registry.spec.ts b/packages/core/application/application-registry.spec.ts new file mode 100644 index 0000000000..afcabad718 --- /dev/null +++ b/packages/core/application/application-registry.spec.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { CoreTypes } from '../core-types'; +import { Observable } from '../data/observable'; +import type { View } from '../ui/core/view'; +import { NativeWindow } from '../native-window/native-window-common'; +import { WindowBase } from '../native-window/window-base'; +import type { WindowRole } from '../native-window/window-base'; +import { ApplicationCommon } from './application-common'; + +/** + * `vitest.setup.ts` installs a `NativeScriptGlobals` whose event 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; +} + +class TestApplication extends ApplicationCommon { + getRootView(): View { + return undefined; + } + + getOrientation(): 'portrait' | 'landscape' | 'unknown' { + return 'portrait'; + } + + getSystemAppearance(): 'dark' | 'light' | null { + return 'light'; + } + + getLayoutDirection(): CoreTypes.LayoutDirectionType | null { + return CoreTypes.LayoutDirection.ltr; + } +} + +class TestWindow extends NativeWindow { + protected _setNativeContent(view: View): void { + // no native surface under test + } + + protected _getOrientation(): 'portrait' | 'landscape' | 'unknown' { + return 'portrait'; + } + + protected _getSystemAppearance(): 'light' | 'dark' | null { + return 'light'; + } + + protected _getLayoutDirection(): CoreTypes.LayoutDirectionType | null { + return CoreTypes.LayoutDirection.ltr; + } + + close(): void { + // no native surface under test + } + + /** Marks the window as carrying content, the way a real attached window would. */ + withContent(): this { + this._adoptRootView({ + isLoaded: false, + cssClasses: new Set(), + callUnloaded() {}, + _tearDownUI() {}, + _onRootViewReset() {}, + _getRootModalViews: () => [], + } as unknown as View); + + return this; + } +} + +/** A surface with no view tree at all - a CarPlay scene is the real-world case. */ +class TemplateWindow extends WindowBase { + constructor(id?: string, isPrimary = false, role: WindowRole = 'carplay') { + super(id, isPrimary, role); + } + + close(): void { + // no native surface under test + } +} + +function asWindow(window: WindowBase): NativeWindow { + return window as unknown as NativeWindow; +} + +describe('ApplicationCommon window registry', () => { + let app: TestApplication; + let events: string[]; + + beforeEach(() => { + installApplicationEventBus(); + app = new TestApplication(); + events = []; + }); + + afterEach(() => { + installApplicationEventBus(); + }); + + function record(...eventNames: string[]): Array<{ eventName: string; window: WindowBase }> { + const recorded: Array<{ eventName: string; window: WindowBase }> = []; + for (const eventName of eventNames) { + app.on(eventName, (args: any) => { + events.push(eventName); + recorded.push({ eventName, window: args.window }); + }); + } + + return recorded; + } + + it('raises windowOpen with the registered window once per registration', () => { + const recorded = record('windowOpen'); + const first = new TestWindow('a', true); + const second = new TestWindow('b'); + + app._registerWindow(first); + app._registerWindow(second); + + expect(recorded.map((entry) => entry.window)).toEqual([first, second]); + expect(app.getWindows()).toEqual([first, second]); + }); + + it('reports the window flagged primary', () => { + const first = new TestWindow('a'); + const primary = new TestWindow('b', true); + app._registerWindow(first); + app._registerWindow(primary); + + expect(app.primaryWindow).toBe(primary); + }); + + it('finds a window by id and returns undefined for an unknown one', () => { + const window = new TestWindow('scene-7', true); + app._registerWindow(window); + + expect(app.getWindowById('scene-7')).toBe(window); + expect(app.getWindowById('scene-8')).toBeUndefined(); + }); + + describe('role filtering', () => { + let application: TestWindow; + let embedded: TestWindow; + let carPlay: TemplateWindow; + + beforeEach(() => { + application = new TestWindow('app', true, 'application'); + embedded = new TestWindow('embedded', false, 'embedded'); + carPlay = new TemplateWindow('carplay'); + + app._registerWindow(application); + app._registerWindow(embedded); + app._registerWindow(asWindow(carPlay)); + }); + + it('returns only the view-carrying roles by default', () => { + expect(app.getWindows()).toEqual([application, embedded]); + }); + + it("includes every surface for 'all'", () => { + expect(app.getWindows('all')).toEqual([application, embedded, carPlay]); + }); + + it('returns the requested roles when asked explicitly', () => { + expect(app.getWindows('carplay')).toEqual([carPlay]); + expect(app.getWindows(['application', 'carplay'])).toEqual([application, carPlay]); + }); + + it('exposes every surface through the internal accessor', () => { + expect(app._getWindows()).toEqual([application, embedded, carPlay]); + }); + }); + + describe('returned arrays are copies', () => { + let first: TestWindow; + let second: TestWindow; + + beforeEach(() => { + first = new TestWindow('a', true); + second = new TestWindow('b'); + app._registerWindow(first); + app._registerWindow(second); + }); + + it('mutating the result of getWindows() leaves the registry untouched', () => { + const windows = app.getWindows(); + windows.pop(); + windows.push(new TestWindow('intruder')); + + expect(app.getWindows()).toEqual([first, second]); + }); + + it("mutating the result of getWindows('all') leaves the registry untouched", () => { + app.getWindows('all').length = 0; + + expect(app.getWindows('all')).toEqual([first, second]); + }); + + it('mutating the result of _getWindows() leaves the registry untouched', () => { + app._getWindows().length = 0; + + expect(app._getWindows()).toEqual([first, second]); + }); + }); + + describe('unregistering', () => { + it('raises windowClose, drops the window and ends its session', () => { + const window = new TestWindow('a', true); + app._registerWindow(window); + const recorded = record('windowClose'); + + app._unregisterWindow(window); + + expect(recorded.map((entry) => entry.window)).toEqual([window]); + expect(app.getWindows()).toEqual([]); + expect(window.state).toBe('closed'); + }); + + it('promotes the first attached view-carrying window and announces it', () => { + const primary = new TestWindow('primary', true).withContent(); + const detached = new TestWindow('detached').withContent(); + const carPlay = new TemplateWindow('carplay'); + const successor = new TestWindow('successor').withContent(); + + app._registerWindow(primary); + app._registerWindow(detached); + app._registerWindow(asWindow(carPlay)); + app._registerWindow(successor); + detached._detach(); + + const recorded = record('windowClose', 'primaryWindowChanged'); + app._unregisterWindow(primary); + + expect(events).toEqual(['windowClose', 'primaryWindowChanged']); + expect(recorded[1].window).toBe(successor); + expect(primary.isPrimary).toBe(false); + expect(successor.isPrimary).toBe(true); + expect(app.primaryWindow).toBe(successor); + }); + + it('leaves the app without a primary window when nothing can take over', () => { + const primary = new TestWindow('primary', true).withContent(); + const detached = new TestWindow('detached').withContent(); + app._registerWindow(primary); + app._registerWindow(detached); + detached._detach(); + + record('windowClose', 'primaryWindowChanged'); + app._unregisterWindow(primary); + + expect(events).toEqual(['windowClose']); + expect(primary.isPrimary).toBe(false); + expect(app.primaryWindow).toBeUndefined(); + }); + + it('does not promote anything when a non-primary window goes away', () => { + const primary = new TestWindow('primary', true).withContent(); + const secondary = new TestWindow('secondary').withContent(); + app._registerWindow(primary); + app._registerWindow(secondary); + + record('windowClose', 'primaryWindowChanged'); + app._unregisterWindow(secondary); + + expect(events).toEqual(['windowClose']); + expect(app.primaryWindow).toBe(primary); + }); + }); +}); diff --git a/packages/core/application/window-content-resolver.spec.ts b/packages/core/application/window-content-resolver.spec.ts new file mode 100644 index 0000000000..6219ce75fa --- /dev/null +++ b/packages/core/application/window-content-resolver.spec.ts @@ -0,0 +1,388 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { CoreTypes } from '../core-types'; +import { Observable } from '../data/observable'; +import { Builder } from '../ui/builder'; +import type { View } from '../ui/core/view'; +import type { NavigationEntry } from '../ui/frame/frame-interfaces'; +import { NativeWindow } from '../native-window/native-window-common'; +import type { WindowContentRequest } from '../native-window/native-window-interfaces'; +import { ApplicationCommon } from './application-common'; +import { getAppMainEntry, setAppMainEntry } 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; +} + +function createFakeView(name: string) { + return { + name, + cssClasses: new Set(), + isLoaded: false, + _styleScope: null, + _setupAsRootView() {}, + _onCssStateChange() {}, + _onRootViewReset() {}, + _tearDownUI() {}, + callUnloaded() {}, + _getRootModalViews() { + return []; + }, + } as unknown as View; +} + +class TestApplication extends ApplicationCommon { + getRootView(): View { + return undefined; + } + + getOrientation(): 'portrait' | 'landscape' | 'unknown' { + return 'portrait'; + } + + getSystemAppearance(): 'dark' | 'light' | null { + return 'light'; + } + + getLayoutDirection(): CoreTypes.LayoutDirectionType | null { + return CoreTypes.LayoutDirection.ltr; + } +} + +class TestWindow extends NativeWindow { + protected _setNativeContent(view: View): void { + // no native surface under test + } + + protected _getOrientation(): 'portrait' | 'landscape' | 'unknown' { + return 'portrait'; + } + + protected _getSystemAppearance(): 'light' | 'dark' | null { + return 'light'; + } + + protected _getLayoutDirection(): CoreTypes.LayoutDirectionType | null { + return CoreTypes.LayoutDirection.ltr; + } + + close(): void { + // no native surface under test + } +} + +describe('window content resolution', () => { + let app: TestApplication; + let window: TestWindow; + let request: WindowContentRequest; + let mainEntryView: View; + let previousMainEntry: any; + let createViewFromEntry: ReturnType; + + beforeEach(() => { + previousMainEntry = getAppMainEntry(); + installApplicationEventBus(); + app = new TestApplication(); + window = new TestWindow('window-1', true); + request = { window, isPrimary: true }; + mainEntryView = createFakeView('main-entry'); + + setAppMainEntry({ moduleName: 'app-root' }); + createViewFromEntry = vi.spyOn(Builder, 'createViewFromEntry').mockImplementation(() => mainEntryView); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setAppMainEntry(previousMainEntry); + installApplicationEventBus(); + }); + + describe('the resolver chain', () => { + it('uses the View a resolver returns', () => { + const view = createFakeView('resolved'); + app.setWindowContentResolver(() => view); + + const resolved = app._resolveWindowContent(window, request); + + expect(resolved).toBe(view); + expect(window.rootView).toBe(view); + }); + + it('hands the resolver the request it was given', () => { + const resolver = vi.fn(() => createFakeView('resolved')); + app.setWindowContentResolver(resolver); + + app._resolveWindowContent(window, request); + + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith(request); + }); + + it('builds the NavigationEntry a resolver returns', () => { + const entry: NavigationEntry = { moduleName: 'pages/detail' }; + const built = createFakeView('built'); + createViewFromEntry.mockReturnValue(built); + app.setWindowContentResolver(() => entry); + + const resolved = app._resolveWindowContent(window, request); + + expect(createViewFromEntry).toHaveBeenCalledWith(entry); + expect(resolved).toBe(built); + }); + + it('builds the module name a resolver returns', () => { + const built = createFakeView('built'); + createViewFromEntry.mockReturnValue(built); + app.setWindowContentResolver(() => 'pages/detail'); + + const resolved = app._resolveWindowContent(window, request); + + expect(createViewFromEntry).toHaveBeenCalledWith({ moduleName: 'pages/detail' }); + expect(resolved).toBe(built); + }); + + it('sets no content when a resolver returns null, leaving it to supply one later', () => { + const launched = vi.fn(); + app.on('launch', launched); + app.setWindowContentResolver(() => null); + + const resolved = app._resolveWindowContent(window, request); + + expect(resolved).toBeNull(); + expect(window.rootView).toBeUndefined(); + expect(launched).not.toHaveBeenCalled(); + expect(createViewFromEntry).not.toHaveBeenCalled(); + }); + + it('falls through to the main entry when a resolver returns undefined', () => { + const resolver = vi.fn(() => undefined); + app.setWindowContentResolver(resolver); + + const resolved = app._resolveWindowContent(window, request); + + expect(resolver).toHaveBeenCalledTimes(1); + expect(createViewFromEntry).toHaveBeenCalledWith({ moduleName: 'app-root' }); + expect(resolved).toBe(mainEntryView); + expect(window.rootView).toBe(mainEntryView); + }); + + it('falls back to the main entry when no resolver is set', () => { + const resolved = app._resolveWindowContent(window, request); + + expect(resolved).toBe(mainEntryView); + expect(window.rootView).toBe(mainEntryView); + }); + + it('leaves the window empty instead of throwing when there is no main entry', () => { + setAppMainEntry(undefined); + + const resolved = app._resolveWindowContent(window, request); + + expect(resolved).toBeNull(); + expect(window.rootView).toBeUndefined(); + }); + + it('returns the built view without installing it when asked not to install', () => { + const view = createFakeView('resolved'); + app.setWindowContentResolver(() => view); + + const resolved = app._resolveWindowContent(window, request, { install: false }); + + expect(resolved).toBe(view); + expect(window.rootView).toBeUndefined(); + }); + + it('drops a previously set resolver when it is cleared', () => { + app.setWindowContentResolver(() => createFakeView('resolved')); + app.setWindowContentResolver(null); + + expect(app.getWindowContentResolver()).toBeNull(); + expect(app._resolveWindowContent(window, request)).toBe(mainEntryView); + }); + }); + + describe('the legacy launch bridge', () => { + it('uses the View a launch handler puts on args.root', () => { + const view = createFakeView('launch-root'); + app.on('launch', (args: any) => { + args.root = view; + }); + + const resolved = app._resolveWindowContent(window, request); + + expect(resolved).toBe(view); + expect(createViewFromEntry).not.toHaveBeenCalled(); + }); + + it('sets no content when a launch handler puts null on args.root', () => { + app.on('launch', (args: any) => { + args.root = null; + }); + + const resolved = app._resolveWindowContent(window, request); + + expect(resolved).toBeNull(); + expect(window.rootView).toBeUndefined(); + expect(createViewFromEntry).not.toHaveBeenCalled(); + }); + + it('falls through to the main entry when a launch handler leaves args.root alone', () => { + const launched = vi.fn(); + app.on('launch', launched); + + const resolved = app._resolveWindowContent(window, request); + + expect(launched).toHaveBeenCalledTimes(1); + expect(resolved).toBe(mainEntryView); + }); + + it('merges the platform launch data into the launch args', () => { + const received: any[] = []; + app.on('launch', (args: any) => received.push(args)); + + app._resolveWindowContent(window, request, { launchData: { android: 'intent' } }); + + expect(received[0].android).toBe('intent'); + expect(received[0].eventName).toBe('launch'); + }); + + it('fires for the first window only', () => { + const launched = vi.fn(); + app.on('launch', launched); + const second = new TestWindow('window-2'); + + app._resolveWindowContent(window, request); + app._resolveWindowContent(second, { window: second, isPrimary: false }); + + expect(launched).toHaveBeenCalledTimes(1); + }); + + it('never fires once a resolver has answered the first window', () => { + const launched = vi.fn(); + app.on('launch', launched); + app.setWindowContentResolver(() => createFakeView('resolved')); + + app._resolveWindowContent(window, request); + app.setWindowContentResolver(null); + const second = new TestWindow('window-2'); + app._resolveWindowContent(second, { window: second, isPrimary: false }); + + expect(launched).not.toHaveBeenCalled(); + expect(second.rootView).toBe(mainEntryView); + }); + }); + + describe('the ready event', () => { + it('is raised once, before any window opens', () => { + const order: string[] = []; + app.on('ready', () => order.push('ready')); + app.on('windowOpen', () => order.push('windowOpen')); + app.on('launch', () => order.push('launch')); + + app.notifyReady(); + app._registerWindow(window); + app._resolveWindowContent(window, request); + + expect(order).toEqual(['ready', 'windowOpen', 'launch']); + }); + + it('is not raised a second time', () => { + const ready = vi.fn(); + app.on('ready', ready); + + app.notifyReady(); + app.notifyReady(); + + expect(ready).toHaveBeenCalledTimes(1); + }); + + it('precedes windowOpen for every window, however many open', () => { + const order: string[] = []; + app.on('ready', () => order.push('ready')); + app.on('windowOpen', () => order.push('windowOpen')); + + app.notifyReady(); + app._registerWindow(window); + app._registerWindow(new TestWindow('window-2')); + + expect(order).toEqual(['ready', 'windowOpen', 'windowOpen']); + }); + }); + + describe('app css loading', () => { + let loadAppCss: ReturnType; + + beforeEach(() => { + loadAppCss = vi.spyOn(app, 'loadAppCss').mockImplementation(() => {}); + }); + + it('loads once when a resolver supplies the content', () => { + app.setWindowContentResolver(() => createFakeView('resolved')); + + app._resolveWindowContent(window, request); + + expect(loadAppCss).toHaveBeenCalledTimes(1); + }); + + it('loads once when a resolver takes ownership by returning null', () => { + app.setWindowContentResolver(() => null); + + app._resolveWindowContent(window, request); + + expect(loadAppCss).toHaveBeenCalledTimes(1); + }); + + it('loads once on the legacy launch path', () => { + app.on('launch', (args: any) => { + args.root = createFakeView('launch-root'); + }); + + app._resolveWindowContent(window, request); + + expect(loadAppCss).toHaveBeenCalledTimes(1); + }); + + it('loads once on the main entry path', () => { + app._resolveWindowContent(window, request); + + expect(loadAppCss).toHaveBeenCalledTimes(1); + }); + + it('loads only once across several windows', () => { + app.setWindowContentResolver(() => createFakeView('resolved')); + const second = new TestWindow('window-2'); + + app._resolveWindowContent(window, request); + app._resolveWindowContent(second, { window: second, isPrimary: false }); + + expect(loadAppCss).toHaveBeenCalledTimes(1); + }); + + it('loads after the launch handlers, which may still change the css file', () => { + let cssFileAtLoad: string; + loadAppCss.mockImplementation(() => { + cssFileAtLoad = app.getCssFileName(); + }); + app.on('launch', () => { + app.setCssFileName('./themed.css'); + }); + + app._resolveWindowContent(window, request); + + expect(cssFileAtLoad).toBe('./themed.css'); + }); + }); +}); diff --git a/packages/core/native-window/native-window.android.spec.ts b/packages/core/native-window/native-window.android.spec.ts new file mode 100644 index 0000000000..66b3830b08 --- /dev/null +++ b/packages/core/native-window/native-window.android.spec.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { CoreTypes } from '../core-types'; + +// `frame-helper-for-android` pulls in `fragment.transitions.android`, which the iOS-flavoured +// vitest resolver cannot load. Only the CALLBACKS key is used by the module under test. +vi.mock('../ui/frame/frame-helper-for-android', () => ({ + CALLBACKS: '_callbacks', +})); + +import { AndroidNativeWindow } from './native-window.android'; +import { NativeWindowEvents } from './native-window-interfaces'; + +// Real android.content.res.Configuration / android.view.View constants: the bitmask logic +// under test is only meaningful against the values the platform actually reports. +const Configuration = { + ORIENTATION_UNDEFINED: 0, + ORIENTATION_PORTRAIT: 1, + ORIENTATION_LANDSCAPE: 2, + UI_MODE_NIGHT_MASK: 0x30, + UI_MODE_NIGHT_UNDEFINED: 0x00, + UI_MODE_NIGHT_NO: 0x10, + UI_MODE_NIGHT_YES: 0x20, + UI_MODE_TYPE_NORMAL: 0x01, + UI_MODE_TYPE_CAR: 0x03, +}; + +const AndroidView = { + LAYOUT_DIRECTION_LTR: 0, + LAYOUT_DIRECTION_RTL: 1, +}; + +class FakeComponentCallbacks2 { + constructor(implementation: Record) { + Object.assign(this, implementation); + } +} + +/** Mirrors android.os.Bundle's string slots: a miss reads back as null, not undefined. */ +class FakeBundle { + private readonly values = new Map(); + + putString(key: string, value: string): void { + this.values.set(key, value); + } + + getString(key: string): string | null { + return this.values.has(key) ? this.values.get(key) : null; + } +} + +function createConfiguration(overrides: Partial<{ orientation: number; uiMode: number; layoutDirection: number }> = {}) { + const state = { + orientation: Configuration.ORIENTATION_PORTRAIT, + uiMode: Configuration.UI_MODE_TYPE_NORMAL | Configuration.UI_MODE_NIGHT_NO, + layoutDirection: AndroidView.LAYOUT_DIRECTION_LTR, + ...overrides, + }; + + return Object.assign(state, { + getLayoutDirection() { + return state.layoutDirection; + }, + }); +} + +type FakeConfiguration = ReturnType; + +function createActivity(configuration: FakeConfiguration) { + return { + registered: [] as any[], + unregistered: [] as any[], + finishCalls: 0, + getResources() { + return { getConfiguration: () => configuration }; + }, + registerComponentCallbacks(callbacks: any) { + this.registered.push(callbacks); + }, + unregisterComponentCallbacks(callbacks: any) { + this.unregistered.push(callbacks); + }, + finish() { + this.finishCalls++; + }, + }; +} + +type FakeActivity = ReturnType; + +function createWindow(activity: FakeActivity, id?: string, isPrimary = false): AndroidNativeWindow { + return new AndroidNativeWindow(activity as unknown as androidx.appcompat.app.AppCompatActivity, id, isPrimary); +} + +describe('AndroidNativeWindow', () => { + let configuration: FakeConfiguration; + let activity: FakeActivity; + let window: AndroidNativeWindow; + let uuidCounter: number; + + beforeEach(() => { + uuidCounter = 0; + vi.stubGlobal('android', { + content: { + res: { Configuration }, + ComponentCallbacks2: FakeComponentCallbacks2, + }, + view: { View: AndroidView }, + }); + vi.stubGlobal('java', { + util: { + UUID: { + randomUUID: () => ({ toString: () => `uuid-${++uuidCounter}` }), + }, + }, + }); + + configuration = createConfiguration(); + activity = createActivity(configuration); + window = createWindow(activity, 'window-under-test'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('Configuration value converters', () => { + it('maps the orientation constants, treating anything else as unknown', () => { + expect(window._getOrientationValue(createConfiguration({ orientation: Configuration.ORIENTATION_PORTRAIT }) as any)).toBe('portrait'); + expect(window._getOrientationValue(createConfiguration({ orientation: Configuration.ORIENTATION_LANDSCAPE }) as any)).toBe('landscape'); + expect(window._getOrientationValue(createConfiguration({ orientation: Configuration.ORIENTATION_UNDEFINED }) as any)).toBe('unknown'); + }); + + it('reads the night bits out of uiMode rather than comparing the whole field', () => { + // uiMode packs the ui type into the low bits; a whole-field comparison would miss this. + const carAtNight = createConfiguration({ uiMode: Configuration.UI_MODE_TYPE_CAR | Configuration.UI_MODE_NIGHT_YES }); + + expect(window._getSystemAppearanceValue(carAtNight as any)).toBe('dark'); + }); + + it('reports light for night-no, night-undefined and unknown night bits', () => { + const dayCases = [Configuration.UI_MODE_NIGHT_NO, Configuration.UI_MODE_NIGHT_UNDEFINED, Configuration.UI_MODE_NIGHT_MASK]; + + for (const nightBits of dayCases) { + const config = createConfiguration({ uiMode: Configuration.UI_MODE_TYPE_NORMAL | nightBits }); + expect(window._getSystemAppearanceValue(config as any)).toBe('light'); + } + }); + + it('maps the layout direction constants, defaulting to ltr', () => { + expect(window._getLayoutDirectionValue(createConfiguration({ layoutDirection: AndroidView.LAYOUT_DIRECTION_RTL }) as any)).toBe(CoreTypes.LayoutDirection.rtl); + expect(window._getLayoutDirectionValue(createConfiguration({ layoutDirection: AndroidView.LAYOUT_DIRECTION_LTR }) as any)).toBe(CoreTypes.LayoutDirection.ltr); + expect(window._getLayoutDirectionValue(createConfiguration({ layoutDirection: 99 }) as any)).toBe(CoreTypes.LayoutDirection.ltr); + }); + + it('reads the traits off the activity configuration', () => { + configuration.orientation = Configuration.ORIENTATION_LANDSCAPE; + configuration.uiMode = Configuration.UI_MODE_TYPE_NORMAL | Configuration.UI_MODE_NIGHT_YES; + configuration.layoutDirection = AndroidView.LAYOUT_DIRECTION_RTL; + + expect(window.orientation()).toBe('landscape'); + expect(window.systemAppearance()).toBe('dark'); + expect(window.layoutDirection()).toBe(CoreTypes.LayoutDirection.rtl); + }); + }); + + describe('per-activity configuration callbacks', () => { + it('registers on the activity and announces every trait a configuration change carries', () => { + window.orientation(); + window.systemAppearance(); + window.layoutDirection(); + const changes: Array<[string, unknown]> = []; + for (const eventName of [NativeWindowEvents.orientationChanged, NativeWindowEvents.systemAppearanceChanged, NativeWindowEvents.layoutDirectionChanged]) { + window.on(eventName, (data: any) => changes.push([data.eventName, data.newValue])); + } + + window._registerConfigurationCallbacks(); + expect(activity.registered).toHaveLength(1); + + const changed = createConfiguration({ + orientation: Configuration.ORIENTATION_LANDSCAPE, + uiMode: Configuration.UI_MODE_TYPE_NORMAL | Configuration.UI_MODE_NIGHT_YES, + layoutDirection: AndroidView.LAYOUT_DIRECTION_RTL, + }); + (activity.registered[0] as any).onConfigurationChanged(changed); + + expect(changes).toEqual([ + ['orientationChanged', 'landscape'], + ['systemAppearanceChanged', 'dark'], + ['layoutDirectionChanged', CoreTypes.LayoutDirection.rtl], + ]); + }); + + it('registers at most once for the same activity', () => { + window._registerConfigurationCallbacks(); + window._registerConfigurationCallbacks(); + + expect(activity.registered).toHaveLength(1); + }); + + it('unregisters the callbacks it registered when the surface detaches', () => { + window._registerConfigurationCallbacks(); + + window._detach(); + + expect(activity.unregistered).toEqual(activity.registered); + expect(window.state).toBe('detached'); + }); + }); + + describe('window identity across activity recreation', () => { + it('mints a prefixed, unique id', () => { + const first = AndroidNativeWindow.newWindowId(); + const second = AndroidNativeWindow.newWindowId(); + + expect(first).toMatch(/^window-uuid-/); + expect(second).not.toBe(first); + }); + + it('carries the minted id through the saved state bundle onto the recreated window', () => { + const bundle = new FakeBundle(); + const original = createWindow(activity, AndroidNativeWindow.newWindowId()); + + bundle.putString('com.tns.activity.windowId', original.id); + const restored = createWindow(createActivity(createConfiguration()), bundle.getString('com.tns.activity.windowId')); + + expect(restored.id).toBe(original.id); + }); + + it('mints a fresh id when the bundle carries none', () => { + const bundle = new FakeBundle(); + const savedId = bundle.getString('com.tns.activity.windowId'); + + const created = createWindow(activity, savedId || AndroidNativeWindow.newWindowId()); + + expect(savedId).toBeNull(); + expect(created.id).toBe('window-uuid-1'); + }); + + it('binds a recreated activity to the same window session', () => { + const events: string[] = []; + window.on(NativeWindowEvents.detached, (data) => events.push(data.eventName)); + window._registerConfigurationCallbacks(); + + window._detach(); + const recreated = createActivity(createConfiguration()); + window._reattach(recreated as unknown as androidx.appcompat.app.AppCompatActivity); + + expect(events).toEqual(['detached']); + expect(window.state).toBe('attached'); + expect(window.id).toBe('window-under-test'); + expect(window.android?.activity).toBe(recreated as unknown as androidx.appcompat.app.AppCompatActivity); + }); + }); + + describe('close', () => { + it('finishes the activity of a secondary window', () => { + window.close(); + + expect(activity.finishCalls).toBe(1); + }); + + it('refuses to close the primary window', () => { + const primary = createWindow(activity, 'primary', true); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + primary.close(); + + expect(activity.finishCalls).toBe(0); + log.mockRestore(); + }); + }); +}); diff --git a/packages/core/native-window/window-base.spec.ts b/packages/core/native-window/window-base.spec.ts new file mode 100644 index 0000000000..a51ce6f33b --- /dev/null +++ b/packages/core/native-window/window-base.spec.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { CoreTypes } from '../core-types'; +import { Builder } from '../ui/builder'; +import type { View } from '../ui/core/view'; +import type { NavigationEntry } from '../ui/frame/frame-interfaces'; +import { NativeWindow } from './native-window-common'; +import { NativeWindowEvents } from './native-window-interfaces'; +import { WindowBase } from './window-base'; +import type { WindowRole } from './window-base'; + +/** + * Stand-in for a root view. A real `View` cannot be used here: `_setupAsRootView()` + * reaches straight into UIView/android.view.View, which do not exist under vitest. + */ +function createFakeView() { + return { + cssClasses: new Set(), + isLoaded: false, + _styleScope: null, + unloadedCount: 0, + resetCount: 0, + tearDownCount: 0, + setupCount: 0, + _setupAsRootView() { + this.setupCount++; + }, + _onCssStateChange() {}, + _onRootViewReset() { + this.resetCount++; + }, + _tearDownUI() { + this.tearDownCount++; + }, + callUnloaded() { + this.unloadedCount++; + this.isLoaded = false; + }, + _getRootModalViews() { + return []; + }, + }; +} + +type FakeView = ReturnType; + +function asView(view: FakeView): View { + return view as unknown as View; +} + +class TestWindow extends NativeWindow { + orientationValue: 'portrait' | 'landscape' | 'unknown' = 'portrait'; + appearanceValue: 'light' | 'dark' | null = 'light'; + directionValue: CoreTypes.LayoutDirectionType | null = CoreTypes.LayoutDirection.ltr; + + nativeContent: View[] = []; + closeCalls = 0; + destroyHookRan = false; + + protected _setNativeContent(view: View): void { + this.nativeContent.push(view); + } + + protected _getOrientation(): 'portrait' | 'landscape' | 'unknown' { + return this.orientationValue; + } + + protected _getSystemAppearance(): 'light' | 'dark' | null { + return this.appearanceValue; + } + + protected _getLayoutDirection(): CoreTypes.LayoutDirectionType | null { + return this.directionValue; + } + + close(): void { + this.closeCalls++; + } + + protected _onDestroy(): void { + this.destroyHookRan = true; + // Runs before the listeners are dropped, so this must still reach subscribers. + this._notifyEvent(NativeWindowEvents.deactivate); + super._onDestroy(); + } +} + +class BareWindow extends WindowBase { + closeCalls = 0; + + constructor(id?: string, isPrimary = false, role: WindowRole = 'application') { + super(id, isPrimary, role); + } + + close(): void { + this.closeCalls++; + } +} + +/** Records every event the window emits, in order. */ +function recordEvents(window: WindowBase, events: string[] = []): string[] { + for (const eventName of Object.values(NativeWindowEvents)) { + window.on(eventName, (data) => events.push(data.eventName)); + } + + return events; +} + +describe('WindowBase identity, role and state', () => { + it('defaults to an application window that is attached and not primary', () => { + const window = new TestWindow(); + + expect(window.role).toBe('application'); + expect(window.state).toBe('attached'); + expect(window.isPrimary).toBe(false); + expect(window.id).toMatch(/^window-\d+$/); + }); + + it('keeps the id, primary flag and role it was constructed with', () => { + const window = new TestWindow('scene-42', true, 'carplay'); + + expect(window.id).toBe('scene-42'); + expect(window.isPrimary).toBe(true); + expect(window.role).toBe('carplay'); + }); + + it('mints a distinct id for every window left without one', () => { + const ids = [new TestWindow().id, new TestWindow().id, new TestWindow().id]; + + expect(new Set(ids).size).toBe(ids.length); + }); + + it('_setIsPrimary promotes and demotes the window', () => { + const window = new TestWindow(); + + window._setIsPrimary(true); + expect(window.isPrimary).toBe(true); + + window._setIsPrimary(false); + expect(window.isPrimary).toBe(false); + }); + + it('exposes no native accessors on a bare window surface', () => { + const window = new BareWindow(); + + expect(window.ios).toBeUndefined(); + expect(window.android).toBeUndefined(); + }); +}); + +describe('WindowBase lifecycle', () => { + it('walks attach -> detach -> re-attach -> close, emitting one event per transition', () => { + const window = new TestWindow(); + const events = recordEvents(window); + + window._notifyEvent(NativeWindowEvents.attached); + expect(window.state).toBe('attached'); + + window._detach(); + expect(window.state).toBe('detached'); + + window._setState('attached'); + window._notifyEvent(NativeWindowEvents.attached); + expect(window.state).toBe('attached'); + + window._notifyEvent(NativeWindowEvents.close); + window._destroy(); + expect(window.state).toBe('closed'); + + expect(events).toEqual(['attached', 'detached', 'attached', 'close', 'deactivate']); + }); + + it('keeps listeners through a detach so a re-attached window still notifies them', () => { + const window = new TestWindow(); + const events: string[] = []; + window.on(NativeWindowEvents.attached, (data) => events.push(data.eventName)); + + window._detach(); + window._setState('attached'); + window._notifyEvent(NativeWindowEvents.attached); + + expect(events).toEqual(['attached']); + }); + + it('drops listeners only after the teardown hook has run', () => { + const window = new TestWindow(); + const events = recordEvents(window); + + window._destroy(); + + expect(window.destroyHookRan).toBe(true); + expect(events).toEqual(['deactivate']); + }); + + it('delivers close to listeners registered before it and nothing afterwards', () => { + const window = new TestWindow(); + const events = recordEvents(window); + + window._notifyEvent(NativeWindowEvents.close); + window._destroy(); + expect(events).toEqual(['close', 'deactivate']); + events.length = 0; + + window._notifyEvent(NativeWindowEvents.close); + window._notifyEvent(NativeWindowEvents.activate); + window._destroy(); + + expect(events).toEqual([]); + }); + + it('unloads and resets the root view when the window session ends', () => { + const window = new TestWindow(); + const view = createFakeView(); + window.setContent(asView(view)); + view.isLoaded = true; + + window._destroy(); + + expect(view.unloadedCount).toBe(1); + expect(view.resetCount).toBe(1); + expect(window.rootView).toBeNull(); + }); + + it('tears the root view down on detach but keeps reporting it', () => { + const window = new TestWindow(); + const view = createFakeView(); + window.setContent(asView(view)); + view.isLoaded = true; + + window._detach(); + + expect(view.unloadedCount).toBe(1); + expect(view.tearDownCount).toBe(1); + expect(window.rootView).toBe(asView(view)); + }); +}); + +describe('NativeWindow.setContent', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('accepts a View, applies the root view settings and installs it natively', () => { + const window = new TestWindow(); + const view = createFakeView(); + const events: string[] = []; + window.on(NativeWindowEvents.contentLoaded, (data) => events.push(data.eventName)); + + window.setContent(asView(view)); + + expect(window.rootView).toBe(asView(view)); + expect(view.setupCount).toBe(1); + expect(window.nativeContent).toEqual([asView(view)]); + expect(events).toEqual(['contentLoaded']); + }); + + it('gives the root view the window-scoped css classes', () => { + const window = new TestWindow(); + window.orientationValue = 'landscape'; + window.appearanceValue = 'dark'; + window.directionValue = CoreTypes.LayoutDirection.rtl; + const view = createFakeView(); + + window.setContent(asView(view)); + + expect(view.cssClasses).toContain('ns-root'); + expect(view.cssClasses).toContain('ns-landscape'); + expect(view.cssClasses).toContain('ns-dark'); + expect(view.cssClasses).toContain('ns-rtl'); + }); + + it('builds the view from a NavigationEntry', () => { + const window = new TestWindow(); + const built = createFakeView(); + const create = vi.spyOn(Builder, 'createViewFromEntry').mockReturnValue(asView(built)); + const entry: NavigationEntry = { moduleName: 'pages/second' }; + + window.setContent(entry); + + expect(create).toHaveBeenCalledWith(entry); + expect(window.rootView).toBe(asView(built)); + expect(window.nativeContent).toEqual([asView(built)]); + }); + + it('builds the view from a module name string', () => { + const window = new TestWindow(); + const built = createFakeView(); + const create = vi.spyOn(Builder, 'createViewFromEntry').mockReturnValue(asView(built)); + + window.setContent('pages/second'); + + expect(create).toHaveBeenCalledWith({ moduleName: 'pages/second' }); + expect(window.rootView).toBe(asView(built)); + }); + + it('resets the previous root view when the content is replaced', () => { + const window = new TestWindow(); + const first = createFakeView(); + const second = createFakeView(); + const events: string[] = []; + window.on(NativeWindowEvents.contentLoaded, (data) => events.push(data.eventName)); + + window.setContent(asView(first)); + window.setContent(asView(second)); + + expect(first.resetCount).toBe(1); + expect(window.rootView).toBe(asView(second)); + expect(events).toEqual(['contentLoaded', 'contentLoaded']); + }); + + it('throws rather than silently leaving the window empty on unusable content', () => { + const window = new TestWindow(); + + expect(() => window.setContent(undefined as unknown as View)).toThrow(/Invalid content/); + }); +}); + +describe('NativeWindow._adoptRootView', () => { + it('records the view and announces it without re-running the platform setup', () => { + const window = new TestWindow(); + const view = createFakeView(); + const events: string[] = []; + window.on(NativeWindowEvents.contentLoaded, (data) => events.push(data.eventName)); + + window._adoptRootView(asView(view)); + + expect(window.rootView).toBe(asView(view)); + expect(events).toEqual(['contentLoaded']); + // The platform pipeline already did both; redoing them would double-initialize the view. + expect(window.nativeContent).toEqual([]); + expect(view.setupCount).toBe(0); + }); + + it('is a no-op when handed the view it already holds', () => { + const window = new TestWindow(); + const view = createFakeView(); + window._adoptRootView(asView(view)); + const events: string[] = []; + window.on(NativeWindowEvents.contentLoaded, (data) => events.push(data.eventName)); + + window._adoptRootView(asView(view)); + + expect(events).toEqual([]); + }); + + it('ignores a missing view', () => { + const window = new TestWindow(); + const events: string[] = []; + window.on(NativeWindowEvents.contentLoaded, (data) => events.push(data.eventName)); + + window._adoptRootView(undefined as unknown as View); + + expect(window.rootView).toBeUndefined(); + expect(events).toEqual([]); + }); +}); + +describe('NativeWindow trait readings', () => { + let window: TestWindow; + + beforeEach(() => { + window = new TestWindow(); + }); + + it('reports a change the platform has not announced yet and raises the event for it', () => { + expect(window.orientation()).toBe('portrait'); + const changes: string[] = []; + window.on(NativeWindowEvents.orientationChanged, (data) => changes.push(data.newValue)); + + window.orientationValue = 'landscape'; + + expect(window.orientation()).toBe('landscape'); + expect(changes).toEqual(['landscape']); + }); + + it('freezes the last reading once the native surface is gone', () => { + window.appearanceValue = 'dark'; + window.directionValue = CoreTypes.LayoutDirection.rtl; + expect(window.systemAppearance()).toBe('dark'); + expect(window.layoutDirection()).toBe(CoreTypes.LayoutDirection.rtl); + + window._detach(); + window.appearanceValue = 'light'; + window.directionValue = CoreTypes.LayoutDirection.ltr; + window.orientationValue = 'landscape'; + + expect(window.systemAppearance()).toBe('dark'); + expect(window.layoutDirection()).toBe(CoreTypes.LayoutDirection.rtl); + expect(window.orientation()).toBe('portrait'); + }); +}); From 8c99542b50ed08f255b2bb46c648f10a81a18381 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 20 Aug 2026 18:51:08 -0300 Subject: [PATCH 16/23] chore(toolbox): cross-platform multi-window demo Rewrites the multiple-scenes page on the shared window API: windows are opened with Application.openWindow({ data }), their UI comes from a setWindowContentResolver, and each row shows live id/role/state/traits driven by that window's own events. Gating is by capability rather than platform, so the page renders and explains itself on Android too. --- apps/toolbox/src/pages/multiple-scenes.ts | 795 ++++++++------------- apps/toolbox/src/pages/multiple-scenes.xml | 138 ++-- 2 files changed, 390 insertions(+), 543 deletions(-) diff --git a/apps/toolbox/src/pages/multiple-scenes.ts b/apps/toolbox/src/pages/multiple-scenes.ts index 84f90d1657..253213387f 100644 --- a/apps/toolbox/src/pages/multiple-scenes.ts +++ b/apps/toolbox/src/pages/multiple-scenes.ts @@ -1,569 +1,406 @@ -import { Observable, EventData, Page, Application, StackLayout, Label, Button, Dialogs, View, Color, NativeWindowEvents, SceneEventData, Utils, WindowEvents, WindowOpenEventData, WindowCloseEventData, NativeWindow } from '@nativescript/core'; +import { Application, Button, Color, Dialogs, EventData, isAndroid, isIOS, Label, NativeWindowEvents, Observable, Page, StackLayout, WindowEvents } from '@nativescript/core'; +import type { NativeWindow, NativeWindowEventData, PrimaryWindowChangedEventData, WindowCloseEventData, WindowContentRequest, WindowOpenEventData } from '@nativescript/core'; -let page: Page; let viewModel: MultipleScenesModel; export function navigatingTo(args: EventData) { - page = args.object; + installWindowContentResolver(); viewModel = new MultipleScenesModel(); - page.bindingContext = viewModel; + (args.object).bindingContext = viewModel; } export function navigatingFrom(args: EventData) { - if (viewModel) { - viewModel.destroy(); - viewModel = undefined; - } + viewModel?.destroy(); + viewModel = undefined; } -export class MultipleScenesModel extends Observable { - private _sceneCount = 0; - private _isMultiSceneSupported = false; - private _currentWindows: any[] = []; - private _sceneEvents: string[] = []; - private _windowOpenHandler: (args: WindowOpenEventData) => void; - private _windowCloseHandler: (args: WindowCloseEventData) => void; - private _sceneEventHandlers: Map void> = new Map(); +/** + * The demo can open two different kinds of window. The kind is chosen when the window is + * requested and travels to the new window as `openWindow({ data })`. + */ +type DemoWindowKind = 'newSceneBasic' | 'newSceneAlt'; - constructor() { - super(); - this.checkSceneSupport(); - this.setupSceneEventListeners(); - this.updateSceneInfo(); - this.checkSceneDelegateRegistration(); - } +const DEMO_WINDOW_KINDS: Record = { + newSceneBasic: { title: 'Basic demo window', background: '#cdffdb', accent: '#ff4444', titleColor: '#1c4d2e' }, + newSceneAlt: { title: 'Alternate demo window', background: '#65adf1', accent: '#006ead', titleColor: '#00305c' }, +}; + +/** Per-window events the demo mirrors into its log and its live window list. */ +const TRACKED_WINDOW_EVENTS: string[] = [NativeWindowEvents.attached, NativeWindowEvents.detached, NativeWindowEvents.activate, NativeWindowEvents.deactivate, NativeWindowEvents.background, NativeWindowEvents.foreground, NativeWindowEvents.contentLoaded, NativeWindowEvents.displayed, NativeWindowEvents.close, NativeWindowEvents.orientationChanged, NativeWindowEvents.systemAppearanceChanged, NativeWindowEvents.layoutDirectionChanged]; - get sceneCount(): number { - return this._sceneCount; +// --- Window content --- + +let contentResolverInstalled = false; + +function installWindowContentResolver() { + if (contentResolverInstalled) { + return; } + contentResolverInstalled = true; + + // The resolver stays installed for the rest of the process: a window that detaches and + // re-attaches (iOS scene reconnect, Android activity recreation) asks for its content + // again, long after this page may have been navigated away from. + Application.setWindowContentResolver(resolveWindowContent); +} + +function resolveWindowContent(request: WindowContentRequest): Page | undefined { + // `data` is exactly what openWindow({ data }) was called with, carried across by the + // platform: NSUserActivity.userInfo on iOS, intent extras on Android. + const kind = request.data?.kind as DemoWindowKind; - get isMultiSceneSupported(): boolean { - return this._isMultiSceneSupported; + // Returning undefined hands the window back to the default main-entry behaviour, which + // is what every window this demo did not open should get - the primary window, a cold + // start, or a window the system restored on its own. + if (request.isPrimary || !DEMO_WINDOW_KINDS[kind]) { + return undefined; } - get currentWindows(): any[] { - return this._currentWindows; + viewModel?.logEvent(`content resolved for ${request.window.id} (${kind})`); + + return createDemoWindowPage(kind, request.window); +} + +/** + * Views built in plain code are only reachable through the closures that created them, so + * an unreferenced button can be collected while its window is still on screen and stop + * responding to taps. Holding the buttons here keeps them alive for the window's lifetime. + */ +const liveCloseButtons = new Set