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