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} +