Skip to content

Commit b3aad0b

Browse files
authored
feat: display dev menu as a sheet when invoked from Metro (1/2) (#2750)
## Summary: You can launch the dev menu 3 ways: 1. Via the hot key Cmd+D (not supported on macOS at time of writing, see notes for why) 2. Via a right click (not enabled in Fabric yet, see #2749) 3. By pressing "d" in a metro server This PR is focused on fixing issues with (3). When the dev menu was presented from metro, we didn't have a window or mouse location to key off of, so a context menu would present at coordinates (0,0), AKA, the bottom left of your screen on macOS. This isn't very discoverable or useful. To match other platforms, I think it would be better to make the dev menu an NSAlert, presented on top of the apps window. Let's also preserve the right click context menu for now, since that is still useful and muscle memory for most macOS devs. Let's also use some newer NSMenu APIs to set the title / subtitle while we're here. ## Test Plan: https://github.com/user-attachments/assets/64bc0bc1-c1b2-4b39-a0cb-def87baa91f2 ## Notes We don't want to support (1) because we don't want to register hotkeys on the window, a window may have multiple instances of RN running. We could register it on the root view... but on macOS, keyboard focus is rarely in the root view (it's usually just on the window or application) so this isn't very discoverable. I'll elect to skip implementing this flow for now.
1 parent 9ed2ea2 commit b3aad0b

File tree

1 file changed

+123
-83
lines changed

1 file changed

+123
-83
lines changed

packages/react-native/React/CoreModules/RCTDevMenu.mm

Lines changed: 123 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,10 @@ - (NSString *)title
100100
@end
101101

102102
#if !TARGET_OS_OSX // [macOS]
103-
104103
typedef void (^RCTDevMenuAlertActionHandler)(UIAlertAction *action);
105-
106-
#endif // [macOS]
104+
#else // [macOS
105+
typedef void (^RCTDevMenuAlertActionHandler)(NSModalResponse response);
106+
#endif // macOS]
107107

108108
@interface RCTDevMenu () <RCTBridgeModule, RCTInvalidating, NativeDevMenuSpec>
109109

@@ -112,6 +112,8 @@ @interface RCTDevMenu () <RCTBridgeModule, RCTInvalidating, NativeDevMenuSpec>
112112
@implementation RCTDevMenu {
113113
#if !TARGET_OS_OSX // [macOS]
114114
UIAlertController *_actionSheet;
115+
#else // [macOS
116+
NSAlert *_alert;
115117
#endif // [macOS]
116118
NSMutableArray<RCTDevMenuItem *> *_extraMenuItems;
117119
}
@@ -247,12 +249,16 @@ - (void)toggle
247249
[self show];
248250
}
249251
}
252+
#endif // macOS]
250253

251254
- (BOOL)isActionSheetShown
252255
{
256+
#if !TARGET_OS_OSX // [macOS]
253257
return _actionSheet != nil;
258+
#else // [macOS
259+
return _alert != nil;
260+
#endif // macOS]
254261
}
255-
#endif // [macOS]
256262

257263
- (void)addItem:(NSString *)title handler:(void (^)(void))handler
258264
{
@@ -433,43 +439,42 @@ - (void)setDefaultJSBundle
433439
[alert addButtonWithTitle:@"Cancel"];
434440
[alert setAlertStyle:NSAlertStyleWarning];
435441

436-
[alert beginSheetModalForWindow:[NSApp keyWindow]
437-
completionHandler:^(NSModalResponse response) {
438-
if (response == NSAlertFirstButtonReturn) {
439-
// Apply Changes
440-
NSString *ipAddress = ipTextField.stringValue;
441-
NSString *port = portTextField.stringValue;
442-
NSString *bundleRoot = entrypointTextField.stringValue;
443-
444-
if (ipAddress.length == 0 && port.length == 0) {
445-
[weakSelf setDefaultJSBundle];
446-
return;
447-
}
448-
449-
NSNumberFormatter *formatter = [NSNumberFormatter new];
450-
formatter.numberStyle = NSNumberFormatterDecimalStyle;
451-
NSNumber *portNumber = [formatter numberFromString:port];
452-
if (portNumber == nil) {
453-
portNumber = [NSNumber numberWithInt:RCT_METRO_PORT];
454-
}
455-
456-
[RCTBundleURLProvider sharedSettings].jsLocation =
457-
[NSString stringWithFormat:@"%@:%d", ipAddress, portNumber.intValue];
458-
459-
if (bundleRoot.length == 0) {
460-
[bundleManager resetBundleURL];
461-
} else {
462-
bundleManager.bundleURL = [[RCTBundleURLProvider sharedSettings]
463-
jsBundleURLForBundleRoot:bundleRoot];
464-
}
465-
466-
RCTTriggerReloadCommandListeners(@"Dev menu - apply changes");
467-
} else if (response == NSAlertSecondButtonReturn) {
468-
// Reset to Default
469-
[weakSelf setDefaultJSBundle];
470-
}
471-
// Cancel - do nothing
472-
}];
442+
NSModalResponse response = [alert runModal];
443+
444+
if (response == NSAlertFirstButtonReturn) {
445+
// Apply Changes
446+
NSString *ipAddress = ipTextField.stringValue;
447+
NSString *port = portTextField.stringValue;
448+
NSString *bundleRoot = entrypointTextField.stringValue;
449+
450+
if (ipAddress.length == 0 && port.length == 0) {
451+
[weakSelf setDefaultJSBundle];
452+
return;
453+
}
454+
455+
NSNumberFormatter *formatter = [NSNumberFormatter new];
456+
formatter.numberStyle = NSNumberFormatterDecimalStyle;
457+
NSNumber *portNumber = [formatter numberFromString:port];
458+
if (portNumber == nil) {
459+
portNumber = [NSNumber numberWithInt:RCT_METRO_PORT];
460+
}
461+
462+
[RCTBundleURLProvider sharedSettings].jsLocation =
463+
[NSString stringWithFormat:@"%@:%d", ipAddress, portNumber.intValue];
464+
465+
if (bundleRoot.length == 0) {
466+
[bundleManager resetBundleURL];
467+
} else {
468+
bundleManager.bundleURL = [[RCTBundleURLProvider sharedSettings]
469+
jsBundleURLForBundleRoot:bundleRoot];
470+
}
471+
472+
RCTTriggerReloadCommandListeners(@"Dev menu - apply changes");
473+
} else if (response == NSAlertSecondButtonReturn) {
474+
// Reset to Default
475+
[weakSelf setDefaultJSBundle];
476+
}
477+
// Cancel - do nothing
473478
#endif // macOS]
474479
}]];
475480

@@ -483,19 +488,29 @@ - (void)setDefaultJSBundle
483488
if (_actionSheet || RCTRunningInAppExtension()) {
484489
return;
485490
}
491+
#else // [macOS
492+
if (_alert) {
493+
return;
494+
}
495+
#endif // [macOS]
486496

487497
NSString *bridgeDescription = _bridge.bridgeDescription;
488498
NSString *description =
489499
bridgeDescription.length > 0 ? [NSString stringWithFormat:@"Running %@", bridgeDescription] : nil;
490500

501+
#if !TARGET_OS_OSX // [macOS]
491502
// On larger devices we don't have an anchor point for the action sheet
492503
UIAlertControllerStyle style = [[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone
493504
? UIAlertControllerStyleActionSheet
494505
: UIAlertControllerStyleAlert;
506+
#else // [macOS
507+
NSAlertStyle style = NSAlertStyleInformational;
508+
#endif // macOS]
495509

496510
NSString *devMenuType = [self.bridge isKindOfClass:RCTBridge.class] ? @"Bridge" : @"Bridgeless";
497511
NSString *devMenuTitle = [NSString stringWithFormat:@"React Native Dev Menu (%@)", devMenuType];
498512

513+
#if !TARGET_OS_OSX // [macOS]
499514
_actionSheet = [UIAlertController alertControllerWithTitle:devMenuTitle message:description preferredStyle:style];
500515

501516
NSArray<RCTDevMenuItem *> *items = [self _menuItemsToPresent];
@@ -513,20 +528,38 @@ - (void)setDefaultJSBundle
513528

514529
_presentedItems = items;
515530
[RCTPresentedViewController() presentViewController:_actionSheet animated:YES completion:nil];
516-
517531
#else // [macOS
518-
NSMenu *menu = [self menu];
519-
NSWindow *window = [NSApp keyWindow];
520-
NSEvent *event = [NSEvent mouseEventWithType:NSEventTypeLeftMouseUp
521-
location:CGPointMake(0, 0)
522-
modifierFlags:0
523-
timestamp:NSTimeIntervalSince1970
524-
windowNumber:[window windowNumber]
525-
context:nil
526-
eventNumber:0
527-
clickCount:0
528-
pressure:0.1];
529-
[NSMenu popUpContextMenu:menu withEvent:event forView:[window contentView]];
532+
_alert = [NSAlert new];
533+
[_alert setMessageText:devMenuTitle];
534+
[_alert setInformativeText:description];
535+
[_alert setAlertStyle:NSAlertStyleInformational];
536+
537+
NSArray<RCTDevMenuItem *> *items = [self _menuItemsToPresent];
538+
for (RCTDevMenuItem *item in items) {
539+
[_alert addButtonWithTitle:item.title];
540+
}
541+
542+
[_alert addButtonWithTitle:@"Cancel"];
543+
544+
_presentedItems = items;
545+
546+
// If Invoked from Metro, both the key window and main window may be nil, so we fallback to the first window in that case
547+
NSWindow *window = RCTKeyWindow() ?: [NSApp mainWindow] ?: [[NSApp windows] firstObject];
548+
549+
550+
[_alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
551+
// Button responses are NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, etc.
552+
// The last button (Cancel) will have response = NSAlertFirstButtonReturn + menuItems.count
553+
NSInteger buttonIndex = response - NSAlertFirstButtonReturn;
554+
555+
RCTDevMenuItem *selectedItem = nil;
556+
if (buttonIndex >= 0 && buttonIndex < self->_presentedItems.count) {
557+
// Execute the corresponding menu item
558+
selectedItem = self->_presentedItems[buttonIndex];
559+
}
560+
RCTDevMenuAlertActionHandler handler = [self alertActionHandlerForDevItem:selectedItem];
561+
handler(response);
562+
}];
530563
#endif // macOS]
531564

532565
[_callableJSModules invokeModule:@"RCTNativeAppEventEmitter" method:@"emit" withArgs:@[ @"RCTDevMenuShown" ]];
@@ -544,37 +577,44 @@ - (RCTDevMenuAlertActionHandler)alertActionHandlerForDevItem:(RCTDevMenuItem *__
544577
};
545578
}
546579
#else // [macOS
580+
- (RCTDevMenuAlertActionHandler)alertActionHandlerForDevItem:(RCTDevMenuItem *__nullable)item
581+
{
582+
return ^(NSModalResponse response) {
583+
if (item) {
584+
[item callHandler];
585+
}
586+
587+
self->_alert = nil;
588+
};
589+
}
590+
#endif // [macOS]
591+
592+
#if TARGET_OS_OSX // [macOS
547593
- (NSMenu *)menu
548594
{
549-
if ([_bridge.devSettings isSecondaryClickToShowDevMenuEnabled]) {
550-
NSMenu *menu = nil;
551-
if (_bridge) {
552-
NSString *desc = _bridge.bridgeDescription;
553-
if (desc.length == 0) {
554-
desc = NSStringFromClass([_bridge class]);
555-
}
556-
NSString *title = [NSString stringWithFormat:@"React Native: Development\n(%@)", desc];
557-
558-
menu = [NSMenu new];
559-
560-
NSMutableAttributedString *attributedTitle = [[NSMutableAttributedString alloc] initWithString:title];
561-
[attributedTitle setAttributes:@{NSFontAttributeName : [NSFont menuFontOfSize:0]}
562-
range:NSMakeRange(0, [attributedTitle length])];
563-
NSMenuItem *titleItem = [NSMenuItem new];
564-
[titleItem setAttributedTitle:attributedTitle];
565-
[menu addItem:titleItem];
566-
567-
[menu addItem:[NSMenuItem separatorItem]];
568-
569-
NSArray<RCTDevMenuItem *> *items = [self _menuItemsToPresent];
570-
for (RCTDevMenuItem *item in items) {
571-
NSMenuItem *menuItem = [[NSMenuItem alloc] initWithTitle:[item title]
572-
action:@selector(menuItemSelected:)
573-
keyEquivalent:@""];
574-
[menuItem setTarget:self];
575-
[menuItem setRepresentedObject:item];
576-
[menu addItem:menuItem];
577-
}
595+
if ([((RCTDevSettings *)[_moduleRegistry moduleForName:"DevSettings"]) isSecondaryClickToShowDevMenuEnabled]) {
596+
NSMenu *menu = [NSMenu new];
597+
598+
NSString *devMenuType = [self.bridge isKindOfClass:RCTBridge.class] ? @"Bridge" : @"Bridgeless";
599+
NSString *devMenuTitle = [NSString stringWithFormat:@"React Native Dev Menu (%@)", devMenuType];
600+
601+
NSMenuItem *titleItem = [NSMenuItem sectionHeaderWithTitle:devMenuTitle];
602+
if (@available(macOS 14.4, *)) {
603+
NSString *bridgeDescription = _bridge.bridgeDescription;
604+
NSString *description =
605+
bridgeDescription.length > 0 ? [NSString stringWithFormat:@"Running %@", bridgeDescription] : nil;
606+
[titleItem setSubtitle:description];
607+
}
608+
[menu addItem:titleItem];
609+
610+
NSArray<RCTDevMenuItem *> *items = [self _menuItemsToPresent];
611+
for (RCTDevMenuItem *item in items) {
612+
NSMenuItem *menuItem = [[NSMenuItem alloc] initWithTitle:[item title]
613+
action:@selector(menuItemSelected:)
614+
keyEquivalent:@""];
615+
[menuItem setTarget:self];
616+
[menuItem setRepresentedObject:item];
617+
[menu addItem:menuItem];
578618
}
579619
return menu;
580620
}

0 commit comments

Comments
 (0)