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