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