/** * Copyright (c) 2015-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. */ #import "RCTDevMenu.h" #import #import #import #import "JSCSamplingProfiler.h" #import "RCTAssert.h" #import "RCTBridge+Private.h" #import "RCTDefines.h" #import "RCTEventDispatcher.h" #import "RCTKeyCommands.h" #import "RCTLog.h" #import "RCTProfile.h" #import "RCTRootView.h" #import "RCTUtils.h" #import "RCTWebSocketObserverProtocol.h" #if RCT_DEV static NSString *const RCTShowDevMenuNotification = @"RCTShowDevMenuNotification"; static NSString *const RCTDevMenuSettingsKey = @"RCTDevMenu"; @implementation UIWindow (RCTDevMenu) - (void)RCT_motionEnded:(__unused UIEventSubtype)motion withEvent:(UIEvent *)event { if (event.subtype == UIEventSubtypeMotionShake) { [[NSNotificationCenter defaultCenter] postNotificationName:RCTShowDevMenuNotification object:nil]; } } @end typedef NS_ENUM(NSInteger, RCTDevMenuType) { RCTDevMenuTypeButton, RCTDevMenuTypeToggle }; @interface RCTDevMenuItem () @property (nonatomic, assign, readonly) RCTDevMenuType type; @property (nonatomic, copy, readonly) NSString *key; @property (nonatomic, copy) id value; @end @implementation RCTDevMenuItem { id _handler; // block NSString *_title; NSString *_selectedTitle; } - (instancetype)initWithType:(RCTDevMenuType)type key:(NSString *)key title:(NSString *)title selectedTitle:(NSString *)selectedTitle handler:(id /* block */)handler { if ((self = [super init])) { _type = type; _key = [key copy]; _title = [title copy]; _selectedTitle = [selectedTitle copy]; _handler = [handler copy]; _value = nil; } return self; } - (NSString *)title { if (_type == RCTDevMenuTypeToggle && [_value boolValue]) { return _selectedTitle; } return _title; } RCT_NOT_IMPLEMENTED(- (instancetype)init) + (instancetype)buttonItemWithTitle:(NSString *)title handler:(void (^)(void))handler { return [[self alloc] initWithType:RCTDevMenuTypeButton key:nil title:title selectedTitle:nil handler:handler]; } + (instancetype)toggleItemWithKey:(NSString *)key title:(NSString *)title selectedTitle:(NSString *)selectedTitle handler:(void (^)(BOOL selected))handler { return [[self alloc] initWithType:RCTDevMenuTypeToggle key:key title:title selectedTitle:selectedTitle handler:handler]; } - (void)callHandler { switch (_type) { case RCTDevMenuTypeButton: { if (_handler) { ((void(^)())_handler)(); } break; } case RCTDevMenuTypeToggle: { if (_handler) { ((void(^)(BOOL selected))_handler)([_value boolValue]); } break; } } } @end typedef void(^RCTDevMenuAlertActionHandler)(UIAlertAction *action); @interface RCTDevMenu () @property (nonatomic, strong) Class executorClass; @end @implementation RCTDevMenu { UIAlertController *_actionSheet; NSUserDefaults *_defaults; NSMutableDictionary *_settings; NSURLSessionDataTask *_updateTask; NSURL *_liveReloadURL; BOOL _jsLoaded; NSMutableArray *_extraMenuItems; NSString *_webSocketExecutorName; NSString *_executorOverride; } @synthesize bridge = _bridge; RCT_EXPORT_MODULE() + (void)initialize { // We're swizzling here because it's poor form to override methods in a category, // however UIWindow doesn't actually implement motionEnded:withEvent:, so there's // no need to call the original implementation. RCTSwapInstanceMethods([UIWindow class], @selector(motionEnded:withEvent:), @selector(RCT_motionEnded:withEvent:)); } - (instancetype)init { if ((self = [super init])) { NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter]; [notificationCenter addObserver:self selector:@selector(showOnShake) name:RCTShowDevMenuNotification object:nil]; [notificationCenter addObserver:self selector:@selector(settingsDidChange) name:NSUserDefaultsDidChangeNotification object:nil]; [notificationCenter addObserver:self selector:@selector(jsLoaded:) name:RCTJavaScriptDidLoadNotification object:nil]; _defaults = [NSUserDefaults standardUserDefaults]; _settings = [[NSMutableDictionary alloc] initWithDictionary:[_defaults objectForKey:RCTDevMenuSettingsKey]]; _extraMenuItems = [NSMutableArray new]; __weak RCTDevMenu *weakSelf = self; [_extraMenuItems addObject:[RCTDevMenuItem toggleItemWithKey:@"showInspector" title:@"Show Inspector" selectedTitle:@"Hide Inspector" handler:^(__unused BOOL enabled) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" [weakSelf.bridge.eventDispatcher sendDeviceEventWithName:@"toggleElementInspector" body:nil]; #pragma clang diagnostic pop }]]; _webSocketExecutorName = [_defaults objectForKey:@"websocket-executor-name"] ?: @"JS Remotely"; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ self->_executorOverride = [self->_defaults objectForKey:@"executor-override"]; }); // Delay setup until after Bridge init dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf updateSettings:self->_settings]; [weakSelf connectPackager]; }); #if TARGET_IPHONE_SIMULATOR RCTKeyCommands *commands = [RCTKeyCommands sharedInstance]; // Toggle debug menu [commands registerKeyCommandWithInput:@"d" modifierFlags:UIKeyModifierCommand action:^(__unused UIKeyCommand *command) { [weakSelf toggle]; }]; // Toggle element inspector [commands registerKeyCommandWithInput:@"i" modifierFlags:UIKeyModifierCommand action:^(__unused UIKeyCommand *command) { [weakSelf.bridge.eventDispatcher #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" sendDeviceEventWithName:@"toggleElementInspector" body:nil]; #pragma clang diagnostic pop }]; // Reload in normal mode [commands registerKeyCommandWithInput:@"n" modifierFlags:UIKeyModifierCommand action:^(__unused UIKeyCommand *command) { weakSelf.executorClass = Nil; }]; #endif } return self; } - (NSURL *)packagerURL { NSString *host = [_bridge.bundleURL host]; NSString *scheme = [_bridge.bundleURL scheme]; if (!host) { host = @"localhost"; scheme = @"http"; } NSNumber *port = [_bridge.bundleURL port]; if (!port) { port = @8081; // Packager default port } return [NSURL URLWithString:[NSString stringWithFormat:@"%@://%@:%@/message?role=shell", scheme, host, port]]; } // TODO: Move non-UI logic into separate RCTDevSettings module - (void)connectPackager { RCTAssertMainQueue(); NSURL *url = [self packagerURL]; if (!url) { return; } Class webSocketObserverClass = objc_lookUpClass("RCTWebSocketObserver"); if (webSocketObserverClass == Nil) { return; } // If multiple RCTDevMenus are created, the most recently connected one steals the RCTWebSocketObserver. // (Why this behavior exists is beyond me, as of this writing.) static NSMutableDictionary> *observers = nil; if (observers == nil) { observers = [NSMutableDictionary new]; } NSString *key = [url absoluteString]; id existingObserver = observers[key]; if (existingObserver) { existingObserver.delegate = self; } else { id newObserver = [(id)[webSocketObserverClass alloc] initWithURL:url]; newObserver.delegate = self; [newObserver start]; observers[key] = newObserver; } } - (BOOL)isSupportedVersion:(NSNumber *)version { NSArray *const kSupportedVersions = @[ @1 ]; return [kSupportedVersions containsObject:version]; } - (void)didReceiveWebSocketMessage:(NSDictionary *)message { if ([self isSupportedVersion:message[@"version"]]) { [self processTarget:message[@"target"] action:message[@"action"] options:message[@"options"]]; } } - (void)processTarget:(NSString *)target action:(NSString *)action options:(NSDictionary *)options { if ([target isEqualToString:@"bridge"]) { if ([action isEqualToString:@"reload"]) { if ([options[@"debug"] boolValue]) { _bridge.executorClass = objc_lookUpClass("RCTWebSocketExecutor"); } [_bridge reload]; } } } - (dispatch_queue_t)methodQueue { return dispatch_get_main_queue(); } - (void)settingsDidChange { // Needed to prevent a race condition when reloading __weak RCTDevMenu *weakSelf = self; NSDictionary *settings = [_defaults objectForKey:RCTDevMenuSettingsKey]; dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf updateSettings:settings]; }); } /** * This method loads the settings from NSUserDefaults and overrides any local * settings with them. It should only be called on app launch, or after the app * has returned from the background, when the settings might have been edited * outside of the app. */ - (void)updateSettings:(NSDictionary *)settings { [_settings setDictionary:settings]; // Fire handlers for items whose values have changed for (RCTDevMenuItem *item in _extraMenuItems) { if (item.key) { id value = settings[item.key]; if (value != item.value && ![value isEqual:item.value]) { item.value = value; [item callHandler]; } } } self.shakeToShow = [_settings[@"shakeToShow"] ?: @YES boolValue]; self.profilingEnabled = [_settings[@"profilingEnabled"] ?: @NO boolValue]; self.liveReloadEnabled = [_settings[@"liveReloadEnabled"] ?: @NO boolValue]; self.hotLoadingEnabled = [_settings[@"hotLoadingEnabled"] ?: @NO boolValue]; self.showFPS = [_settings[@"showFPS"] ?: @NO boolValue]; self.executorClass = NSClassFromString(_executorOverride ?: _settings[@"executorClass"]); } /** * This updates a particular setting, and then saves the settings. Because all * settings are overwritten by this, it's important that this is not called * before settings have been loaded initially, otherwise the other settings * will be reset. */ - (void)updateSetting:(NSString *)name value:(id)value { // Fire handler for item whose values has changed for (RCTDevMenuItem *item in _extraMenuItems) { if ([item.key isEqualToString:name]) { if (value != item.value && ![value isEqual:item.value]) { item.value = value; [item callHandler]; } break; } } // Save the setting id currentValue = _settings[name]; if (currentValue == value || [currentValue isEqual:value]) { return; } if (value) { _settings[name] = value; } else { [_settings removeObjectForKey:name]; } [_defaults setObject:_settings forKey:RCTDevMenuSettingsKey]; [_defaults synchronize]; } - (void)jsLoaded:(NSNotification *)notification { if (notification.userInfo[@"bridge"] != _bridge) { return; } _jsLoaded = YES; // Check if live reloading is available NSURL *scriptURL = _bridge.bundleURL; if (![scriptURL isFileURL]) { // Live reloading is disabled when running from bundled JS file _liveReloadURL = [[NSURL alloc] initWithString:@"/onchange" relativeToURL:scriptURL]; } else { _liveReloadURL = nil; } dispatch_async(dispatch_get_main_queue(), ^{ // Hit these setters again after bridge has finished loading self.profilingEnabled = self->_profilingEnabled; self.liveReloadEnabled = self->_liveReloadEnabled; self.executorClass = self->_executorClass; // Inspector can only be shown after JS has loaded if ([self->_settings[@"showInspector"] boolValue]) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" [self.bridge.eventDispatcher sendDeviceEventWithName:@"toggleElementInspector" body:nil]; #pragma clang diagnostic pop } }); } - (void)invalidate { _presentedItems = nil; [_updateTask cancel]; [_actionSheet dismissViewControllerAnimated:YES completion:^(void){}]; [[NSNotificationCenter defaultCenter] removeObserver:self]; } - (void)showOnShake { if (_shakeToShow) { [self show]; } } - (void)toggle { if (_actionSheet) { [_actionSheet dismissViewControllerAnimated:YES completion:^(void){}]; _actionSheet = nil; } else { [self show]; } } - (void)addItem:(NSString *)title handler:(void(^)(void))handler { [self addItem:[RCTDevMenuItem buttonItemWithTitle:title handler:handler]]; } - (void)addItem:(RCTDevMenuItem *)item { [_extraMenuItems addObject:item]; // Fire handler for items whose saved value doesn't match the default [self settingsDidChange]; } - (NSArray *)menuItems { NSMutableArray *items = [NSMutableArray new]; // Add built-in items __weak RCTDevMenu *weakSelf = self; [items addObject:[RCTDevMenuItem buttonItemWithTitle:@"Reload" handler:^{ [weakSelf reload]; }]]; Class jsDebuggingExecutorClass = objc_lookUpClass("RCTWebSocketExecutor"); if (!jsDebuggingExecutorClass) { [items addObject:[RCTDevMenuItem buttonItemWithTitle:[NSString stringWithFormat:@"%@ Debugger Unavailable", _webSocketExecutorName] handler:^{ UIAlertController *alertController = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:@"%@ Debugger Unavailable", self->_webSocketExecutorName] message:[NSString stringWithFormat:@"You need to include the RCTWebSocket library to enable %@ debugging", self->_webSocketExecutorName] preferredStyle:UIAlertControllerStyleAlert]; [RCTPresentedViewController() presentViewController:alertController animated:YES completion:NULL]; }]]; } else { BOOL isDebuggingJS = _executorClass && _executorClass == jsDebuggingExecutorClass; NSString *debuggingDescription = [_defaults objectForKey:@"websocket-executor-name"] ?: @"Remote JS"; NSString *debugTitleJS = isDebuggingJS ? [NSString stringWithFormat:@"Stop %@ Debugging", debuggingDescription] : [NSString stringWithFormat:@"Debug %@", _webSocketExecutorName]; [items addObject:[RCTDevMenuItem buttonItemWithTitle:debugTitleJS handler:^{ weakSelf.executorClass = isDebuggingJS ? Nil : jsDebuggingExecutorClass; }]]; } if (_liveReloadURL) { NSString *liveReloadTitle = _liveReloadEnabled ? @"Disable Live Reload" : @"Enable Live Reload"; [items addObject:[RCTDevMenuItem buttonItemWithTitle:liveReloadTitle handler:^{ __typeof(self) strongSelf = weakSelf; if (strongSelf) { strongSelf.liveReloadEnabled = !strongSelf->_liveReloadEnabled; } }]]; NSString *profilingTitle = RCTProfileIsProfiling() ? @"Stop Systrace" : @"Start Systrace"; [items addObject:[RCTDevMenuItem buttonItemWithTitle:profilingTitle handler:^{ __typeof(self) strongSelf = weakSelf; if (strongSelf) { strongSelf.profilingEnabled = !strongSelf->_profilingEnabled; } }]]; } if ([self hotLoadingAvailable]) { NSString *hotLoadingTitle = _hotLoadingEnabled ? @"Disable Hot Reloading" : @"Enable Hot Reloading"; [items addObject:[RCTDevMenuItem buttonItemWithTitle:hotLoadingTitle handler:^{ __typeof(self) strongSelf = weakSelf; if (strongSelf) { strongSelf.hotLoadingEnabled = !strongSelf->_hotLoadingEnabled; } }]]; } // Add toggles for JSC's sampling profiler, if the profiler is enabled if (JSC_JSSamplingProfilerEnabled(self->_bridge.jsContext.JSGlobalContextRef)) { JSContext *context = self->_bridge.jsContext; // Allow to toggle the sampling profiler through RN's dev menu [items addObject:[RCTDevMenuItem buttonItemWithTitle:@"Start / Stop JS Sampling Profiler" handler:^{ JSGlobalContextRef globalContext = context.JSGlobalContextRef; // JSPokeSamplingProfiler() toggles the profiling process JSValueRef jsResult = JSC_JSPokeSamplingProfiler(globalContext); if (JSC_JSValueGetType(globalContext, jsResult) != kJSTypeNull) { NSString *results = [[JSC_JSValue(globalContext) valueWithJSValueRef:jsResult inContext:context] toObject]; JSCSamplingProfiler *profilerModule = [self->_bridge moduleForClass:[JSCSamplingProfiler class]]; [profilerModule operationCompletedWithResults:results]; } }]]; } [items addObjectsFromArray:_extraMenuItems]; return items; } RCT_EXPORT_METHOD(show) { if (_actionSheet || !_bridge || RCTRunningInAppExtension()) { return; } NSString *title = [NSString stringWithFormat:@"React Native: Development (%@)", [_bridge class]]; // On larger devices we don't have an anchor point for the action sheet UIAlertControllerStyle style = [[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone ? UIAlertControllerStyleActionSheet : UIAlertControllerStyleAlert; _actionSheet = [UIAlertController alertControllerWithTitle:title message:@"" preferredStyle:style]; NSArray *items = [self menuItems]; for (RCTDevMenuItem *item in items) { [_actionSheet addAction:[UIAlertAction actionWithTitle:item.title style:UIAlertActionStyleDefault handler:[self alertActionHandlerForDevItem:item]]]; } [_actionSheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:[self alertActionHandlerForDevItem:nil]]]; _presentedItems = items; [RCTPresentedViewController() presentViewController:_actionSheet animated:YES completion:nil]; } - (RCTDevMenuAlertActionHandler)alertActionHandlerForDevItem:(RCTDevMenuItem *__nullable)item { return ^(__unused UIAlertAction *action) { if (item) { switch (item.type) { case RCTDevMenuTypeButton: { [item callHandler]; break; } case RCTDevMenuTypeToggle: { BOOL value = [self->_settings[item.key] boolValue]; [self updateSetting:item.key value:@(!value)]; // will call handler break; } } } self->_actionSheet = nil; }; } RCT_EXPORT_METHOD(reload) { [_bridge reload]; } RCT_EXPORT_METHOD(debugRemotely:(BOOL)enableDebug) { Class jsDebuggingExecutorClass = NSClassFromString(@"RCTWebSocketExecutor"); self.executorClass = enableDebug ? jsDebuggingExecutorClass : nil; } - (void)setShakeToShow:(BOOL)shakeToShow { _shakeToShow = shakeToShow; [self updateSetting:@"shakeToShow" value:@(_shakeToShow)]; } RCT_EXPORT_METHOD(setProfilingEnabled:(BOOL)enabled) { _profilingEnabled = enabled; [self updateSetting:@"profilingEnabled" value:@(_profilingEnabled)]; if (_liveReloadURL && enabled != RCTProfileIsProfiling()) { if (enabled) { [_bridge startProfiling]; } else { [_bridge stopProfiling:^(NSData *logData) { RCTProfileSendResult(self->_bridge, @"systrace", logData); }]; } } } RCT_EXPORT_METHOD(setLiveReloadEnabled:(BOOL)enabled) { _liveReloadEnabled = enabled; [self updateSetting:@"liveReloadEnabled" value:@(_liveReloadEnabled)]; if (_liveReloadEnabled) { [self checkForUpdates]; } else { [_updateTask cancel]; _updateTask = nil; } } - (BOOL)hotLoadingAvailable { return _bridge.bundleURL && !_bridge.bundleURL.fileURL; // Only works when running from server } RCT_EXPORT_METHOD(setHotLoadingEnabled:(BOOL)enabled) { _hotLoadingEnabled = enabled; [self updateSetting:@"hotLoadingEnabled" value:@(_hotLoadingEnabled)]; BOOL actuallyEnabled = [self hotLoadingAvailable] && _hotLoadingEnabled; if (RCTGetURLQueryParam(_bridge.bundleURL, @"hot").boolValue != actuallyEnabled) { _bridge.bundleURL = RCTURLByReplacingQueryParam(_bridge.bundleURL, @"hot", actuallyEnabled ? @"true" : nil); [_bridge reload]; } } - (void)setExecutorClass:(Class)executorClass { if (_executorClass != executorClass) { _executorClass = executorClass; _executorOverride = nil; [self updateSetting:@"executorClass" value:NSStringFromClass(executorClass)]; } if (_bridge.executorClass != executorClass) { // TODO (6929129): we can remove this special case test once we have better // support for custom executors in the dev menu. But right now this is // needed to prevent overriding a custom executor with the default if a // custom executor has been set directly on the bridge if (executorClass == Nil && _bridge.executorClass != objc_lookUpClass("RCTWebSocketExecutor")) { return; } _bridge.executorClass = executorClass; [_bridge reload]; } } - (void)setShowFPS:(BOOL)showFPS { _showFPS = showFPS; [self updateSetting:@"showFPS" value:@(showFPS)]; } - (void)checkForUpdates { if (!_jsLoaded || !_liveReloadEnabled || !_liveReloadURL) { return; } if (_updateTask) { return; } __weak RCTDevMenu *weakSelf = self; _updateTask = [[NSURLSession sharedSession] dataTaskWithURL:_liveReloadURL completionHandler: ^(__unused NSData *data, NSURLResponse *response, NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ RCTDevMenu *strongSelf = weakSelf; if (strongSelf && strongSelf->_liveReloadEnabled) { NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response; if (!error && HTTPResponse.statusCode == 205) { [strongSelf reload]; } else { if (error.code != NSURLErrorCancelled) { strongSelf->_updateTask = nil; [strongSelf checkForUpdates]; } } } }); }]; [_updateTask resume]; } - (BOOL)isActionSheetShown { return _actionSheet != nil; } @end #else // Unavailable when not in dev mode @implementation RCTDevMenu - (void)show {} - (void)reload {} - (void)addItem:(NSString *)title handler:(dispatch_block_t)handler {} - (void)addItem:(RCTDevMenu *)item {} - (BOOL)isActionSheetShown { return NO; } @end @implementation RCTDevMenuItem + (instancetype)buttonItemWithTitle:(NSString *)title handler:(void(^)(void))handler {return nil;} + (instancetype)toggleItemWithKey:(NSString *)key title:(NSString *)title selectedTitle:(NSString *)selectedTitle handler:(void(^)(BOOL selected))handler {return nil;} @end #endif @implementation RCTBridge (RCTDevMenu) - (RCTDevMenu *)devMenu { #if RCT_DEV return [self moduleForClass:[RCTDevMenu class]]; #else return nil; #endif } @end