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