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