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