Skip to content

Commit 929c5ae

Browse files
committed
feat: display dev menu as a sheet when invoked from Metro (1/2) (microsoft#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 microsoft#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 c25e49d commit 929c5ae

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
}
@@ -249,12 +251,16 @@ - (void)toggle
249251
[self show];
250252
}
251253
}
254+
#endif // macOS]
252255

253256
- (BOOL)isActionSheetShown
254257
{
258+
#if !TARGET_OS_OSX // [macOS]
255259
return _actionSheet != nil;
260+
#else // [macOS
261+
return _alert != nil;
262+
#endif // macOS]
256263
}
257-
#endif // [macOS]
258264

259265
- (void)addItem:(NSString *)title handler:(void (^)(void))handler
260266
{
@@ -435,43 +441,42 @@ - (void)setDefaultJSBundle
435441
[alert addButtonWithTitle:@"Cancel"];
436442
[alert setAlertStyle:NSAlertStyleWarning];
437443

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

@@ -485,19 +490,29 @@ - (void)setDefaultJSBundle
485490
if (_actionSheet || RCTRunningInAppExtension()) {
486491
return;
487492
}
493+
#else // [macOS
494+
if (_alert) {
495+
return;
496+
}
497+
#endif // [macOS]
488498

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

503+
#if !TARGET_OS_OSX // [macOS]
493504
// On larger devices we don't have an anchor point for the action sheet
494505
UIAlertControllerStyle style = [[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone
495506
? UIAlertControllerStyleActionSheet
496507
: UIAlertControllerStyleAlert;
508+
#else // [macOS
509+
NSAlertStyle style = NSAlertStyleInformational;
510+
#endif // macOS]
497511

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

515+
#if !TARGET_OS_OSX // [macOS]
501516
_actionSheet = [UIAlertController alertControllerWithTitle:devMenuTitle message:description preferredStyle:style];
502517

503518
NSArray<RCTDevMenuItem *> *items = [self _menuItemsToPresent];
@@ -515,20 +530,38 @@ - (void)setDefaultJSBundle
515530

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

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

0 commit comments

Comments
 (0)