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