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