1 // Copyright 2015-present 650 Industries. All rights reserved. 2 3 import EXDevMenuInterface 4 5 class DevMenuBridgeProxyDelegate : DevMenuDelegateProtocol { 6 private let bridge: RCTBridge 7 8 init(_ bridge: RCTBridge) { 9 self.bridge = bridge 10 } 11 12 public func appBridge(forDevMenuManager manager: DevMenuManagerProtocol) -> AnyObject? { 13 return self.bridge; 14 } 15 } 16 17 class Dispatch { 18 static func mainSync<T>(_ closure: () -> T) -> T { 19 if Thread.isMainThread { 20 return closure() 21 } else { 22 var result: T? 23 DispatchQueue.main.sync { 24 result = closure() 25 } 26 return result! 27 } 28 } 29 } 30 31 /** 32 A container for array. 33 NSMapTable requires the second generic type to be a class, so `[DevMenuScreen]` is not allowed. 34 */ 35 class DevMenuCacheContainer<T> { 36 fileprivate let items: [T] 37 38 fileprivate init(items: [T]) { 39 self.items = items 40 } 41 } 42 43 /** 44 A hash map storing an array of dev menu items for specific extension. 45 */ 46 private let extensionToDevMenuItemsMap = NSMapTable<DevMenuExtensionProtocol, DevMenuItemsContainerProtocol>.weakToStrongObjects() 47 48 /** 49 A hash map storing an array of dev menu screens for specific extension. 50 */ 51 private let extensionToDevMenuScreensMap = NSMapTable<DevMenuExtensionProtocol, DevMenuCacheContainer<DevMenuScreen>>.weakToStrongObjects() 52 53 /** 54 A hash map storing an array of dev menu screens for specific extension. 55 */ 56 private let extensionToDevMenuDataSourcesMap = NSMapTable<DevMenuExtensionProtocol, DevMenuCacheContainer<DevMenuDataSourceProtocol>>.weakToStrongObjects() 57 58 /** 59 Manages the dev menu and provides most of the public API. 60 */ 61 @objc 62 open class DevMenuManager: NSObject, DevMenuManagerProtocol { 63 var packagerConnectionHandler: DevMenuPackagerConnectionHandler? 64 lazy var expoSessionDelegate: DevMenuExpoSessionDelegate = DevMenuExpoSessionDelegate(manager: self) 65 lazy var extensionSettings: DevMenuExtensionSettingsProtocol = DevMenuExtensionDefaultSettings(manager: self) 66 67 public var expoApiClient: DevMenuExpoApiClientProtocol = DevMenuExpoApiClient() 68 69 /** 70 Shared singleton instance. 71 */ 72 @objc 73 static public let shared = DevMenuManager() 74 75 /** 76 The window that controls and displays the dev menu view. 77 */ 78 var window: DevMenuWindow? 79 80 /** 81 `DevMenuAppInstance` instance that is responsible for initializing and managing React Native context for the dev menu. 82 */ 83 lazy var appInstance: DevMenuAppInstance = DevMenuAppInstance(manager: self) 84 85 /** 86 Instance of `DevMenuSession` that keeps the details of the currently opened dev menu session. 87 */ 88 public private(set) var session: DevMenuSession? 89 90 var currentScreen: String? 91 92 /** 93 The delegate of `DevMenuManager` implementing `DevMenuDelegateProtocol`. 94 */ 95 @objc 96 public var delegate: DevMenuDelegateProtocol? { 97 didSet { 98 guard DevMenuSettings.showsAtLaunch || !DevMenuSettings.isOnboardingFinished, let bridge = delegate?.appBridge?(forDevMenuManager: self) as? RCTBridge else { 99 return 100 } 101 if bridge.isLoading { 102 NotificationCenter.default.addObserver(self, selector: #selector(DevMenuManager.autoLaunch), name: DevMenuViewController.ContentDidAppearNotification, object: nil) 103 } else { 104 autoLaunch() 105 } 106 } 107 } 108 109 @objc 110 public static func configure(withBridge bridge: AnyObject) { 111 if let bridge = bridge as? RCTBridge { 112 shared.delegate = DevMenuBridgeProxyDelegate(bridge) 113 } else { 114 fatalError("Cound't cast to RCTBrigde. Make sure that you passed `RCTBridge` to `DevMenuManager.initializeWithBridge`.") 115 } 116 } 117 118 @objc 119 public func autoLaunch(_ shouldRemoveObserver: Bool = true) { 120 NotificationCenter.default.removeObserver(self) 121 122 DispatchQueue.main.async { 123 self.openMenu() 124 } 125 } 126 127 override init() { 128 super.init() 129 self.window = DevMenuWindow(manager: self) 130 self.packagerConnectionHandler = DevMenuPackagerConnectionHandler(manager: self) 131 self.packagerConnectionHandler?.setup() 132 DevMenuSettings.setup() 133 self.expoSessionDelegate.restoreSession() 134 } 135 136 /** 137 Whether the dev menu window is visible on the device screen. 138 */ 139 @objc 140 public var isVisible: Bool { 141 return Dispatch.mainSync { !(window?.isHidden ?? true) } 142 } 143 144 /** 145 Opens up the dev menu. 146 */ 147 @objc 148 @discardableResult 149 public func openMenu(_ screen: String? = nil) -> Bool { 150 return setVisibility(true, screen: screen) 151 } 152 153 @objc 154 @discardableResult 155 public func openMenu() -> Bool { 156 return openMenu(nil) 157 } 158 159 /** 160 Sends an event to JS to start collapsing the dev menu bottom sheet. 161 */ 162 @objc 163 @discardableResult 164 public func closeMenu() -> Bool { 165 if (isVisible) { 166 appInstance.sendCloseEvent() 167 return true 168 } 169 170 return false 171 } 172 173 /** 174 Forces the dev menu to hide. Called by JS once collapsing the bottom sheet finishes. 175 */ 176 @objc 177 @discardableResult 178 public func hideMenu() -> Bool { 179 return setVisibility(false) 180 } 181 182 /** 183 Toggles the visibility of the dev menu. 184 */ 185 @objc 186 @discardableResult 187 public func toggleMenu() -> Bool { 188 return isVisible ? closeMenu() : openMenu() 189 } 190 191 @objc 192 public func setCurrentScreen(_ screenName: String?) { 193 currentScreen = screenName 194 } 195 196 @objc 197 public func sendEventToDelegateBridge(_ eventName: String, data: Any?) { 198 guard let bridge = delegate?.appBridge?(forDevMenuManager: self) as? RCTBridge else { 199 return; 200 } 201 202 let args = data == nil ? [eventName] : [eventName, data!]; 203 bridge.enqueueJSCall("RCTDeviceEventEmitter.emit", args: args) 204 } 205 206 // MARK: internals 207 208 func dispatchCallable(withId id: String, args: [String : Any]?) { 209 for callable in devMenuCallable { 210 if callable.id == id { 211 switch callable { 212 case let action as DevMenuExportedAction: 213 if (args != nil) { 214 NSLog("[DevMenu] Action $@ was called with arguments.", id) 215 } 216 action.call() 217 case let function as DevMenuExportedFunction: 218 function.call(args: args) 219 default: 220 NSLog("[DevMenu] Callable $@ has unknown type.", id) 221 } 222 223 } 224 } 225 } 226 227 /** 228 Returns an array of modules conforming to `DevMenuExtensionProtocol`. 229 Bridge may register multiple modules with the same name – in this case it returns only the one that overrides the others. 230 */ 231 var extensions: [DevMenuExtensionProtocol]? { 232 guard let bridge = session?.bridge else { 233 return nil 234 } 235 let allExtensions = bridge.modulesConforming(to: DevMenuExtensionProtocol.self) as! [DevMenuExtensionProtocol] 236 237 let uniqueExtensionNames = Set( 238 allExtensions 239 .map { type(of: $0).moduleName!() } 240 .compactMap { $0 } // removes nils 241 ).sorted() 242 243 return uniqueExtensionNames 244 .map({ bridge.module(forName: DevMenuUtils.stripRCT($0)) }) 245 .filter({ $0 is DevMenuExtensionProtocol }) as? [DevMenuExtensionProtocol] 246 } 247 248 /** 249 Gathers `DevMenuItem`s from all dev menu extensions and returns them as an array. 250 */ 251 var devMenuItems: [DevMenuScreenItem] { 252 return extensions?.map { loadDevMenuItems(forExtension: $0)?.getAllItems() ?? [] }.flatMap { $0 } ?? [] 253 } 254 255 /** 256 Gathers root `DevMenuItem`s (elements on the main screen) from all dev menu extensions and returns them as an array. 257 */ 258 var devMenuRootItems: [DevMenuScreenItem] { 259 return extensions?.map { loadDevMenuItems(forExtension: $0)?.getRootItems() ?? [] }.flatMap { $0 } ?? [] 260 } 261 262 /** 263 Gathers `DevMenuScreen`s from all dev menu extensions and returns them as an array. 264 */ 265 var devMenuScreens: [DevMenuScreen] { 266 return extensions?.map { loadDevMenuScreens(forExtension: $0) ?? [] }.flatMap {$0} ?? [] 267 } 268 269 /** 270 Gathers `DevMenuDataSourceProtocol`s from all dev menu extensions and returns them as an array. 271 */ 272 var devMenuDataSources: [DevMenuDataSourceProtocol] { 273 return extensions?.map { loadDevMenuDataSources(forExtension: $0) ?? [] }.flatMap {$0} ?? [] 274 } 275 276 /** 277 Returns an array of `DevMenuExportedCallable`s returned by the dev menu extensions. 278 */ 279 var devMenuCallable: [DevMenuExportedCallable] { 280 let providers = currentScreen == nil ? 281 devMenuItems.filter { $0 is DevMenuCallableProvider } : 282 (devMenuScreens.first { $0.screenName == currentScreen }?.getAllItems() ?? []).filter { $0 is DevMenuCallableProvider } 283 284 // We use compactMap here to remove nils 285 return (providers as! [DevMenuCallableProvider]).compactMap { $0.registerCallable?() } 286 } 287 288 /** 289 Returns an array of dev menu items serialized to the dictionary. 290 */ 291 func serializedDevMenuItems() -> [[String : Any]] { 292 return devMenuRootItems 293 .sorted(by: { $0.importance > $1.importance }) 294 .map({ $0.serialize() }) 295 } 296 297 /** 298 Returns an array of dev menu screens serialized to the dictionary. 299 */ 300 func serializedDevMenuScreens() -> [[String : Any]] { 301 return devMenuScreens 302 .map({ $0.serialize() }) 303 } 304 305 // MARK: delegate stubs 306 307 /** 308 Returns a bool value whether the dev menu can change its visibility. 309 Returning `false` entirely disables the dev menu. 310 */ 311 func canChangeVisibility(to visible: Bool) -> Bool { 312 if isVisible == visible { 313 return false 314 } 315 return delegate?.devMenuManager?(self, canChangeVisibility: visible) ?? true 316 } 317 318 /** 319 Returns bool value whether the onboarding view should be displayed by the dev menu view. 320 */ 321 func shouldShowOnboarding() -> Bool { 322 return delegate?.shouldShowOnboarding?(manager: self) ?? !DevMenuSettings.isOnboardingFinished 323 } 324 325 @available(iOS 12.0, *) 326 var userInterfaceStyle: UIUserInterfaceStyle { 327 return delegate?.userInterfaceStyle?(forDevMenuManager: self) ?? UIUserInterfaceStyle.unspecified 328 } 329 330 // MARK: private 331 332 private func loadDevMenuItems(forExtension ext: DevMenuExtensionProtocol) -> DevMenuItemsContainerProtocol? { 333 if let itemsContainer = extensionToDevMenuItemsMap.object(forKey: ext) { 334 return itemsContainer 335 } 336 337 if let itemsContainer = ext.devMenuItems?(extensionSettings) { 338 extensionToDevMenuItemsMap.setObject(itemsContainer, forKey: ext) 339 return itemsContainer 340 } 341 342 return nil 343 } 344 345 private func loadDevMenuScreens(forExtension ext: DevMenuExtensionProtocol) -> [DevMenuScreen]? { 346 if let screenContainer = extensionToDevMenuScreensMap.object(forKey: ext) { 347 return screenContainer.items 348 } 349 350 if let screens = ext.devMenuScreens?(extensionSettings) { 351 let container = DevMenuCacheContainer<DevMenuScreen>(items: screens) 352 extensionToDevMenuScreensMap.setObject(container, forKey: ext) 353 return screens 354 } 355 356 return nil; 357 } 358 359 private func loadDevMenuDataSources(forExtension ext: DevMenuExtensionProtocol) -> [DevMenuDataSourceProtocol]? { 360 if let dataSourcesContainer = extensionToDevMenuDataSourcesMap.object(forKey: ext) { 361 return dataSourcesContainer.items 362 } 363 364 if let dataSources = ext.devMenuDataSources?(extensionSettings) { 365 let container = DevMenuCacheContainer<DevMenuDataSourceProtocol>(items: dataSources) 366 extensionToDevMenuDataSourcesMap.setObject(container, forKey: ext) 367 return dataSources 368 } 369 370 return nil 371 } 372 373 private func setVisibility(_ visible: Bool, screen: String? = nil) -> Bool { 374 if !canChangeVisibility(to: visible) { 375 return false 376 } 377 if visible { 378 guard let bridge = delegate?.appBridge?(forDevMenuManager: self) as? RCTBridge else { 379 debugPrint("DevMenuManager: The delegate is unset or it didn't provide a bridge to render for.") 380 return false 381 } 382 session = DevMenuSession(bridge: bridge, appInfo: delegate?.appInfo?(forDevMenuManager: self), screen: screen) 383 setCurrentScreen(screen) 384 DispatchQueue.main.async { self.window?.makeKeyAndVisible() } 385 } else { 386 session = nil 387 DispatchQueue.main.async { self.window?.isHidden = true } 388 } 389 return true 390 } 391 } 392