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