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