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 public class Callback { 56 let name: String 57 let shouldCollapse: Bool 58 59 init(name: String, shouldCollapse: Bool) { 60 self.name = name 61 self.shouldCollapse = shouldCollapse 62 } 63 } 64 65 var packagerConnectionHandler: DevMenuPackagerConnectionHandler? 66 lazy var extensionSettings: DevMenuExtensionSettingsProtocol = DevMenuExtensionDefaultSettings(manager: self) 67 var canLaunchDevMenuOnStart = true 68 69 static public var wasInitilized = false 70 71 /** 72 Shared singleton instance. 73 */ 74 @objc 75 static public let shared: DevMenuManager = { 76 wasInitilized = true 77 return DevMenuManager() 78 }() 79 80 /** 81 The window that controls and displays the dev menu view. 82 */ 83 var window: DevMenuWindow? 84 85 /** 86 `DevMenuAppInstance` instance that is responsible for initializing and managing React Native context for the dev menu. 87 */ 88 lazy var appInstance: DevMenuAppInstance = DevMenuAppInstance(manager: self) 89 90 var currentScreen: String? 91 92 /** 93 For backwards compatibility in projects that call this method from AppDelegate 94 */ 95 @available(*, deprecated, message: "Manual setup of DevMenuManager in AppDelegate is deprecated in favor of automatic setup with Expo Modules") 96 @objc configurenull97 public static func configure(withBridge bridge: AnyObject) { } 98 99 @objc 100 public var currentBridge: RCTBridge? { 101 didSet { 102 guard self.canLaunchDevMenuOnStart && (DevMenuPreferences.showsAtLaunch || self.shouldShowOnboarding()), let bridge = currentBridge else { 103 return 104 } 105 106 if bridge.isLoading { 107 NotificationCenter.default.addObserver(self, selector: #selector(DevMenuManager.autoLaunch), name: DevMenuViewController.ContentDidAppearNotification, object: nil) 108 } else { 109 autoLaunch() 110 } 111 } 112 } 113 @objc 114 public var currentManifest: Manifest? 115 116 @objc 117 public var currentManifestURL: URL? 118 119 @objc autoLaunchnull120 public func autoLaunch(_ shouldRemoveObserver: Bool = true) { 121 NotificationCenter.default.removeObserver(self) 122 123 DispatchQueue.main.async { 124 self.openMenu() 125 } 126 } 127 128 override init() { 129 super.init() 130 self.window = DevMenuWindow(manager: self) 131 self.packagerConnectionHandler = DevMenuPackagerConnectionHandler(manager: self) 132 self.packagerConnectionHandler?.setup() 133 DevMenuPreferences.setup() 134 self.readAutoLaunchDisabledState() 135 } 136 137 /** 138 Whether the dev menu window is visible on the device screen. 139 */ 140 @objc 141 public var isVisible: Bool { 142 return Dispatch.mainSync { !(window?.isHidden ?? true) } 143 } 144 145 /** 146 Opens up the dev menu. 147 */ 148 @objc 149 @discardableResult openMenunull150 public func openMenu(_ screen: String? = nil) -> Bool { 151 return setVisibility(true, screen: screen) 152 } 153 154 @objc 155 @discardableResult openMenunull156 public func openMenu() -> Bool { 157 appInstance.sendOpenEvent() 158 return openMenu(nil) 159 } 160 161 /** 162 Sends an event to JS to start collapsing the dev menu bottom sheet. 163 */ 164 @objc 165 @discardableResult 166 public func closeMenu(completion: (() -> Void)? = nil) -> Bool { 167 if isVisible { 168 if Thread.isMainThread { 169 window?.closeBottomSheet(completion: completion) 170 } else { 171 DispatchQueue.main.async { [self] in 172 window?.closeBottomSheet(completion: completion) 173 } 174 } 175 return true 176 } 177 178 return false 179 } 180 181 /** 182 Forces the dev menu to hide. Called by JS once collapsing the bottom sheet finishes. 183 */ 184 @objc 185 @discardableResult hideMenunull186 public func hideMenu() -> Bool { 187 return setVisibility(false) 188 } 189 190 /** 191 Toggles the visibility of the dev menu. 192 */ 193 @objc 194 @discardableResult toggleMenunull195 public func toggleMenu() -> Bool { 196 return isVisible ? closeMenu() : openMenu() 197 } 198 199 @objc setCurrentScreennull200 public func setCurrentScreen(_ screenName: String?) { 201 currentScreen = screenName 202 } 203 204 @objc sendEventToDelegateBridgenull205 public func sendEventToDelegateBridge(_ eventName: String, data: Any?) { 206 guard let bridge = currentBridge else { 207 return 208 } 209 210 let args = data == nil ? [eventName] : [eventName, data!] 211 bridge.enqueueJSCall("RCTDeviceEventEmitter.emit", args: args) 212 } 213 214 // MARK: internals 215 dispatchCallablenull216 func dispatchCallable(withId id: String, args: [String: Any]?) { 217 for callable in devMenuCallable { 218 if callable.id == id { 219 switch callable { 220 case let action as DevMenuExportedAction: 221 if args != nil { 222 NSLog("[DevMenu] Action $@ was called with arguments.", id) 223 } 224 action.call() 225 case let function as DevMenuExportedFunction: 226 function.call(args: args) 227 default: 228 NSLog("[DevMenu] Callable $@ has unknown type.", id) 229 } 230 } 231 } 232 } 233 234 /** 235 Returns an array of modules conforming to `DevMenuExtensionProtocol`. 236 Bridge may register multiple modules with the same name – in this case it returns only the one that overrides the others. 237 */ 238 var extensions: [DevMenuExtensionProtocol]? { 239 guard let bridge = currentBridge else { 240 return nil 241 } 242 let allExtensions = bridge.modulesConforming(to: DevMenuExtensionProtocol.self) as! [DevMenuExtensionProtocol] 243 244 let uniqueExtensionNames = Set( 245 allExtensions 246 .map { type(of: $0).moduleName!() } 247 .compactMap { $0 } // removes nils 248 ).sorted() 249 250 return uniqueExtensionNames 251 .map({ bridge.module(forName: DevMenuUtils.stripRCT($0)) }) 252 .filter({ $0 is DevMenuExtensionProtocol }) as? [DevMenuExtensionProtocol] 253 } 254 255 /** 256 Gathers `DevMenuItem`s from all dev menu extensions and returns them as an array. 257 */ 258 var devMenuItems: [DevMenuScreenItem] { 259 return extensions?.map { loadDevMenuItems(forExtension: $0)?.getAllItems() ?? [] }.flatMap { $0 } ?? [] 260 } 261 262 /** 263 Gathers root `DevMenuItem`s (elements on the main screen) from all dev menu extensions and returns them as an array. 264 */ 265 var devMenuRootItems: [DevMenuScreenItem] { 266 return extensions?.map { loadDevMenuItems(forExtension: $0)?.getRootItems() ?? [] }.flatMap { $0 } ?? [] 267 } 268 269 /** 270 Gathers `DevMenuScreen`s from all dev menu extensions and returns them as an array. 271 */ 272 var devMenuScreens: [DevMenuScreen] { 273 return extensions?.map { loadDevMenuScreens(forExtension: $0) ?? [] }.flatMap { $0 } ?? [] 274 } 275 276 /** 277 Gathers `DevMenuDataSourceProtocol`s from all dev menu extensions and returns them as an array. 278 */ 279 var devMenuDataSources: [DevMenuDataSourceProtocol] { 280 return extensions?.map { loadDevMenuDataSources(forExtension: $0) ?? [] }.flatMap { $0 } ?? [] 281 } 282 283 /** 284 Returns an array of `DevMenuExportedCallable`s returned by the dev menu extensions. 285 */ 286 var devMenuCallable: [DevMenuExportedCallable] { 287 let providers = currentScreen == nil ? 288 devMenuItems.filter { $0 is DevMenuCallableProvider } : 289 (devMenuScreens.first { $0.screenName == currentScreen }?.getAllItems() ?? []).filter { $0 is DevMenuCallableProvider } 290 291 // We use compactMap here to remove nils 292 return (providers as! [DevMenuCallableProvider]).compactMap { $0.registerCallable?() } 293 } 294 295 /** 296 Returns an array of dev menu items serialized to the dictionary. 297 */ serializedDevMenuItemsnull298 func serializedDevMenuItems() -> [[String: Any]] { 299 return devMenuRootItems 300 .sorted(by: { $0.importance > $1.importance }) 301 .map({ $0.serialize() }) 302 } 303 304 /** 305 Returns an array of dev menu screens serialized to the dictionary. 306 */ serializedDevMenuScreensnull307 func serializedDevMenuScreens() -> [[String: Any]] { 308 return devMenuScreens 309 .map({ $0.serialize() }) 310 } 311 312 // MARK: delegate stubs 313 314 /** 315 Returns a bool value whether the dev menu can change its visibility. 316 Returning `false` entirely disables the dev menu. 317 */ canChangeVisibilitynull318 func canChangeVisibility(to visible: Bool) -> Bool { 319 if isVisible == visible { 320 return false 321 } 322 323 return true 324 } 325 326 /** 327 Returns bool value whether the onboarding view should be displayed by the dev menu view. 328 */ shouldShowOnboardingnull329 func shouldShowOnboarding() -> Bool { 330 return !DevMenuPreferences.isOnboardingFinished 331 } 332 readAutoLaunchDisabledStatenull333 func readAutoLaunchDisabledState() { 334 let userDefaultsValue = UserDefaults.standard.bool(forKey: "EXDevMenuDisableAutoLaunch") 335 if userDefaultsValue { 336 self.canLaunchDevMenuOnStart = false 337 UserDefaults.standard.removeObject(forKey: "EXDevMenuDisableAutoLaunch") 338 } else { 339 self.canLaunchDevMenuOnStart = true 340 } 341 } 342 343 @available(iOS 12.0, *) 344 var userInterfaceStyle: UIUserInterfaceStyle { 345 return UIUserInterfaceStyle.unspecified 346 } 347 348 349 // MARK: private 350 loadDevMenuItemsnull351 private func loadDevMenuItems(forExtension ext: DevMenuExtensionProtocol) -> DevMenuItemsContainerProtocol? { 352 if let itemsContainer = extensionToDevMenuItemsMap.object(forKey: ext) { 353 return itemsContainer 354 } 355 356 if let itemsContainer = ext.devMenuItems?(extensionSettings) { 357 extensionToDevMenuItemsMap.setObject(itemsContainer, forKey: ext) 358 return itemsContainer 359 } 360 361 return nil 362 } 363 loadDevMenuScreensnull364 private func loadDevMenuScreens(forExtension ext: DevMenuExtensionProtocol) -> [DevMenuScreen]? { 365 if let screenContainer = extensionToDevMenuScreensMap.object(forKey: ext) { 366 return screenContainer.items 367 } 368 369 if let screens = ext.devMenuScreens?(extensionSettings) { 370 let container = DevMenuCacheContainer<DevMenuScreen>(items: screens) 371 extensionToDevMenuScreensMap.setObject(container, forKey: ext) 372 return screens 373 } 374 375 return nil 376 } 377 loadDevMenuDataSourcesnull378 private func loadDevMenuDataSources(forExtension ext: DevMenuExtensionProtocol) -> [DevMenuDataSourceProtocol]? { 379 if let dataSourcesContainer = extensionToDevMenuDataSourcesMap.object(forKey: ext) { 380 return dataSourcesContainer.items 381 } 382 383 if let dataSources = ext.devMenuDataSources?(extensionSettings) { 384 let container = DevMenuCacheContainer<DevMenuDataSourceProtocol>(items: dataSources) 385 extensionToDevMenuDataSourcesMap.setObject(container, forKey: ext) 386 return dataSources 387 } 388 389 return nil 390 } 391 setVisibilitynull392 private func setVisibility(_ visible: Bool, screen: String? = nil) -> Bool { 393 if !canChangeVisibility(to: visible) { 394 return false 395 } 396 if visible { 397 guard currentBridge != nil else { 398 debugPrint("DevMenuManager: There is no bridge to render DevMenu.") 399 return false 400 } 401 setCurrentScreen(screen) 402 DispatchQueue.main.async { self.window?.makeKeyAndVisible() } 403 } else { 404 DispatchQueue.main.async { self.window?.isHidden = true } 405 } 406 return true 407 } 408 409 @objc getAppInfonull410 public func getAppInfo() -> [AnyHashable: Any] { 411 return EXDevMenuAppInfo.getAppInfo() 412 } 413 414 @objc getDevSettingsnull415 public func getDevSettings() -> [AnyHashable: Any] { 416 return EXDevMenuDevSettings.getDevSettings() 417 } 418 419 private static var fontsWereLoaded = false 420 421 @objc loadFontsnull422 public func loadFonts() { 423 if DevMenuManager.fontsWereLoaded { 424 return 425 } 426 DevMenuManager.fontsWereLoaded = true 427 428 let fonts = [ 429 "Inter-Black", 430 "Inter-ExtraBold", 431 "Inter-Bold", 432 "Inter-SemiBold", 433 "Inter-Medium", 434 "Inter-Regular", 435 "Inter-Light", 436 "Inter-ExtraLight", 437 "Inter-Thin" 438 ] 439 440 for font in fonts { 441 let path = DevMenuUtils.resourcesBundle()?.path(forResource: font, ofType: "otf") 442 let data = FileManager.default.contents(atPath: path!) 443 let provider = CGDataProvider(data: data! as CFData) 444 let font = CGFont(provider!) 445 var error: Unmanaged<CFError>? 446 CTFontManagerRegisterGraphicsFont(font!, &error) 447 } 448 } 449 450 // captures any callbacks that are registered via the `registerDevMenuItems` module method 451 // it is set and unset by the public facing `DevMenuModule` 452 // when the DevMenuModule instance is unloaded (e.g between app loads) the callback list is reset to an empty array 453 public var registeredCallbacks: [Callback] = [] 454 } 455