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 public var expoApiClient: DevMenuExpoApiClientProtocol = DevMenuExpoApiClient() 60 61 /** 62 Shared singleton instance. 63 */ 64 @objc 65 static public let shared = DevMenuManager() 66 /** 67 The window that controls and displays the dev menu view. 68 */ 69 var window: DevMenuWindow? 70 71 /** 72 `DevMenuAppInstance` instance that is responsible for initializing and managing React Native context for the dev menu. 73 */ 74 lazy var appInstance: DevMenuAppInstance = DevMenuAppInstance(manager: self) 75 76 var currentScreen: String? 77 78 /** 79 For backwards compatibility in projects that call this method from AppDelegate 80 */ 81 @available(*, deprecated, message: "Manual setup of DevMenuManager in AppDelegate is deprecated in favor of automatic setup with Expo Modules") 82 @objc 83 public static func configure(withBridge bridge: AnyObject) { } 84 85 @objc 86 public var currentBridge: RCTBridge? { 87 didSet { 88 guard self.canLaunchDevMenuOnStart && (DevMenuPreferences.showsAtLaunch || self.shouldShowOnboarding()), let bridge = currentBridge else { 89 return 90 } 91 92 if bridge.isLoading { 93 NotificationCenter.default.addObserver(self, selector: #selector(DevMenuManager.autoLaunch), name: DevMenuViewController.ContentDidAppearNotification, object: nil) 94 } else { 95 autoLaunch() 96 } 97 } 98 } 99 @objc 100 public var currentManifest: EXManifestsManifestBehavior? 101 102 @objc 103 public var currentManifestURL: URL? 104 105 106 @objc 107 public func setSession(_ session: String) { 108 self.expoApiClient.setSessionSecret(session) 109 } 110 111 @objc 112 public func autoLaunch(_ shouldRemoveObserver: Bool = true) { 113 NotificationCenter.default.removeObserver(self) 114 115 DispatchQueue.main.async { 116 self.openMenu() 117 } 118 } 119 120 override init() { 121 super.init() 122 self.window = DevMenuWindow(manager: self) 123 self.packagerConnectionHandler = DevMenuPackagerConnectionHandler(manager: self) 124 self.packagerConnectionHandler?.setup() 125 DevMenuPreferences.setup() 126 self.readAutoLaunchDisabledState() 127 } 128 129 /** 130 Whether the dev menu window is visible on the device screen. 131 */ 132 @objc 133 public var isVisible: Bool { 134 return Dispatch.mainSync { !(window?.isHidden ?? true) } 135 } 136 137 /** 138 Opens up the dev menu. 139 */ 140 @objc 141 @discardableResult 142 public func openMenu(_ screen: String? = nil) -> Bool { 143 return setVisibility(true, screen: screen) 144 } 145 146 @objc 147 @discardableResult 148 public func openMenu() -> Bool { 149 appInstance.sendOpenEvent() 150 return openMenu(nil) 151 } 152 153 /** 154 Sends an event to JS to start collapsing the dev menu bottom sheet. 155 */ 156 @objc 157 @discardableResult 158 public func closeMenu() -> Bool { 159 if isVisible { 160 appInstance.sendCloseEvent() 161 return true 162 } 163 164 return false 165 } 166 167 /** 168 Forces the dev menu to hide. Called by JS once collapsing the bottom sheet finishes. 169 */ 170 @objc 171 @discardableResult 172 public func hideMenu() -> Bool { 173 return setVisibility(false) 174 } 175 176 /** 177 Toggles the visibility of the dev menu. 178 */ 179 @objc 180 @discardableResult 181 public func toggleMenu() -> Bool { 182 return isVisible ? closeMenu() : openMenu() 183 } 184 185 @objc 186 public func setCurrentScreen(_ screenName: String?) { 187 currentScreen = screenName 188 } 189 190 @objc 191 public func sendEventToDelegateBridge(_ eventName: String, data: Any?) { 192 guard let bridge = currentBridge else { 193 return 194 } 195 196 let args = data == nil ? [eventName] : [eventName, data!] 197 bridge.enqueueJSCall("RCTDeviceEventEmitter.emit", args: args) 198 } 199 200 // MARK: internals 201 202 func dispatchCallable(withId id: String, args: [String: Any]?) { 203 for callable in devMenuCallable { 204 if callable.id == id { 205 switch callable { 206 case let action as DevMenuExportedAction: 207 if args != nil { 208 NSLog("[DevMenu] Action $@ was called with arguments.", id) 209 } 210 action.call() 211 case let function as DevMenuExportedFunction: 212 function.call(args: args) 213 default: 214 NSLog("[DevMenu] Callable $@ has unknown type.", id) 215 } 216 } 217 } 218 } 219 220 /** 221 Returns an array of modules conforming to `DevMenuExtensionProtocol`. 222 Bridge may register multiple modules with the same name – in this case it returns only the one that overrides the others. 223 */ 224 var extensions: [DevMenuExtensionProtocol]? { 225 guard let bridge = currentBridge else { 226 return nil 227 } 228 let allExtensions = bridge.modulesConforming(to: DevMenuExtensionProtocol.self) as! [DevMenuExtensionProtocol] 229 230 let uniqueExtensionNames = Set( 231 allExtensions 232 .map { type(of: $0).moduleName!() } 233 .compactMap { $0 } // removes nils 234 ).sorted() 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 309 return true 310 } 311 312 /** 313 Returns bool value whether the onboarding view should be displayed by the dev menu view. 314 */ 315 func shouldShowOnboarding() -> Bool { 316 return !DevMenuPreferences.isOnboardingFinished 317 } 318 319 func readAutoLaunchDisabledState() { 320 let userDefaultsValue = UserDefaults.standard.bool(forKey: "EXDevMenuDisableAutoLaunch") 321 if userDefaultsValue { 322 self.canLaunchDevMenuOnStart = false 323 UserDefaults.standard.removeObject(forKey: "EXDevMenuDisableAutoLaunch") 324 } else { 325 self.canLaunchDevMenuOnStart = true 326 } 327 } 328 329 @available(iOS 12.0, *) 330 var userInterfaceStyle: UIUserInterfaceStyle { 331 return UIUserInterfaceStyle.unspecified 332 } 333 334 335 // MARK: private 336 337 private func loadDevMenuItems(forExtension ext: DevMenuExtensionProtocol) -> DevMenuItemsContainerProtocol? { 338 if let itemsContainer = extensionToDevMenuItemsMap.object(forKey: ext) { 339 return itemsContainer 340 } 341 342 if let itemsContainer = ext.devMenuItems?(extensionSettings) { 343 extensionToDevMenuItemsMap.setObject(itemsContainer, forKey: ext) 344 return itemsContainer 345 } 346 347 return nil 348 } 349 350 private func loadDevMenuScreens(forExtension ext: DevMenuExtensionProtocol) -> [DevMenuScreen]? { 351 if let screenContainer = extensionToDevMenuScreensMap.object(forKey: ext) { 352 return screenContainer.items 353 } 354 355 if let screens = ext.devMenuScreens?(extensionSettings) { 356 let container = DevMenuCacheContainer<DevMenuScreen>(items: screens) 357 extensionToDevMenuScreensMap.setObject(container, forKey: ext) 358 return screens 359 } 360 361 return nil 362 } 363 364 private func loadDevMenuDataSources(forExtension ext: DevMenuExtensionProtocol) -> [DevMenuDataSourceProtocol]? { 365 if let dataSourcesContainer = extensionToDevMenuDataSourcesMap.object(forKey: ext) { 366 return dataSourcesContainer.items 367 } 368 369 if let dataSources = ext.devMenuDataSources?(extensionSettings) { 370 let container = DevMenuCacheContainer<DevMenuDataSourceProtocol>(items: dataSources) 371 extensionToDevMenuDataSourcesMap.setObject(container, forKey: ext) 372 return dataSources 373 } 374 375 return nil 376 } 377 378 private func setVisibility(_ visible: Bool, screen: String? = nil) -> Bool { 379 if !canChangeVisibility(to: visible) { 380 return false 381 } 382 if visible { 383 guard currentBridge != nil else { 384 debugPrint("DevMenuManager: There is no bridge to render DevMenu.") 385 return false 386 } 387 setCurrentScreen(screen) 388 DispatchQueue.main.async { self.window?.makeKeyAndVisible() } 389 } else { 390 DispatchQueue.main.async { self.window?.isHidden = true } 391 } 392 return true 393 } 394 395 @objc 396 public func getAppInfo() -> [AnyHashable: Any] { 397 return EXDevMenuAppInfo.getAppInfo() 398 } 399 400 @objc 401 public func getDevSettings() -> [AnyHashable: Any] { 402 return EXDevMenuDevSettings.getDevSettings() 403 } 404 405 private static var fontsWereLoaded = false 406 407 @objc 408 public func loadFonts() { 409 if DevMenuManager.fontsWereLoaded { 410 return 411 } 412 DevMenuManager.fontsWereLoaded = true 413 414 let fonts = [ 415 "Inter-Black", 416 "Inter-ExtraBold", 417 "Inter-Bold", 418 "Inter-SemiBold", 419 "Inter-Medium", 420 "Inter-Regular", 421 "Inter-Light", 422 "Inter-ExtraLight", 423 "Inter-Thin" 424 ] 425 426 for font in fonts { 427 let path = DevMenuUtils.resourcesBundle()?.path(forResource: font, ofType: "otf") 428 let data = FileManager.default.contents(atPath: path!) 429 let provider = CGDataProvider(data: data! as CFData) 430 let font = CGFont(provider!) 431 var error: Unmanaged<CFError>? 432 CTFontManagerRegisterGraphicsFont(font!, &error) 433 } 434 } 435 436 // captures any callbacks that are registered via the `registerDevMenuItems` module method 437 // it is set and unset by the public facing `DevMenuModule` 438 // when the DevMenuModule instance is unloaded (e.g between app loads) the callback list is reset to an empty array 439 @objc 440 public var registeredCallbacks: [String] = [] 441 442 } 443