From 1f31977b47c3ceecdc6d3c340a5cdeca12c42d91 Mon Sep 17 00:00:00 2001 From: realZachi <109552121+realZachi@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:22:07 +0200 Subject: [PATCH] feat: add WindowManagerMacOS for multi-window support react-native-macos has so far had no way to show React content in more than one window: RCTAppDelegate creates a single NSWindow in loadReactNativeWindow: and there is no JavaScript API to create more. Add a WindowManagerMacOS module that opens additional NSWindows from JavaScript. Each window hosts its own React root, identified by an app key registered with AppRegistry.registerComponent, while all windows share a single bridge and JavaScript runtime. AppRegistry.registerComponent('Settings', () => SettingsScreen); await WindowManagerMacOS.open({ moduleName: 'Settings', title: 'Settings', width: 480, height: 360, }); Root views are created through the app delegate's RCTRootViewFactory, which already guards bridge and host creation internally and can safely be called more than once, so the same code path works for the old architecture, Fabric and bridgeless alike. When no factory is available the module falls back to -[RCTRootView initWithBridge:moduleName: initialProperties:] so the bridge is still shared. open() is idempotent per key: opening an already-open key focuses the existing window instead of creating a duplicate. Windows can be closed, focused, retitled and enumerated, and lifecycle events are delivered to JavaScript via RCTEventEmitter. Also make RCTKeyWindow() fall back to the main window and then to any visible, focusable window on macOS. It previously returned [NSApp keyWindow] unconditionally, which is nil whenever no window holds key focus, causing RCTDeviceInfo to report a zero-sized window through Dimensions. That is more likely to happen with several windows open. Adds an RNTester example under the macOS category demonstrating opening, focusing, retitling and closing secondary windows, and a shared counter that shows state being observed across windows. --- .changeset/multi-window-support.md | 5 + .../WindowManager/NativeWindowManagerMacOS.js | 14 + .../WindowManager/WindowManagerMacOS.d.ts | 74 +++ .../WindowManager/WindowManagerMacOS.js | 199 ++++++++ packages/react-native/React/Base/RCTUtils.mm | 18 +- .../React/CoreModules/CoreModulesPlugins.h | 4 + .../React/CoreModules/CoreModulesPlugins.mm | 3 + .../React/CoreModules/RCTWindowManager.h | 15 + .../React/CoreModules/RCTWindowManager.mm | 478 ++++++++++++++++++ packages/react-native/index.js | 5 + packages/react-native/index.js.flow | 8 + packages/react-native/package.json | 3 + .../modules/NativeWindowManagerMacOS.js | 30 ++ packages/react-native/types/index.d.ts | 1 + .../WindowManager/WindowManagerExample.js | 272 ++++++++++ .../rn-tester/js/utils/RNTesterList.ios.js | 5 + 16 files changed, 1133 insertions(+), 1 deletion(-) create mode 100644 .changeset/multi-window-support.md create mode 100644 packages/react-native/Libraries/WindowManager/NativeWindowManagerMacOS.js create mode 100644 packages/react-native/Libraries/WindowManager/WindowManagerMacOS.d.ts create mode 100644 packages/react-native/Libraries/WindowManager/WindowManagerMacOS.js create mode 100644 packages/react-native/React/CoreModules/RCTWindowManager.h create mode 100644 packages/react-native/React/CoreModules/RCTWindowManager.mm create mode 100644 packages/react-native/src/private/specs_DEPRECATED/modules/NativeWindowManagerMacOS.js create mode 100644 packages/rn-tester/js/examples/WindowManager/WindowManagerExample.js diff --git a/.changeset/multi-window-support.md b/.changeset/multi-window-support.md new file mode 100644 index 000000000000..529b32253f82 --- /dev/null +++ b/.changeset/multi-window-support.md @@ -0,0 +1,5 @@ +--- +'react-native-macos': patch +--- + +Add `WindowManagerMacOS`, an API for opening and managing additional windows from JavaScript. Each window hosts its own React root (registered via `AppRegistry.registerComponent`) while sharing a single bridge and JavaScript runtime. Also makes `RCTKeyWindow()` fall back to the main window and then any visible window, so `Dimensions` no longer reports a zero-sized window when no window has key focus. diff --git a/packages/react-native/Libraries/WindowManager/NativeWindowManagerMacOS.js b/packages/react-native/Libraries/WindowManager/NativeWindowManagerMacOS.js new file mode 100644 index 000000000000..ca7a28b1ac4f --- /dev/null +++ b/packages/react-native/Libraries/WindowManager/NativeWindowManagerMacOS.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +export * from '../../src/private/specs_DEPRECATED/modules/NativeWindowManagerMacOS'; +import NativeWindowManagerMacOS from '../../src/private/specs_DEPRECATED/modules/NativeWindowManagerMacOS'; + +export default NativeWindowManagerMacOS; diff --git a/packages/react-native/Libraries/WindowManager/WindowManagerMacOS.d.ts b/packages/react-native/Libraries/WindowManager/WindowManagerMacOS.d.ts new file mode 100644 index 000000000000..53474f5e4289 --- /dev/null +++ b/packages/react-native/Libraries/WindowManager/WindowManagerMacOS.d.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +// [macOS] + +import type {EventSubscription} from '../vendor/emitter/EventEmitter'; + +export type WindowManagerEventMap = { + windowDidOpen: WindowInfo; + windowDidClose: {key: string; moduleName: string}; + windowDidFocus: WindowInfo; + windowDidBlur: WindowInfo; + windowDidResize: WindowInfo; +}; + +export interface WindowOptions { + moduleName: string; + key?: string | undefined; + initialProps?: Object | undefined; + title?: string | undefined; + width?: number | undefined; + height?: number | undefined; + minWidth?: number | undefined; + minHeight?: number | undefined; + x?: number | undefined; + y?: number | undefined; + resizable?: boolean | undefined; + closable?: boolean | undefined; + minimizable?: boolean | undefined; + titlebarAppearsTransparent?: boolean | undefined; + hidesOnDeactivate?: boolean | undefined; + alwaysOnTop?: boolean | undefined; + rememberFrame?: boolean | undefined; + focus?: boolean | undefined; +} + +export interface WindowInfo { + key: string; + moduleName: string; + title: string; + width: number; + height: number; + x: number; + y: number; + isKey: boolean; + isVisible: boolean; +} + +export interface WindowManagerMacOSImpl { + isSupported: boolean; + addListener( + eventType: K, + listener: (event: WindowManagerEventMap[K]) => void, + context?: unknown, + ): EventSubscription; + removeAllListeners( + eventType?: K | null, + ): void; + open(options: WindowOptions): Promise; + close(key: string): Promise; + focus(key: string): Promise; + setTitle(key: string, title: string): Promise; + isOpen(key: string): Promise; + getWindows(): Promise; +} + +export const WindowManagerMacOS: WindowManagerMacOSImpl; +export type WindowManagerMacOS = WindowManagerMacOSImpl; diff --git a/packages/react-native/Libraries/WindowManager/WindowManagerMacOS.js b/packages/react-native/Libraries/WindowManager/WindowManagerMacOS.js new file mode 100644 index 000000000000..54a82f585677 --- /dev/null +++ b/packages/react-native/Libraries/WindowManager/WindowManagerMacOS.js @@ -0,0 +1,199 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {EventSubscription} from '../vendor/emitter/EventEmitter'; + +import NativeEventEmitter from '../EventEmitter/NativeEventEmitter'; +import NativeWindowManagerMacOS from './NativeWindowManagerMacOS'; + +export type WindowOptions = { + +moduleName: string, + +key?: ?string, + // $FlowFixMe[unclear-type] unclear type of arbitrary initial props + +initialProps?: ?Object, + +title?: ?string, + +width?: ?number, + +height?: ?number, + +minWidth?: ?number, + +minHeight?: ?number, + +x?: ?number, + +y?: ?number, + +resizable?: ?boolean, + +closable?: ?boolean, + +minimizable?: ?boolean, + +titlebarAppearsTransparent?: ?boolean, + +hidesOnDeactivate?: ?boolean, + +alwaysOnTop?: ?boolean, + +rememberFrame?: ?boolean, + +focus?: ?boolean, +}; + +export type WindowInfo = { + +key: string, + +moduleName: string, + +title: string, + +width: number, + +height: number, + +x: number, + +y: number, + +isKey: boolean, + +isVisible: boolean, +}; + +type WindowManagerEventMap = { + windowDidOpen: [WindowInfo], + windowDidClose: [{key: string, moduleName: string}], + windowDidFocus: [WindowInfo], + windowDidBlur: [WindowInfo], + windowDidResize: [WindowInfo], +}; + +function rejectNotSupported(): Promise { + return Promise.reject( + new Error('WindowManagerMacOS is only available on macOS'), + ); +} + +/** + * `WindowManagerMacOS` lets you open additional NSWindows, each hosting its + * own React root, from JavaScript. + * + * First register the component you want to display in a window, just like + * any other app key: + * + * AppRegistry.registerComponent('Settings', () => SettingsScreen); + * + * Then open a window for it: + * + * WindowManagerMacOS.open({ + * moduleName: 'Settings', + * title: 'Settings', + * width: 480, + * height: 360, + * }); + * + * All windows share a single bridge and JavaScript runtime, so plain module + * state (or any store) is visible to every window. Note that + * `Dimensions`/`useWindowDimensions` still report the app's key window, not + * the window a given root is rendered into. + */ +class WindowManagerMacOS { + /** + * Whether the native module backing this API is present. It is only ever + * true on macOS. + */ + isSupported: boolean = NativeWindowManagerMacOS != null; + + _emitter: ?NativeEventEmitter = null; + + /** + * The emitter is created lazily and never in the constructor: on iOS and + * macOS `new NativeEventEmitter()` asserts on a null module, so building it + * eagerly would make merely importing this module throw on every platform + * that has no window manager — defeating the `isSupported` check below. + */ + _getEmitter(): NativeEventEmitter { + let emitter = this._emitter; + if (emitter == null) { + if (NativeWindowManagerMacOS == null) { + throw new Error('WindowManagerMacOS is only available on macOS'); + } + emitter = new NativeEventEmitter( + NativeWindowManagerMacOS, + ); + this._emitter = emitter; + } + return emitter; + } + + /** + * Subscribe to window lifecycle events. Throws on platforms where + * `isSupported` is false. + */ + addListener>( + eventType: TEvent, + listener: (...args: WindowManagerEventMap[TEvent]) => mixed, + context?: mixed, + ): EventSubscription { + return this._getEmitter().addListener(eventType, listener, context); + } + + removeAllListeners>( + eventType: ?TEvent, + ): void { + if (this._emitter != null) { + this._emitter.removeAllListeners(eventType); + } + } + + /** + * Open a new window hosting `options.moduleName`. If a window with the + * same `key` (defaulting to `moduleName`) already exists, it is focused + * and its current info is resolved instead of opening a new window. + */ + open(options: WindowOptions): Promise { + if (NativeWindowManagerMacOS == null) { + return rejectNotSupported(); + } + return NativeWindowManagerMacOS.open(options); + } + + /** + * Close the window registered under `key`. + */ + close(key: string): Promise { + if (NativeWindowManagerMacOS == null) { + return rejectNotSupported(); + } + return NativeWindowManagerMacOS.close(key); + } + + /** + * Bring the window registered under `key` to the front. + */ + focus(key: string): Promise { + if (NativeWindowManagerMacOS == null) { + return rejectNotSupported(); + } + return NativeWindowManagerMacOS.focus(key); + } + + /** + * Update the title of the window registered under `key`. + */ + setTitle(key: string, title: string): Promise { + if (NativeWindowManagerMacOS == null) { + return rejectNotSupported(); + } + return NativeWindowManagerMacOS.setTitle(key, title); + } + + /** + * Whether a window is currently open for `key`. + */ + isOpen(key: string): Promise { + if (NativeWindowManagerMacOS == null) { + return Promise.resolve(false); + } + return NativeWindowManagerMacOS.isOpen(key); + } + + /** + * Info for all currently open windows. + */ + getWindows(): Promise> { + if (NativeWindowManagerMacOS == null) { + return Promise.resolve([]); + } + return NativeWindowManagerMacOS.getWindows(); + } +} + +export default (new WindowManagerMacOS(): WindowManagerMacOS); diff --git a/packages/react-native/React/Base/RCTUtils.mm b/packages/react-native/React/Base/RCTUtils.mm index a378d0001e7a..94921876b340 100644 --- a/packages/react-native/React/Base/RCTUtils.mm +++ b/packages/react-native/React/Base/RCTUtils.mm @@ -680,7 +680,23 @@ BOOL RCTRunningInAppExtension(void) return nil; #else // [macOS - return [NSApp keyWindow]; + NSWindow *keyWindow = [NSApp keyWindow]; + if (keyWindow != nil) { + return keyWindow; + } + // [macOS] With multiple windows open, or while the app is inactive, AppKit may report no key + // window at all. Fall back to the main window and then to any visible, focusable window so that + // callers (e.g. RCTDeviceInfo's `Dimensions`) never observe a zero-sized window. + NSWindow *mainWindow = [NSApp mainWindow]; + if (mainWindow != nil) { + return mainWindow; + } + for (NSWindow *window in [NSApp windows]) { + if (window.isVisible && window.canBecomeKeyWindow) { + return window; + } + } + return nil; #endif // macOS] } diff --git a/packages/react-native/React/CoreModules/CoreModulesPlugins.h b/packages/react-native/React/CoreModules/CoreModulesPlugins.h index e62d4d157bb0..511ab0f6d2ca 100644 --- a/packages/react-native/React/CoreModules/CoreModulesPlugins.h +++ b/packages/react-native/React/CoreModules/CoreModulesPlugins.h @@ -17,6 +17,7 @@ // OSS-compatibility layer #import +#import // [macOS] #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wreturn-type-c-linkage" @@ -52,6 +53,9 @@ Class RCTStatusBarManagerCls(void) __attribute__((used)); Class RCTTimingCls(void) __attribute__((used)); Class RCTWebSocketModuleCls(void) __attribute__((used)); Class RCTBlobManagerCls(void) __attribute__((used)); +#if TARGET_OS_OSX // [macOS] +Class RCTWindowManagerCls(void) __attribute__((used)); +#endif // [macOS] #ifdef __cplusplus } diff --git a/packages/react-native/React/CoreModules/CoreModulesPlugins.mm b/packages/react-native/React/CoreModules/CoreModulesPlugins.mm index fad41711dadd..14852fe91a4f 100644 --- a/packages/react-native/React/CoreModules/CoreModulesPlugins.mm +++ b/packages/react-native/React/CoreModules/CoreModulesPlugins.mm @@ -46,6 +46,9 @@ Class RCTCoreModulesClassProvider(const char *name) { {"Timing", RCTTimingCls}, {"WebSocketModule", RCTWebSocketModuleCls}, {"BlobModule", RCTBlobManagerCls}, +#if TARGET_OS_OSX // [macOS] + {"WindowManagerMacOS", RCTWindowManagerCls}, +#endif // [macOS] }; auto p = sCoreModuleClassMap->find(name); diff --git a/packages/react-native/React/CoreModules/RCTWindowManager.h b/packages/react-native/React/CoreModules/RCTWindowManager.h new file mode 100644 index 000000000000..1c67c4eb563d --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTWindowManager.h @@ -0,0 +1,15 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import // [macOS] + +#import +#import + +@interface RCTWindowManager : RCTEventEmitter + +@end diff --git a/packages/react-native/React/CoreModules/RCTWindowManager.mm b/packages/react-native/React/CoreModules/RCTWindowManager.mm new file mode 100644 index 000000000000..6c4a2e46693e --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTWindowManager.mm @@ -0,0 +1,478 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTWindowManager.h" + +#import +#import +#import +#import + +#import "CoreModulesPlugins.h" + +#if TARGET_OS_OSX // [macOS +#import +#endif // macOS] + +@interface RCTWindowManager () + +@end + +#if TARGET_OS_OSX // [macOS + +/** + * `RCTRootViewFactory` lives in the `React-RCTAppDelegate` pod, which itself depends on + * `React-Core` (and therefore on `React-CoreModules`). Importing `RCTRootViewFactory.h` here + * would create a circular pod dependency, so we instead redeclare just the two members we need + * as local protocols. Objective-C dispatches by selector, so a plain message send against these + * protocol types reaches the real `RCTAppDelegate`/`RCTRootViewFactory` implementations while + * keeping this file free of any compile-time dependency on that pod. Both call sites are guarded + * by `respondsToSelector:`. + */ +@protocol RCTWindowManagerRootViewFactory +- (RCTPlatformView *)viewWithModuleName:(NSString *)moduleName + initialProperties:(NSDictionary *)initialProperties + launchOptions:(NSDictionary *)launchOptions; +@end + +@protocol RCTWindowManagerRootViewFactoryProvider +- (id)rootViewFactory; +@end + +@class RCTWindowHost; + +/** + * Forward declaration of the `RCTWindowHost` callback methods implemented further down on + * `RCTWindowManager`, so that `RCTWindowHost` (declared next) can call them. + */ +@interface RCTWindowManager (RCTWindowHostCallbacks) +- (void)windowHostWillClose:(RCTWindowHost *)host; +- (void)windowHostDidBecomeKey:(RCTWindowHost *)host; +- (void)windowHostDidResignKey:(RCTWindowHost *)host; +- (void)windowHostDidResize:(RCTWindowHost *)host; +@end + +/** + * Private helper object that owns one extra `NSWindow` and the React root view hosted inside + * it. Acts as the window's delegate so it can notify `RCTWindowManager` about lifecycle and + * focus events. + */ +@interface RCTWindowHost : NSObject + +@property (nonatomic, copy) NSString *key; +@property (nonatomic, copy) NSString *moduleName; +@property (nonatomic, strong) NSWindow *window; +@property (nonatomic, strong) RCTPlatformView *rootView; +@property (nonatomic, weak) RCTWindowManager *manager; + +@end + +@implementation RCTWindowHost + +- (void)windowWillClose:(NSNotification *)notification +{ + [self.manager windowHostWillClose:self]; +} + +- (void)windowDidBecomeKey:(NSNotification *)notification +{ + [self.manager windowHostDidBecomeKey:self]; +} + +- (void)windowDidResignKey:(NSNotification *)notification +{ + [self.manager windowHostDidResignKey:self]; +} + +- (void)windowDidResize:(NSNotification *)notification +{ + [self.manager windowHostDidResize:self]; +} + +@end + +#endif // macOS] + +@implementation RCTWindowManager { +#if TARGET_OS_OSX // [macOS + NSMutableDictionary *_windows; + BOOL _hasListeners; +#endif // macOS] +} + +RCT_EXPORT_MODULE(WindowManagerMacOS) + +- (instancetype)init +{ + if (self = [super init]) { +#if TARGET_OS_OSX // [macOS + _windows = [NSMutableDictionary new]; +#endif // macOS] + } + return self; +} + +- (dispatch_queue_t)methodQueue +{ + return dispatch_get_main_queue(); +} + ++ (BOOL)requiresMainQueueSetup +{ + return YES; +} + +- (NSArray *)supportedEvents +{ + return @[ @"windowDidOpen", @"windowDidClose", @"windowDidFocus", @"windowDidBlur", @"windowDidResize" ]; +} + +- (void)startObserving +{ +#if TARGET_OS_OSX // [macOS + _hasListeners = YES; +#endif // macOS] +} + +- (void)stopObserving +{ +#if TARGET_OS_OSX // [macOS + _hasListeners = NO; +#endif // macOS] +} + +#if TARGET_OS_OSX // [macOS + +#pragma mark - Helpers + +- (void)emit:(NSString *)name body:(NSDictionary *)body +{ + if (!_hasListeners) { + return; + } + [self sendEventWithName:name body:body]; +} + +- (NSDictionary *)infoForHost:(RCTWindowHost *)host +{ + NSWindow *window = host.window; + return @{ + @"key" : host.key, + @"moduleName" : host.moduleName, + @"title" : window.title ?: @"", + @"width" : @(window.contentLayoutRect.size.width), + @"height" : @(window.contentLayoutRect.size.height), + @"x" : @(window.frame.origin.x), + @"y" : @(window.frame.origin.y), + @"isKey" : @(window.isKeyWindow), + @"isVisible" : @(window.isVisible), + }; +} + +/** + * Creates a React root view for `moduleName`, preferring the app delegate's + * `RCTRootViewFactory` (works for old architecture, Fabric, and bridgeless alike, since + * `RCTRootViewFactory` guards bridge/host creation internally and can safely be invoked more + * than once), and falling back to `-[RCTRootView initWithBridge:moduleName:initialProperties:]` + * when only a bridge is available. Never uses `-initWithBundleURL:`, which would create a + * second bridge. + */ +- (RCTPlatformView *)rootViewForModuleName:(NSString *)moduleName + initialProperties:(NSDictionary *)initialProperties + error:(NSString **)errorOut +{ + id delegate = [NSApp delegate]; + if ([delegate respondsToSelector:@selector(rootViewFactory)]) { + id factory = + [(id)delegate rootViewFactory]; + + if ([factory respondsToSelector:@selector(viewWithModuleName:initialProperties:launchOptions:)]) { + return [factory viewWithModuleName:moduleName initialProperties:initialProperties launchOptions:nil]; + } + } + + if (self.bridge != nil) { + return [[RCTRootView alloc] initWithBridge:self.bridge moduleName:moduleName initialProperties:initialProperties]; + } + + if (errorOut != NULL) { + *errorOut = + @"Could not obtain a React root view: no RCTRootViewFactory on the app delegate and no bridge available."; + } + return nil; +} + +#pragma mark - RCTWindowHost callbacks + +- (void)windowHostWillClose:(RCTWindowHost *)host +{ + host.window.delegate = nil; + [_windows removeObjectForKey:host.key]; + NSString *key = host.key; + NSString *moduleName = host.moduleName; + host.rootView = nil; + [self emit:@"windowDidClose" body:@{@"key" : key, @"moduleName" : moduleName}]; +} + +- (void)windowHostDidBecomeKey:(RCTWindowHost *)host +{ + [self emit:@"windowDidFocus" body:[self infoForHost:host]]; +} + +- (void)windowHostDidResignKey:(RCTWindowHost *)host +{ + [self emit:@"windowDidBlur" body:[self infoForHost:host]]; +} + +- (void)windowHostDidResize:(RCTWindowHost *)host +{ + [self emit:@"windowDidResize" body:[self infoForHost:host]]; +} + +#pragma mark - RCTInvalidating + +- (void)invalidate +{ + NSArray *hosts = [_windows.allValues copy]; + for (RCTWindowHost *host in hosts) { + host.window.delegate = nil; + [host.window close]; + } + [_windows removeAllObjects]; + [super invalidate]; +} + +#pragma mark - Exported methods + +RCT_EXPORT_METHOD(open : (NSDictionary *)options resolve : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + NSString *moduleName = [RCTConvert NSString:options[@"moduleName"]]; + if (moduleName.length == 0) { + reject(@"E_INVALID_ARGS", @"`moduleName` is required", nil); + return; + } + + NSString *key = [RCTConvert NSString:options[@"key"]] ?: moduleName; + + RCTWindowHost *existingHost = _windows[key]; + if (existingHost != nil) { + BOOL focus = options[@"focus"] == nil ? YES : [RCTConvert BOOL:options[@"focus"]]; + if (focus) { + [existingHost.window makeKeyAndOrderFront:nil]; + } + resolve([self infoForHost:existingHost]); + return; + } + + NSDictionary *initialProps = [RCTConvert NSDictionary:options[@"initialProps"]] ?: @{}; + NSString *title = [RCTConvert NSString:options[@"title"]] ?: moduleName; + double width = options[@"width"] != nil ? [RCTConvert double:options[@"width"]] : 640; + double height = options[@"height"] != nil ? [RCTConvert double:options[@"height"]] : 480; + double minWidth = options[@"minWidth"] != nil ? [RCTConvert double:options[@"minWidth"]] : 0; + double minHeight = options[@"minHeight"] != nil ? [RCTConvert double:options[@"minHeight"]] : 0; + BOOL hasPosition = options[@"x"] != nil && options[@"y"] != nil; + double x = hasPosition ? [RCTConvert double:options[@"x"]] : 0; + double y = hasPosition ? [RCTConvert double:options[@"y"]] : 0; + BOOL resizable = options[@"resizable"] == nil ? YES : [RCTConvert BOOL:options[@"resizable"]]; + BOOL closable = options[@"closable"] == nil ? YES : [RCTConvert BOOL:options[@"closable"]]; + BOOL minimizable = options[@"minimizable"] == nil ? YES : [RCTConvert BOOL:options[@"minimizable"]]; + BOOL titlebarAppearsTransparent = [RCTConvert BOOL:options[@"titlebarAppearsTransparent"]]; + BOOL hidesOnDeactivate = [RCTConvert BOOL:options[@"hidesOnDeactivate"]]; + BOOL alwaysOnTop = [RCTConvert BOOL:options[@"alwaysOnTop"]]; + BOOL rememberFrame = [RCTConvert BOOL:options[@"rememberFrame"]]; + BOOL focus = options[@"focus"] == nil ? YES : [RCTConvert BOOL:options[@"focus"]]; + + NSString *rootViewError = nil; + RCTPlatformView *rootView = [self rootViewForModuleName:moduleName + initialProperties:initialProps + error:&rootViewError]; + if (rootView == nil) { + reject(@"E_NO_ROOT_VIEW", rootViewError, nil); + return; + } + + NSWindowStyleMask styleMask = NSWindowStyleMaskTitled; + if (resizable) { + styleMask |= NSWindowStyleMaskResizable; + } + if (closable) { + styleMask |= NSWindowStyleMaskClosable; + } + if (minimizable) { + styleMask |= NSWindowStyleMaskMiniaturizable; + } + + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, width, height) + styleMask:styleMask + backing:NSBackingStoreBuffered + defer:NO]; + // Required: we hold `window` in a strong property on `RCTWindowHost`, so AppKit must not + // additionally release it when the user closes it, or we'd over-release. + window.releasedWhenClosed = NO; + window.title = title; + window.autorecalculatesKeyViewLoop = YES; + + if (minWidth > 0 || minHeight > 0) { + window.contentMinSize = NSMakeSize(minWidth, minHeight); + } + + if (titlebarAppearsTransparent) { + window.titlebarAppearsTransparent = YES; + } + if (hidesOnDeactivate) { + window.hidesOnDeactivate = YES; + } + if (alwaysOnTop) { + window.level = NSFloatingWindowLevel; + } + + rootView.frame = NSMakeRect(0, 0, width, height); + NSViewController *rootViewController = [NSViewController new]; + rootViewController.view = rootView; + window.contentViewController = rootViewController; + + NSString *frameAutosaveName = [@"RCTWindowManager." stringByAppendingString:key]; + BOOL frameRestored = NO; + if (rememberFrame) { + frameRestored = [window setFrameUsingName:frameAutosaveName]; + } + if (!frameRestored) { + if (hasPosition) { + [window setFrameOrigin:NSMakePoint(x, y)]; + } else { + [window center]; + } + } + if (rememberFrame) { + [window setFrameAutosaveName:frameAutosaveName]; + } + + RCTWindowHost *host = [RCTWindowHost new]; + host.key = key; + host.moduleName = moduleName; + host.window = window; + host.rootView = rootView; + host.manager = self; + window.delegate = host; + + _windows[key] = host; + + if (focus) { + [window makeKeyAndOrderFront:nil]; + } else { + [window orderFront:nil]; + } + + NSDictionary *info = [self infoForHost:host]; + resolve(info); + [self emit:@"windowDidOpen" body:info]; +} + +RCT_EXPORT_METHOD(close : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + RCTWindowHost *host = _windows[key]; + if (host == nil) { + resolve(@NO); + return; + } + [host.window close]; + resolve(@YES); +} + +RCT_EXPORT_METHOD(focus : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + RCTWindowHost *host = _windows[key]; + if (host == nil) { + resolve(@NO); + return; + } + [NSApp activateIgnoringOtherApps:YES]; + [host.window makeKeyAndOrderFront:nil]; + resolve(@YES); +} + +RCT_EXPORT_METHOD(setTitle : (NSString *)key title : (NSString *)title resolve : (RCTPromiseResolveBlock)resolve + reject : (RCTPromiseRejectBlock)reject) +{ + RCTWindowHost *host = _windows[key]; + if (host == nil) { + resolve(@NO); + return; + } + host.window.title = title; + resolve(@YES); +} + +RCT_EXPORT_METHOD(isOpen : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + resolve(@(_windows[key] != nil)); +} + +RCT_EXPORT_METHOD(getWindows : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) +{ + NSMutableArray *result = [NSMutableArray arrayWithCapacity:_windows.count]; + for (RCTWindowHost *host in _windows.allValues) { + [result addObject:[self infoForHost:host]]; + } + resolve(result); +} + +#else // [macOS] iOS is not supported: fulfil protocol conformance but always reject. + +RCT_EXPORT_METHOD(open : (NSDictionary *)options resolve : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + reject(@"E_UNSUPPORTED_PLATFORM", @"WindowManagerMacOS is only available on macOS", nil); +} + +RCT_EXPORT_METHOD(close : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + reject(@"E_UNSUPPORTED_PLATFORM", @"WindowManagerMacOS is only available on macOS", nil); +} + +RCT_EXPORT_METHOD(focus : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + reject(@"E_UNSUPPORTED_PLATFORM", @"WindowManagerMacOS is only available on macOS", nil); +} + +RCT_EXPORT_METHOD(setTitle : (NSString *)key title : (NSString *)title resolve : (RCTPromiseResolveBlock)resolve + reject : (RCTPromiseRejectBlock)reject) +{ + reject(@"E_UNSUPPORTED_PLATFORM", @"WindowManagerMacOS is only available on macOS", nil); +} + +RCT_EXPORT_METHOD(isOpen : (NSString *)key resolve : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + reject(@"E_UNSUPPORTED_PLATFORM", @"WindowManagerMacOS is only available on macOS", nil); +} + +RCT_EXPORT_METHOD(getWindows : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) +{ + reject(@"E_UNSUPPORTED_PLATFORM", @"WindowManagerMacOS is only available on macOS", nil); +} + +#endif // macOS] + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + +@end + +Class RCTWindowManagerCls(void) +{ + return RCTWindowManager.class; +} diff --git a/packages/react-native/index.js b/packages/react-native/index.js index 10b5956ff262..5f02b08375e4 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -365,6 +365,11 @@ module.exports = { return require('./src/private/components/virtualview/VirtualView') .VirtualViewMode; }, + // [macOS + get WindowManagerMacOS() { + return require('./Libraries/WindowManager/WindowManagerMacOS').default; + }, + // macOS] // #endregion } as ReactNativePublicAPI; diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index c8b4d775785f..b1184d2b6604 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -468,4 +468,12 @@ export { } from './src/private/components/virtualview/VirtualView'; export type {ModeChangeEvent} from './src/private/components/virtualview/VirtualView'; +// [macOS +export {default as WindowManagerMacOS} from './Libraries/WindowManager/WindowManagerMacOS'; +export type { + WindowOptions, + WindowInfo, +} from './Libraries/WindowManager/WindowManagerMacOS'; +// macOS] + // #endregion diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 230a3364423f..7386ec6b4569 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -243,6 +243,9 @@ }, "StatusBarManager": { "unstableRequiresMainQueueSetup": true + }, + "WindowManagerMacOS": { + "unstableRequiresMainQueueSetup": true } } }, diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeWindowManagerMacOS.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeWindowManagerMacOS.js new file mode 100644 index 000000000000..78b800dd6a40 --- /dev/null +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeWindowManagerMacOS.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {TurboModule} from '../../../../Libraries/TurboModule/RCTExport'; + +import * as TurboModuleRegistry from '../../../../Libraries/TurboModule/TurboModuleRegistry'; + +export interface Spec extends TurboModule { + // $FlowFixMe[unclear-type] unclear type of native options/result objects + +open: (options: Object) => Promise; + +close: (key: string) => Promise; + +focus: (key: string) => Promise; + +setTitle: (key: string, title: string) => Promise; + +isOpen: (key: string) => Promise; + // $FlowFixMe[unclear-type] unclear type of native result objects + +getWindows: () => Promise>; + + // Events + +addListener: (eventName: string) => void; + +removeListeners: (count: number) => void; +} + +export default (TurboModuleRegistry.get('WindowManagerMacOS'): ?Spec); diff --git a/packages/react-native/types/index.d.ts b/packages/react-native/types/index.d.ts index 3028792a8306..4948df898a93 100644 --- a/packages/react-native/types/index.d.ts +++ b/packages/react-native/types/index.d.ts @@ -151,6 +151,7 @@ export * from '../Libraries/Utilities/PixelRatio'; export * from '../Libraries/Utilities/Platform'; export * from '../Libraries/Vibration/Vibration'; export * from '../Libraries/vendor/core/ErrorUtils'; +export * from '../Libraries/WindowManager/WindowManagerMacOS'; // [macOS] export { EmitterSubscription, EventSubscription, diff --git a/packages/rn-tester/js/examples/WindowManager/WindowManagerExample.js b/packages/rn-tester/js/examples/WindowManager/WindowManagerExample.js new file mode 100644 index 000000000000..c5a465b47db1 --- /dev/null +++ b/packages/rn-tester/js/examples/WindowManager/WindowManagerExample.js @@ -0,0 +1,272 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; +import type {WindowInfo} from 'react-native'; + +import * as React from 'react'; +import {useCallback, useEffect, useState, useSyncExternalStore} from 'react'; +import { + AppRegistry, + Button, + ScrollView, + StyleSheet, + Text, + View, + WindowManagerMacOS, +} from 'react-native'; + +const SECONDARY_WINDOW_MODULE_NAME = 'RNTesterSecondaryWindow'; +const FIRST_WINDOW_KEY = 'secondary-1'; +const SECOND_WINDOW_KEY = 'secondary-2'; + +/** + * A tiny module-scope store so the counter is visible (and can be + * incremented) from every window sharing this bridge/JS runtime, not just + * the window that owns the `useState` call. + */ +let sharedCount = 0; +const sharedCountListeners: Set<() => void> = new Set(); + +function incrementSharedCount(): void { + sharedCount += 1; + sharedCountListeners.forEach(listener => listener()); +} + +function subscribeToSharedCount(listener: () => void): () => void { + sharedCountListeners.add(listener); + return () => { + sharedCountListeners.delete(listener); + }; +} + +function getSharedCount(): number { + return sharedCount; +} + +function useSharedCount(): number { + return useSyncExternalStore(subscribeToSharedCount, getSharedCount); +} + +/** + * Root component rendered inside secondary windows opened via + * `WindowManagerMacOS.open`. `windowKey` is passed through `initialProps` + * rather than reusing the prop name `key`: React treats `key` as a reserved + * prop and strips it from `props` when spread onto an element (see + * `renderApplication.js`, which renders ``), + * so a same-named `key` field in `initialProps` would never actually reach + * this component. + */ +function WindowManagerExampleWindow(props: { + windowKey: string, + title?: ?string, +}): React.Node { + const sharedCountValue = useSharedCount(); + const [error, setError] = useState(null); + + const closeThisWindow = useCallback(() => { + WindowManagerMacOS.close(props.windowKey).catch((closeError: mixed) => { + setError(String(closeError)); + }); + }, [props.windowKey]); + + return ( + + {props.title ?? 'Secondary window'} + windowKey: {props.windowKey} + Shared counter: {sharedCountValue} +